diff --git a/assets/design/recovery_warning.svg b/assets/design/recovery_warning.svg new file mode 100644 index 00000000..a82f734d --- /dev/null +++ b/assets/design/recovery_warning.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/design/reset_success.svg b/assets/design/reset_success.svg new file mode 100644 index 00000000..ad723991 --- /dev/null +++ b/assets/design/reset_success.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/docs/design/redesign-20260923/flutter/README.md b/docs/design/redesign-20260923/flutter/README.md new file mode 100644 index 00000000..978c8e62 --- /dev/null +++ b/docs/design/redesign-20260923/flutter/README.md @@ -0,0 +1,60 @@ +# Figma v3 → Flutter 반영 + +2026-09-23 원본 교체가 끝난 Figma 26개 화면의 레이아웃과 상태를 기존 Flutter 경로에 반영했다. 데이터 생성·반복 계산·저장·백업의 도메인 동작과 기본값은 유지한다. + +## 적용 내용 + +- 기존 `TopBar`, `ModalWideButton`, `StepProgress`, 색상 토큰을 확장해 중앙 헤더, 하단 고정 액션, 번호 단계, 선택·정보 카드를 구성했다. 주색은 기존 blue600, 보조색은 blue200을 사용한다. +- 반복 단위, 주간 요일, 월간 규칙, 종료 날짜/횟수에 실제 폼 상태를 연결했다. 월간 순번과 마지막 요일을 분리해 선택할 수 있다. +- 검토의 예정 회차/충돌/영구 충돌/분리 일정/서머타임/저장 오류를 원인별로 표시한다. 모든 회차 제외 시 저장 금지, 독립 준비과정, 기존 데이터 보존을 유지한다. +- 반복 관리의 카드/빈 상태/상세/종료 확인과 달력에서 여는 회차 상세를 연결했다. 지난 회차의 편집 제한은 기존 정책을 따른다. +- 준비 목록의 순서 변경과 기존 준비 편집 화면을 연결하고, 총 준비 시간에서 여유 시간을 제외한다. +- 마이페이지를 알림·시간·일정·데이터·기타로 묶었다. 값은 실제 use case/운영체제 권한에서 읽으며 백업·복원·초기화 항목은 기존 동작으로 연결된다. +- 복구 경고/초기화 확인/처리 중/실패/완료 및 개인정보 6개 조항을 적용했다. 복구 경고와 완료 표식은 Figma SVG를 로컬 asset으로 사용한다. + +## 검증과 시각 자료 + +- `flutter analyze --no-pub`: 오류 없음. +- 전체 테스트 **603개 통과**. 최종 명령과 결과는 [validation.txt](validation.txt)에 기록했다. +- 390×844 Flutter 위젯 렌더 26개를 아래에 저장했다. 실제 iOS/Android 기기 캡처가 아니다. 테스트의 날짜·준비 단계·설정값은 fixture이며 제품 기본값을 바꾸지 않는다. +- 320px 너비와 글자 1.6배에서 반복·복구 관련 상태를 검사하고, 긴 하단 액션은 필요 시 세로 배치한다. +- `comparison-*.png`: 왼쪽 Figma 원본, 오른쪽 Flutter 렌더. `contact-*.png`: Flutter 렌더 모음. + +## 적용 시 유지한 차이 + +- 앱은 기존 합의대로 Pretendard를 유지한다. Figma 원격 환경의 Noto Sans KR/IBM Plex Sans KR와 글자 폭이 다를 수 있다. +- 상태 표시줄과 홈 표시줄은 운영체제가 그린다. Flutter 캡처에는 가짜 9:41/배터리/홈 막대를 그리지 않는다. 생성 폼 캡처는 실제 입력 위젯을 독립 테스트 화면에 올린 것으로, 홈 배경과 모달 바깥 부분은 재현하지 않는다. +- 월간 규칙/종료 설정은 기존 폼 안의 편집 상태로 이어지며, 장소·이동을 포함한 기존 4단계를 유지한다. 데이터량·글자 크기에 따라 본문을 스크롤한다. +- 초기화 완료 후 기존 저장소가 닫히는 생명주기를 유지한다. '다시 시작 안내' 버튼은 앱을 완전히 종료한 뒤 다시 열도록 안내한다. 화면만 온보딩으로 이동해 닫힌 저장소를 재사용하지 않는다. +- 실제 기기의 권한 팝업·알림 전달·초기화 후 재실행은 이번 위젯 검증 범위에 포함하지 않았다. + +## 화면과 코드 + +| 상태 | Figma 원본 | Flutter 코드 | 렌더 | +|---|---|---|---| +| date-time | [2112:2394](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2112-2394) | `lib/presentation/schedule_create/schedule_date_time/screens/schedule_date_time_form.dart` | [PNG](screenshots/date-time.png) | +| weekly | [2112:21323](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2112-21323) | `lib/presentation/recurring/recurrence_settings_sheet.dart` | [PNG](screenshots/weekly.png) | +| monthly | [2112:21352](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2112-21352) | `lib/presentation/recurring/recurrence_settings_sheet.dart` | [PNG](screenshots/monthly.png) | +| ending | [2112:21395](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2112-21395) | `lib/presentation/recurring/recurrence_settings_sheet.dart` | [PNG](screenshots/ending.png) | +| preparation | [2112:21456](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2112-21456) | `lib/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form.dart` | [PNG](screenshots/preparation.png) | +| review | [2112:21500](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2112-21500) | `lib/presentation/recurring/recurrence_review_sheet.dart` | [PNG](screenshots/review.png) | +| conflicts | [2112:21531](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2112-21531) | `lib/presentation/recurring/recurrence_review_sheet.dart` | [PNG](screenshots/conflicts.png) | +| persistent-conflict | [2112:21557](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2112-21557) | `lib/presentation/recurring/recurrence_review_sheet.dart` | [PNG](screenshots/persistent-conflict.png) | +| scope | [2112:21588](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2112-21588) | `lib/presentation/recurring/recurrence_scope_sheet.dart` | [PNG](screenshots/scope.png) | +| detached | [2112:21613](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2112-21613) | `lib/presentation/recurring/recurrence_review_sheet.dart` | [PNG](screenshots/detached.png) | +| time-exceptions | [2112:21635](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2112-21635) | `lib/presentation/recurring/recurrence_time_choice_sheet.dart` | [PNG](screenshots/time-exceptions.png) | +| save-error | [2112:21659](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2112-21659) | `lib/presentation/recurring/recurrence_time_choice_sheet.dart` | [PNG](screenshots/save-error.png) | +| frequency | [2113:21627](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2113-21627) | `lib/presentation/recurring/recurrence_settings_sheet.dart` | [PNG](screenshots/frequency.png) | +| monthly-fields | [2113:21670](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2113-21670) | `lib/presentation/recurring/recurrence_settings_sheet.dart` | [PNG](screenshots/monthly-fields.png) | +| end-date | [2113:21708](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2113-21708) | `lib/presentation/recurring/recurrence_settings_sheet.dart` | [PNG](screenshots/end-date.png) | +| end-confirm | [2113:21745](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2113-21745) | `lib/presentation/recurring/recurring_management_screen.dart` | [PNG](screenshots/end-confirm.png) | +| management | [2112:21676](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2112-21676) | `lib/presentation/recurring/recurring_management_screen.dart` | [PNG](screenshots/management.png) | +| detail | [2112:21715](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2112-21715) | `lib/presentation/recurring/recurring_management_screen.dart` | [PNG](screenshots/detail.png) | +| empty | [2112:21756](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2112-21756) | `lib/presentation/recurring/recurring_management_screen.dart` | [PNG](screenshots/empty.png) | +| occurrence | [2112:21768](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2112-21768) | `lib/presentation/recurring/recurrence_occurrence_sheet.dart` | [PNG](screenshots/occurrence.png) | +| mypage-entry | [2113:21761](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2113-21761) | `lib/presentation/my_page/my_page_screen.dart` | [PNG](screenshots/mypage-entry.png) | +| recovery | [1957:11762](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=1957-11762) | `lib/presentation/startup/screens/local_data_recovery_screen.dart` | [PNG](screenshots/recovery.png) | +| recovery-busy | [2005:12036](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2005-12036) | `lib/presentation/startup/screens/local_data_recovery_screen.dart` | [PNG](screenshots/recovery-busy.png) | +| recovery-confirm | [2003:12059](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=2003-12059) | `lib/presentation/startup/screens/local_data_recovery_screen.dart` | [PNG](screenshots/recovery-confirm.png) | +| reset-complete | [1963:11762](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=1963-11762) | `lib/presentation/my_page/my_data_screen.dart` | [PNG](screenshots/reset-complete.png) | +| privacy | [1950:11759](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA?node-id=1950-11759) | `lib/presentation/my_page/privacy_policy_screen.dart` | [PNG](screenshots/privacy.png) | diff --git a/docs/design/redesign-20260923/flutter/comparison-1.png b/docs/design/redesign-20260923/flutter/comparison-1.png new file mode 100644 index 00000000..6c627302 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/comparison-1.png differ diff --git a/docs/design/redesign-20260923/flutter/comparison-2.png b/docs/design/redesign-20260923/flutter/comparison-2.png new file mode 100644 index 00000000..ed6d7d2b Binary files /dev/null and b/docs/design/redesign-20260923/flutter/comparison-2.png differ diff --git a/docs/design/redesign-20260923/flutter/comparison-3.png b/docs/design/redesign-20260923/flutter/comparison-3.png new file mode 100644 index 00000000..8b43a15f Binary files /dev/null and b/docs/design/redesign-20260923/flutter/comparison-3.png differ diff --git a/docs/design/redesign-20260923/flutter/comparison-4.png b/docs/design/redesign-20260923/flutter/comparison-4.png new file mode 100644 index 00000000..9d8bba17 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/comparison-4.png differ diff --git a/docs/design/redesign-20260923/flutter/comparison-5.png b/docs/design/redesign-20260923/flutter/comparison-5.png new file mode 100644 index 00000000..b72ca2da Binary files /dev/null and b/docs/design/redesign-20260923/flutter/comparison-5.png differ diff --git a/docs/design/redesign-20260923/flutter/comparison-6.png b/docs/design/redesign-20260923/flutter/comparison-6.png new file mode 100644 index 00000000..b6922db3 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/comparison-6.png differ diff --git a/docs/design/redesign-20260923/flutter/comparison-7.png b/docs/design/redesign-20260923/flutter/comparison-7.png new file mode 100644 index 00000000..4b31e640 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/comparison-7.png differ diff --git a/docs/design/redesign-20260923/flutter/contact-1.png b/docs/design/redesign-20260923/flutter/contact-1.png new file mode 100644 index 00000000..671f51a0 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/contact-1.png differ diff --git a/docs/design/redesign-20260923/flutter/contact-2.png b/docs/design/redesign-20260923/flutter/contact-2.png new file mode 100644 index 00000000..4e994ba2 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/contact-2.png differ diff --git a/docs/design/redesign-20260923/flutter/contact-3.png b/docs/design/redesign-20260923/flutter/contact-3.png new file mode 100644 index 00000000..1d68c6ef Binary files /dev/null and b/docs/design/redesign-20260923/flutter/contact-3.png differ diff --git a/docs/design/redesign-20260923/flutter/contact-4.png b/docs/design/redesign-20260923/flutter/contact-4.png new file mode 100644 index 00000000..3079fdf2 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/contact-4.png differ diff --git a/docs/design/redesign-20260923/flutter/contact-5.png b/docs/design/redesign-20260923/flutter/contact-5.png new file mode 100644 index 00000000..7b30861a Binary files /dev/null and b/docs/design/redesign-20260923/flutter/contact-5.png differ diff --git a/docs/design/redesign-20260923/flutter/contact-6.png b/docs/design/redesign-20260923/flutter/contact-6.png new file mode 100644 index 00000000..621a1c40 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/contact-6.png differ diff --git a/docs/design/redesign-20260923/flutter/contact-7.png b/docs/design/redesign-20260923/flutter/contact-7.png new file mode 100644 index 00000000..2bd59c0d Binary files /dev/null and b/docs/design/redesign-20260923/flutter/contact-7.png differ diff --git a/docs/design/redesign-20260923/flutter/screen-code-map.json b/docs/design/redesign-20260923/flutter/screen-code-map.json new file mode 100644 index 00000000..8cb255bf --- /dev/null +++ b/docs/design/redesign-20260923/flutter/screen-code-map.json @@ -0,0 +1,158 @@ +[ + { + "key": "date-time", + "figmaNode": "2112:2394", + "file": "lib/presentation/schedule_create/schedule_date_time/screens/schedule_date_time_form.dart", + "capture": "screenshots/date-time.png" + }, + { + "key": "weekly", + "figmaNode": "2112:21323", + "file": "lib/presentation/recurring/recurrence_settings_sheet.dart", + "capture": "screenshots/weekly.png" + }, + { + "key": "monthly", + "figmaNode": "2112:21352", + "file": "lib/presentation/recurring/recurrence_settings_sheet.dart", + "capture": "screenshots/monthly.png" + }, + { + "key": "ending", + "figmaNode": "2112:21395", + "file": "lib/presentation/recurring/recurrence_settings_sheet.dart", + "capture": "screenshots/ending.png" + }, + { + "key": "preparation", + "figmaNode": "2112:21456", + "file": "lib/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form.dart", + "capture": "screenshots/preparation.png" + }, + { + "key": "review", + "figmaNode": "2112:21500", + "file": "lib/presentation/recurring/recurrence_review_sheet.dart", + "capture": "screenshots/review.png" + }, + { + "key": "conflicts", + "figmaNode": "2112:21531", + "file": "lib/presentation/recurring/recurrence_review_sheet.dart", + "capture": "screenshots/conflicts.png" + }, + { + "key": "persistent-conflict", + "figmaNode": "2112:21557", + "file": "lib/presentation/recurring/recurrence_review_sheet.dart", + "capture": "screenshots/persistent-conflict.png" + }, + { + "key": "scope", + "figmaNode": "2112:21588", + "file": "lib/presentation/recurring/recurrence_scope_sheet.dart", + "capture": "screenshots/scope.png" + }, + { + "key": "detached", + "figmaNode": "2112:21613", + "file": "lib/presentation/recurring/recurrence_review_sheet.dart", + "capture": "screenshots/detached.png" + }, + { + "key": "time-exceptions", + "figmaNode": "2112:21635", + "file": "lib/presentation/recurring/recurrence_time_choice_sheet.dart", + "capture": "screenshots/time-exceptions.png" + }, + { + "key": "save-error", + "figmaNode": "2112:21659", + "file": "lib/presentation/recurring/recurrence_time_choice_sheet.dart", + "capture": "screenshots/save-error.png" + }, + { + "key": "frequency", + "figmaNode": "2113:21627", + "file": "lib/presentation/recurring/recurrence_settings_sheet.dart", + "capture": "screenshots/frequency.png" + }, + { + "key": "monthly-fields", + "figmaNode": "2113:21670", + "file": "lib/presentation/recurring/recurrence_settings_sheet.dart", + "capture": "screenshots/monthly-fields.png" + }, + { + "key": "end-date", + "figmaNode": "2113:21708", + "file": "lib/presentation/recurring/recurrence_settings_sheet.dart", + "capture": "screenshots/end-date.png" + }, + { + "key": "end-confirm", + "figmaNode": "2113:21745", + "file": "lib/presentation/recurring/recurring_management_screen.dart", + "capture": "screenshots/end-confirm.png" + }, + { + "key": "management", + "figmaNode": "2112:21676", + "file": "lib/presentation/recurring/recurring_management_screen.dart", + "capture": "screenshots/management.png" + }, + { + "key": "detail", + "figmaNode": "2112:21715", + "file": "lib/presentation/recurring/recurring_management_screen.dart", + "capture": "screenshots/detail.png" + }, + { + "key": "empty", + "figmaNode": "2112:21756", + "file": "lib/presentation/recurring/recurring_management_screen.dart", + "capture": "screenshots/empty.png" + }, + { + "key": "occurrence", + "figmaNode": "2112:21768", + "file": "lib/presentation/recurring/recurrence_occurrence_sheet.dart", + "capture": "screenshots/occurrence.png" + }, + { + "key": "mypage-entry", + "figmaNode": "2113:21761", + "file": "lib/presentation/my_page/my_page_screen.dart", + "capture": "screenshots/mypage-entry.png" + }, + { + "key": "recovery", + "figmaNode": "1957:11762", + "file": "lib/presentation/startup/screens/local_data_recovery_screen.dart", + "capture": "screenshots/recovery.png" + }, + { + "key": "recovery-busy", + "figmaNode": "2005:12036", + "file": "lib/presentation/startup/screens/local_data_recovery_screen.dart", + "capture": "screenshots/recovery-busy.png" + }, + { + "key": "recovery-confirm", + "figmaNode": "2003:12059", + "file": "lib/presentation/startup/screens/local_data_recovery_screen.dart", + "capture": "screenshots/recovery-confirm.png" + }, + { + "key": "reset-complete", + "figmaNode": "1963:11762", + "file": "lib/presentation/my_page/my_data_screen.dart", + "capture": "screenshots/reset-complete.png" + }, + { + "key": "privacy", + "figmaNode": "1950:11759", + "file": "lib/presentation/my_page/privacy_policy_screen.dart", + "capture": "screenshots/privacy.png" + } +] diff --git a/docs/design/redesign-20260923/flutter/screenshots/conflicts.png b/docs/design/redesign-20260923/flutter/screenshots/conflicts.png new file mode 100644 index 00000000..514cf1f7 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/conflicts.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/date-time.png b/docs/design/redesign-20260923/flutter/screenshots/date-time.png new file mode 100644 index 00000000..8860d933 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/date-time.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/detached.png b/docs/design/redesign-20260923/flutter/screenshots/detached.png new file mode 100644 index 00000000..ab5c1b51 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/detached.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/detail.png b/docs/design/redesign-20260923/flutter/screenshots/detail.png new file mode 100644 index 00000000..34ed67e3 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/detail.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/empty.png b/docs/design/redesign-20260923/flutter/screenshots/empty.png new file mode 100644 index 00000000..5c3af73e Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/empty.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/end-confirm.png b/docs/design/redesign-20260923/flutter/screenshots/end-confirm.png new file mode 100644 index 00000000..2c9470ef Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/end-confirm.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/end-date.png b/docs/design/redesign-20260923/flutter/screenshots/end-date.png new file mode 100644 index 00000000..bfc161e4 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/end-date.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/ending.png b/docs/design/redesign-20260923/flutter/screenshots/ending.png new file mode 100644 index 00000000..fdcb4156 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/ending.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/frequency.png b/docs/design/redesign-20260923/flutter/screenshots/frequency.png new file mode 100644 index 00000000..f49139df Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/frequency.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/management.png b/docs/design/redesign-20260923/flutter/screenshots/management.png new file mode 100644 index 00000000..a3c57e90 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/management.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/monthly-fields.png b/docs/design/redesign-20260923/flutter/screenshots/monthly-fields.png new file mode 100644 index 00000000..36d8a388 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/monthly-fields.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/monthly.png b/docs/design/redesign-20260923/flutter/screenshots/monthly.png new file mode 100644 index 00000000..54c4d475 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/monthly.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/mypage-entry.png b/docs/design/redesign-20260923/flutter/screenshots/mypage-entry.png new file mode 100644 index 00000000..c68e48a9 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/mypage-entry.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/occurrence.png b/docs/design/redesign-20260923/flutter/screenshots/occurrence.png new file mode 100644 index 00000000..c166a99e Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/occurrence.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/persistent-conflict.png b/docs/design/redesign-20260923/flutter/screenshots/persistent-conflict.png new file mode 100644 index 00000000..9054e74b Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/persistent-conflict.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/preparation.png b/docs/design/redesign-20260923/flutter/screenshots/preparation.png new file mode 100644 index 00000000..8c85a934 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/preparation.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/privacy.png b/docs/design/redesign-20260923/flutter/screenshots/privacy.png new file mode 100644 index 00000000..b3c532a2 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/privacy.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/recovery-busy.png b/docs/design/redesign-20260923/flutter/screenshots/recovery-busy.png new file mode 100644 index 00000000..65d83200 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/recovery-busy.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/recovery-confirm.png b/docs/design/redesign-20260923/flutter/screenshots/recovery-confirm.png new file mode 100644 index 00000000..4ea85e8e Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/recovery-confirm.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/recovery.png b/docs/design/redesign-20260923/flutter/screenshots/recovery.png new file mode 100644 index 00000000..26d09a13 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/recovery.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/reset-complete.png b/docs/design/redesign-20260923/flutter/screenshots/reset-complete.png new file mode 100644 index 00000000..7e6ace58 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/reset-complete.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/review.png b/docs/design/redesign-20260923/flutter/screenshots/review.png new file mode 100644 index 00000000..c1507c64 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/review.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/save-error.png b/docs/design/redesign-20260923/flutter/screenshots/save-error.png new file mode 100644 index 00000000..38d62731 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/save-error.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/scope.png b/docs/design/redesign-20260923/flutter/screenshots/scope.png new file mode 100644 index 00000000..8b8c36e5 Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/scope.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/time-exceptions.png b/docs/design/redesign-20260923/flutter/screenshots/time-exceptions.png new file mode 100644 index 00000000..f838439d Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/time-exceptions.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/weekly.png b/docs/design/redesign-20260923/flutter/screenshots/weekly.png new file mode 100644 index 00000000..50ee57ec Binary files /dev/null and b/docs/design/redesign-20260923/flutter/screenshots/weekly.png differ diff --git a/docs/design/redesign-20260923/flutter/validation.txt b/docs/design/redesign-20260923/flutter/validation.txt new file mode 100644 index 00000000..eb4404df --- /dev/null +++ b/docs/design/redesign-20260923/flutter/validation.txt @@ -0,0 +1,12 @@ +2026-09-23 + +flutter analyze --no-pub +No issues found! + +flutter test --no-pub --concurrency=1 --dart-define=CAPTURE_RECURRING_SCREENSHOTS=true +603 tests passed (1m52s). + +git diff --check +passed + +26 Flutter widget renders saved. Privacy body text: 6/6 original paragraphs preserved. diff --git a/lib/presentation/calendar/component/schedule_detail.dart b/lib/presentation/calendar/component/schedule_detail.dart index aa11bb5a..00b6373e 100644 --- a/lib/presentation/calendar/component/schedule_detail.dart +++ b/lib/presentation/calendar/component/schedule_detail.dart @@ -1,3 +1,4 @@ +import 'package:on_time_front/presentation/recurring/recurrence_occurrence_sheet.dart'; import 'package:on_time_front/presentation/recurring/recurrence_labels.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; @@ -183,6 +184,19 @@ class _ScheduleDetailState extends State { schedule: widget.schedule, placeName: widget.schedule.place.placeName, preparationTime: widget.preparationTime, + onEdit: + widget.schedule.doneStatus == + ScheduleDoneStatus.notEnded && + !_hasPreparationStarted(DateTime.now()) && + !widget.schedule.occurrenceInstantUtc.isBefore( + DateTime.now().toUtc(), + ) + ? widget.onEdit + : null, + onDelete: + widget.schedule.doneStatus == ScheduleDoneStatus.notEnded + ? widget.onDeleted + : null, ), ), ], @@ -229,8 +243,12 @@ class _ScheduleDetailsColumn extends StatelessWidget { required this.schedule, required this.placeName, required this.preparationTime, + this.onEdit, + this.onDelete, }); + final VoidCallback? onEdit; + final VoidCallback? onDelete; final ScheduleEntity schedule; final String placeName; final Duration? preparationTime; @@ -314,6 +332,25 @@ class _ScheduleDetailsColumn extends StatelessWidget { ), ), children: [ + if (schedule.isRecurring) + TextButton( + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => SizedBox( + height: MediaQuery.sizeOf(context).height * .94, + child: RecurrenceOccurrenceSheet( + schedule: schedule, + onEdit: onEdit, + onDelete: onDelete, + ), + ), + ), + child: Text( + recurrenceText(context, '이번 회차 보기', 'View occurrence'), + ), + ), Column( children: [ _ScheduleInfoTile( diff --git a/lib/presentation/my_page/my_data_screen.dart b/lib/presentation/my_page/my_data_screen.dart index 19cbbb61..05853be5 100644 --- a/lib/presentation/my_page/my_data_screen.dart +++ b/lib/presentation/my_page/my_data_screen.dart @@ -1,3 +1,6 @@ +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; +import 'package:on_time_front/presentation/startup/screens/local_data_recovery_screen.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:on_time_front/core/backup/backup_password.dart'; @@ -7,7 +10,9 @@ import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; class MyDataScreen extends StatefulWidget { - const MyDataScreen({super.key}); + const MyDataScreen({super.key, this.initialAction}); + + final String? initialAction; @override State createState() => _MyDataScreenState(); @@ -21,6 +26,19 @@ class _MyDataScreenState extends State { void initState() { super.initState(); _loadFreshness(); + if (widget.initialAction != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + switch (widget.initialAction) { + case 'backup': + _export(); + case 'restore': + _restore(); + case 'reset': + _reset(); + } + }); + } } Future _loadFreshness() async { @@ -167,27 +185,9 @@ class _MyDataScreenState extends State { } Future _reset() async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('모든 로컬 데이터를 삭제할까요?'), - content: const Text( - '일정, 준비 단계, 기록, 설정, 알람과 기기 암호화 키가 삭제됩니다. ' - '이미 내보낸 백업 파일은 삭제되지 않습니다. 이 작업은 되돌릴 수 없습니다.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('취소'), - ), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.error, - ), - onPressed: () => Navigator.pop(context, true), - child: const Text('모두 삭제'), - ), - ], + final confirmed = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const LocalDataResetConfirmationScreen(), ), ); if (confirmed != true) return; @@ -301,29 +301,58 @@ class LocalDataResetCompleteScreen extends StatelessWidget { const LocalDataResetCompleteScreen({super.key}); @override - Widget build(BuildContext context) => const Scaffold( - body: SafeArea( - child: Center( - child: Padding( - padding: EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.check_circle_outline, size: 64), - SizedBox(height: 20), - Text( - '로컬 데이터를 삭제했습니다.', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600), - ), - SizedBox(height: 12), - Text( - 'OnTime을 완전히 종료한 뒤 다시 열면 새 로컬 프로필로 시작합니다.', - textAlign: TextAlign.center, + Widget build(BuildContext context) => Scaffold( + body: RecurrenceSheet( + title: '초기화 완료', + showBack: false, + footer: ScreenActions( + action: '다시 시작 안내', + onAction: () => showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('OnTime을 다시 열어 주세요'), + content: const Text('앱을 완전히 종료한 뒤 다시 열면 새 로컬 프로필로 시작합니다.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('확인'), ), ], ), ), ), + children: [ + const SizedBox(height: 40), + Center( + child: SvgPicture.asset( + 'assets/design/reset_success.svg', + width: 69, + height: 69, + ), + ), + const SizedBox(height: 4), + const Text( + '로컬 데이터 초기화가\n완료되었습니다', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24, + height: 1.3, + fontWeight: FontWeight.w700, + ), + ), + const Text( + '이 기기에 저장된 모든 로컬 데이터가 삭제되었습니다.\nOnTime을 완전히 종료한 뒤 다시 열면 새 로컬 프로필로 시작합니다.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, height: 1.5), + ), + const SizedBox(height: 8), + const RecurrencePanel( + child: Text( + '외부에 저장된 백업 파일은 삭제되지 않았습니다.\n필요한 경우 직접 삭제해 주세요.', + style: TextStyle(fontSize: 13, height: 1.6), + ), + ), + ], ), ); } diff --git a/lib/presentation/my_page/my_page_screen.dart b/lib/presentation/my_page/my_page_screen.dart index cd4b98ca..e200a822 100644 --- a/lib/presentation/my_page/my_page_screen.dart +++ b/lib/presentation/my_page/my_page_screen.dart @@ -1,3 +1,7 @@ +import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; +import 'package:on_time_front/domain/use-cases/get_default_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/stream_user_use_case.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; import 'package:on_time_front/presentation/recurring/recurrence_labels.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -8,7 +12,6 @@ import 'package:on_time_front/core/services/fallback_alarm_notification_service. import 'package:on_time_front/core/services/notification_service.dart'; import 'package:on_time_front/domain/entities/alarm_delivery_policy.dart'; import 'package:on_time_front/domain/entities/alarm_entities.dart'; -import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/repositories/alarm_registry_repository.dart'; import 'package:on_time_front/domain/repositories/alarm_repository.dart'; import 'package:on_time_front/domain/use-cases/cancel_all_alarms_use_case.dart'; @@ -25,79 +28,236 @@ class MyPageScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow, - appBar: AppBar( - title: Text( - AppLocalizations.of(context)!.myPageTitle, - style: Theme.of(context).textTheme.titleLarge, + return RefreshTheme( + child: Scaffold( + appBar: AppBar( + centerTitle: true, + title: Text( + AppLocalizations.of(context)!.myPageTitle, + style: Theme.of(context).textTheme.titleLarge, + ), ), - backgroundColor: Theme.of(context).colorScheme.surface, - ), - body: SingleChildScrollView( - child: Column( - spacing: 12, - children: [ - const _FrameView(title: '알람 설정', child: _AlarmStatusView()), - _FrameView( - title: '내 데이터', - child: Column( - spacing: 25, - children: [ - _SettingTile( - title: '백업, 복원 및 로컬 데이터 초기화', - onTap: () => context.push('/myData'), + body: SingleChildScrollView( + child: Column( + children: [ + _FrameView( + title: recurrenceText(context, '알림 설정', 'Notifications'), + child: Column( + children: [ + const _AlarmStatusView(), + const _DetailedNotificationTile(), + _AppNotificationTile( + service: + _notificationService ?? NotificationService.instance, + ), + ], + ), + ), + _FrameView( + title: recurrenceText(context, '시간 설정', 'Time settings'), + child: const _DefaultTimeTiles(), + ), + _FrameView( + title: recurrenceText(context, '일정 관리', 'Schedules'), + child: _SettingTile( + icon: Icons.repeat, + title: recurrenceText( + context, + '반복 일정 관리', + 'Recurring schedules', ), - ], + onTap: () => context.push('/recurringSchedules'), + ), ), - ), - _FrameView( - title: AppLocalizations.of(context)!.appSettings, - child: Column( - spacing: 25, - children: [ - _SettingTile( - title: recurrenceText( - context, - '반복 일정 관리', - 'Recurring schedules', + _FrameView( + title: recurrenceText(context, '데이터 관리', 'Data'), + child: Column( + children: [ + _SettingTile( + icon: Icons.cloud_upload_outlined, + title: recurrenceText( + context, + '내 데이터 백업', + 'Back up my data', + ), + onTap: () => context.push('/myData?action=backup'), ), - onTap: () => context.push('/recurringSchedules'), - ), - _SettingTile( - title: AppLocalizations.of(context)!.editDefaultPreparation, - onTap: () async { - final PreparationEntity? updatedPreparation = - await context.push( - '/defaultPreparationSpareTimeEdit', - ); - if (updatedPreparation != null) {} - }, - ), - const _DetailedNotificationTile(), - _SettingTile( - title: AppLocalizations.of(context)!.allowAppNotifications, - onTap: () async { - await _handleNotificationPermission( + _SettingTile( + icon: Icons.cloud_download_outlined, + title: recurrenceText( context, - _notificationService ?? NotificationService.instance, - ); - }, - ), - _SettingTile( - title: AppLocalizations.of(context)!.privacyPolicy, - onTap: () => context.push('/privacyPolicy'), - ), - ], + '내 데이터 복원', + 'Restore my data', + ), + onTap: () => context.push('/myData?action=restore'), + ), + _SettingTile( + icon: Icons.delete_outline, + title: recurrenceText( + context, + '내 데이터 초기화', + 'Reset local data', + ), + onTap: () => context.push('/myData?action=reset'), + ), + ], + ), ), - ), - ], + _FrameView( + title: recurrenceText(context, '기타', 'Other'), + child: _SettingTile( + icon: Icons.privacy_tip_outlined, + title: AppLocalizations.of(context)!.privacyPolicy, + onTap: () => context.push('/privacyPolicy'), + ), + ), + ], + ), ), ), ); } } +class _DefaultTimeTiles extends StatefulWidget { + const _DefaultTimeTiles(); + @override + State<_DefaultTimeTiles> createState() => _DefaultTimeTilesState(); +} + +class _DefaultTimeTilesState extends State<_DefaultTimeTiles> { + Future? _duration; + late final Stream? _user = getIt.isRegistered() + ? getIt()() + : null; + @override + void initState() { + super.initState(); + _load(); + } + + void _load() { + _duration = getIt.isRegistered() + ? getIt()().then((p) => p.totalDuration) + : null; + } + + Future _edit() async { + await context.push('/defaultPreparationSpareTimeEdit'); + if (mounted) setState(_load); + } + + String? _minutes(Duration? value) => value == null + ? null + : recurrenceText( + context, + '${value.inMinutes} 분', + '${value.inMinutes} min', + ); + @override + Widget build(BuildContext context) => Column( + children: [ + FutureBuilder( + future: _duration, + builder: (context, snapshot) => _SettingTile( + icon: Icons.timer_outlined, + title: recurrenceText(context, '기본 준비시간', 'Default preparation'), + value: _minutes(snapshot.data), + onTap: _edit, + ), + ), + StreamBuilder( + stream: _user, + builder: (context, snapshot) => _SettingTile( + icon: Icons.timer_outlined, + title: recurrenceText(context, '기본 여유시간', 'Default buffer'), + value: _minutes(snapshot.data?.spareTimeOrNull), + onTap: _edit, + ), + ), + ], + ); +} + +class _AppNotificationTile extends StatefulWidget { + const _AppNotificationTile({required this.service}); + final NotificationService service; + @override + State<_AppNotificationTile> createState() => _AppNotificationTileState(); +} + +class _AppNotificationTileState extends State<_AppNotificationTile> + with WidgetsBindingObserver { + bool? _enabled; + bool _busy = false; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _refresh(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) _refresh(); + } + + Future _refresh() async { + try { + final status = await widget.service.checkNotificationPermission(); + if (mounted) { + setState(() => _enabled = status == AuthorizationStatus.authorized); + } + } catch (_) { + // The permission row remains usable if the platform status is unavailable. + } + } + + Future _request({bool turnOff = false}) async { + if (_busy) return; + setState(() => _busy = true); + try { + if (turnOff) { + await widget.service.openNotificationSettings(); + } else { + await _handleNotificationPermission(context, widget.service); + } + await _refresh(); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) => InkWell( + onTap: _busy ? null : () => _request(), + child: Row( + children: [ + const Icon(Icons.phone_iphone, size: 24), + const SizedBox(width: 16), + Expanded( + child: Text( + AppLocalizations.of(context)!.allowAppNotifications, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + Switch( + value: _enabled ?? false, + onChanged: _busy || _enabled == null + ? null + : (value) => _request(turnOff: !value), + ), + ], + ), + ); +} + class _DetailedNotificationTile extends StatefulWidget { const _DetailedNotificationTile(); @@ -138,7 +298,9 @@ class _DetailedNotificationTileState extends State<_DetailedNotificationTile> { return SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('알림에 일정 이름 표시'), - subtitle: const Text('기본값은 잠금 화면에 상세 내용을 표시하지 않습니다.'), + secondary: const Icon(Icons.person_outline, size: 24), + dense: true, + activeThumbColor: Theme.of(context).colorScheme.primary, value: _enabled, onChanged: _loading ? null : _change, ); @@ -304,27 +466,48 @@ class _AlarmStatusViewState extends State<_AlarmStatusView> { Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + return Column( children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, + Row( children: [ - Text( - AppLocalizations.of(context)!.scheduleNotificationSetting, - style: textTheme.bodyLarge, + const Icon(Icons.notifications_none, size: 24), + const SizedBox(width: 16), + Expanded( + child: Text( + AppLocalizations.of(context)!.scheduleNotificationSetting, + style: textTheme.bodyMedium, + ), ), - const SizedBox(height: 4), - Text( - _isLoading ? '확인 중' : _statusLabel, - style: textTheme.bodySmall?.copyWith(color: colorScheme.outline), + Switch( + key: const Key('alarmSettingsSwitch'), + value: _alarmsEnabled, + onChanged: _isUpdating ? null : _toggle, ), ], ), - Switch( - key: const Key('alarmSettingsSwitch'), - value: _alarmsEnabled, - onChanged: _isUpdating ? null : _toggle, + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + const Icon(Icons.schedule, size: 24), + const SizedBox(width: 16), + Expanded( + child: Text( + recurrenceText(context, '예정 알림 상태', 'Notification status'), + style: textTheme.bodyMedium, + ), + ), + Flexible( + child: Text( + _isLoading ? '확인 중' : _statusLabel, + textAlign: TextAlign.end, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + ), + ), + ), + ], + ), ), ], ); @@ -410,51 +593,74 @@ class _FrameView extends StatelessWidget { Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; - return Material( - color: Theme.of(context).colorScheme.surface, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 19), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 25, - children: [ - Text( + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 6), + child: Text( title, - style: textTheme.bodyMedium!.copyWith(color: colorScheme.outline), + style: textTheme.bodySmall!.copyWith( + color: colorScheme.outline, + fontWeight: FontWeight.w600, + ), ), - child, - ], - ), + ), + RecurrencePanel( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: child, + ), + ], ), ); } } class _SettingTile extends StatelessWidget { - const _SettingTile({required this.title, required this.onTap}); - + const _SettingTile({ + required this.title, + required this.onTap, + required this.icon, + this.value, + }); final String title; final VoidCallback onTap; - + final IconData icon; + final String? value; @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - final colorScheme = Theme.of(context).colorScheme; - return InkWell( - onTap: onTap, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(title, style: textTheme.bodyLarge), - Icon( - Icons.arrow_forward_ios, - size: 16, - color: colorScheme.outlineVariant, - ), - ], + Widget build(BuildContext context) => InkWell( + onTap: onTap, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Icon(icon, size: 24), + const SizedBox(width: 16), + Expanded( + child: Text(title, style: Theme.of(context).textTheme.bodyMedium), + ), + if (value != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + value!, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + Icon( + Icons.chevron_right, + size: 20, + color: Theme.of(context).colorScheme.outline, + ), + ], + ), ), - ); - } + ), + ); } Future _handleNotificationPermission( diff --git a/lib/presentation/my_page/privacy_policy_screen.dart b/lib/presentation/my_page/privacy_policy_screen.dart index 26120745..c584a294 100644 --- a/lib/presentation/my_page/privacy_policy_screen.dart +++ b/lib/presentation/my_page/privacy_policy_screen.dart @@ -1,34 +1,87 @@ import 'package:flutter/material.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; class PrivacyPolicyScreen extends StatelessWidget { const PrivacyPolicyScreen({super.key}); + static const sections = <(String, String)>[ + ( + '저장되는 정보', + 'OnTime은 사용자가 입력한 일정, 장소, 준비 단계, 시간 기록, 정시 도착 집계와 앱 설정을 현재 기기의 암호화된 로컬 저장소에 보관합니다. 이름, 이메일, 소셜 로그인 정보나 서버 계정을 수집하지 않습니다.', + ), + ( + '외부 전송', + 'OnTime은 앱 사용 데이터, 분석 이벤트, 인증 정보 또는 푸시 토큰을 OnTime 서버나 분석 서비스로 전송하지 않습니다. 알림과 알람은 운영체제의 기기 내 기능으로 예약됩니다.', + ), + ( + '백업', + '백업은 사용자가 직접 실행할 때만 생성됩니다. 백업 파일은 사용자가 지정한 위치에 저장되며, 사용자가 입력한 백업 비밀번호로 암호화됩니다. OnTime은 비밀번호나 백업 파일 위치를 저장하지 않으므로 분실한 비밀번호를 복구할 수 없습니다.', + ), + ( + '삭제', + '내 데이터 화면의 로컬 데이터 초기화를 실행하면 이 설치가 소유한 데이터, 알람 등록 정보와 기기 암호화 키가 삭제됩니다. 사용자가 외부 위치로 내보낸 백업 파일은 직접 삭제해야 합니다.', + ), + ( + '운영체제 기능', + '파일 선택, 로컬 알림, 알람과 앱 권한 처리는 Android 또는 iOS가 제공합니다. 운영체제나 사용자가 선택한 외부 파일 제공자의 처리에는 해당 서비스의 정책이 적용됩니다.', + ), + ('문의', '앱 배포 페이지에 표시된 개발자 연락처를 이용할 수 있습니다.'), + ]; @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('개인정보 처리방침')), - body: const SelectionArea( - child: SingleChildScrollView( - padding: EdgeInsets.all(20), - child: Text( - 'OnTime 로컬 전용 개인정보 처리방침\n\n' - '시행일: 2026년 8월 28일\n\n' - '1. 저장되는 정보\n' - 'OnTime은 사용자가 입력한 일정, 장소, 준비 단계, 시간 기록, 정시 도착 집계와 앱 설정을 현재 기기의 암호화된 로컬 저장소에 보관합니다. 이름, 이메일, 소셜 로그인 정보나 서버 계정을 수집하지 않습니다.\n\n' - '2. 외부 전송\n' - 'OnTime은 앱 사용 데이터, 분석 이벤트, 인증 정보 또는 푸시 토큰을 OnTime 서버나 분석 서비스로 전송하지 않습니다. 알림과 알람은 운영체제의 기기 내 기능으로 예약됩니다.\n\n' - '3. 백업\n' - '백업은 사용자가 직접 실행할 때만 생성됩니다. 백업 파일은 사용자가 지정한 위치에 저장되며, 사용자가 입력한 백업 비밀번호로 암호화됩니다. OnTime은 비밀번호나 백업 파일 위치를 저장하지 않으므로 분실한 비밀번호를 복구할 수 없습니다.\n\n' - '4. 삭제\n' - '내 데이터 화면의 로컬 데이터 초기화를 실행하면 이 설치가 소유한 데이터, 알람 등록 정보와 기기 암호화 키가 삭제됩니다. 사용자가 외부 위치로 내보낸 백업 파일은 직접 삭제해야 합니다.\n\n' - '5. 운영체제 기능\n' - '파일 선택, 로컬 알림, 알람과 앱 권한 처리는 Android 또는 iOS가 제공합니다. 운영체제나 사용자가 선택한 외부 파일 제공자의 처리에는 해당 서비스의 정책이 적용됩니다.\n\n' - '6. 문의\n' - '앱 배포 페이지에 표시된 개발자 연락처를 이용할 수 있습니다.', - style: TextStyle(fontSize: 15, height: 1.55), - ), + Widget build(BuildContext context) => Scaffold( + body: RecurrenceSheet( + title: '개인정보 처리방침', + spacing: 8, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(4, 8, 4, 8), + child: Text('시행일: 2026년 8월 28일', style: TextStyle(fontSize: 12)), + ), + const Padding( + padding: EdgeInsets.fromLTRB(4, 0, 4, 16), + child: Text('OnTime 로컬 전용 개인정보 처리방침', style: TextStyle(fontSize: 14)), ), - ), - ); - } + for (var i = 0; i < sections.length; i++) + RecurrencePanel( + child: SelectionArea( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 28, + child: Text( + '${i + 1}.', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + sections[i].$1, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + sections[i].$2, + style: const TextStyle(fontSize: 13, height: 1.4), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ); } diff --git a/lib/presentation/recurring/recurrence_components.dart b/lib/presentation/recurring/recurrence_components.dart index b5bf8692..343fca2d 100644 --- a/lib/presentation/recurring/recurrence_components.dart +++ b/lib/presentation/recurring/recurrence_components.dart @@ -1,7 +1,25 @@ import 'package:flutter/material.dart'; -import 'package:on_time_front/presentation/shared/components/check_button.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_labels.dart'; +import 'package:on_time_front/presentation/shared/components/modal_wide_button.dart'; +import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; import 'package:on_time_front/presentation/schedule_create/components/top_bar.dart'; +/// The existing OnTime tokens used by the refreshed Figma screen compositions. +class RefreshTheme extends StatelessWidget { + const RefreshTheme({super.key, required this.child}); + final Widget child; + @override + Widget build(BuildContext context) => Theme( + data: Theme.of(context).copyWith( + colorScheme: Theme.of(context).colorScheme.copyWith( + primary: AppColors.blue.shade600, + error: AppColors.red.shade900, + ), + ), + child: child, + ); +} + class RecurrenceSheet extends StatelessWidget { const RecurrenceSheet({ super.key, @@ -9,95 +27,255 @@ class RecurrenceSheet extends StatelessWidget { required this.children, this.action, this.onAction, + this.onBack, + this.footer, + this.showBack = true, + this.spacing = 16, }); final String title; final String? action; final VoidCallback? onAction; + final VoidCallback? onBack; final List children; + final Widget? footer; + final bool showBack; + final double spacing; @override - Widget build(BuildContext context) => Material( - color: Theme.of(context).colorScheme.surface, - child: SafeArea( - child: Padding( - padding: EdgeInsets.fromLTRB( - 16, - 16, - 16, - 16 + MediaQuery.viewInsetsOf(context).bottom, - ), - child: Column( - children: [ - TopBar( - title: title, - actionLabel: action ?? '', - onPreviousPageButtonClicked: () => Navigator.of(context).pop(), - onNextPageButtonClicked: onAction, - isNextButtonEnabled: onAction != null, + Widget build(BuildContext context) => RefreshTheme( + child: Builder( + builder: (context) => Material( + color: Theme.of(context).colorScheme.surface, + child: SafeArea( + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, ), - const SizedBox(height: 24), - Expanded( - child: ListView( - children: [ - for (final child in children) - Padding( - padding: const EdgeInsets.only(bottom: 16), - child: child, - ), - ], - ), + child: Column( + children: [ + SizedBox( + height: 44, + child: TopBar( + title: title, + showAction: false, + onPreviousPageButtonClicked: showBack + ? onBack ?? () => Navigator.of(context).maybePop() + : null, + onNextPageButtonClicked: null, + isNextButtonEnabled: false, + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + children: [ + for (final child in children) + Padding( + padding: EdgeInsets.only(bottom: spacing), + child: child, + ), + ], + ), + ), + if (footer != null || action != null) + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + child: + footer ?? + ScreenActions( + action: action!, + onAction: onAction, + onBack: + onBack ?? () => Navigator.of(context).maybePop(), + ), + ), + ], ), - ], + ), + ), + ), + ), + ); +} + +class ScreenActions extends StatelessWidget { + const ScreenActions({ + super.key, + required this.action, + required this.onAction, + this.onBack, + this.backLabel, + this.destructive = false, + this.loading = false, + }); + final String action; + final String? backLabel; + final VoidCallback? onAction; + final VoidCallback? onBack; + final bool destructive; + final bool loading; + @override + Widget build(BuildContext context) => SafeArea( + top: false, + child: RefreshTheme( + child: Builder( + builder: (context) => LayoutBuilder( + builder: (context, constraints) { + final longLabel = action.length > 12; + final fontSize = longLabel ? 14.0 : 16.0; + final textStyle = Theme.of(context).textTheme.titleSmall!.copyWith( + fontSize: fontSize, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onPrimary, + ); + final painter = TextPainter( + text: TextSpan(text: action, style: textStyle), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + final available = onBack == null + ? constraints.maxWidth + : (constraints.maxWidth - 8) * (longLabel ? 2 / 3 : .5); + final stacked = onBack != null && painter.width > available - 16; + final primary = ModalWideButton( + text: action, + onPressed: onAction, + variant: destructive + ? ModalWideButtonVariant.destructive + : ModalWideButtonVariant.primary, + layout: ModalWideButtonLayout.full, + height: 48, + isLoading: loading, + textStyle: textStyle, + ); + final secondary = ModalWideButton( + text: backLabel ?? recurrenceText(context, '뒤로', 'Back'), + onPressed: loading ? null : onBack, + variant: ModalWideButtonVariant.subtle, + layout: ModalWideButtonLayout.full, + height: 48, + ); + if (stacked) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [secondary, const SizedBox(height: 8), primary], + ); + } + return Row( + children: [ + if (onBack != null) ...[ + Expanded(child: secondary), + const SizedBox(width: 8), + ], + Expanded(flex: longLabel ? 2 : 1, child: primary), + ], + ); + }, ), ), ), ); } +class RecurrencePanel extends StatelessWidget { + const RecurrencePanel({ + super.key, + required this.child, + this.highlighted = false, + this.padding = const EdgeInsets.all(16), + }); + final Widget child; + final bool highlighted; + final EdgeInsets padding; + @override + Widget build(BuildContext context) => Material( + color: highlighted + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: double.infinity, + child: Padding(padding: padding, child: child), + ), + ); +} + class RecurrenceChoice extends StatelessWidget { const RecurrenceChoice({ super.key, required this.label, required this.selected, required this.onTap, + this.description, + this.child, }); final String label; + final String? description; final bool selected; final VoidCallback onTap; + final Widget? child; @override - Widget build(BuildContext context) => Semantics( - selected: selected, - button: true, - label: label, - child: Material( - color: Theme.of(context).colorScheme.surfaceContainerLowest, - borderRadius: BorderRadius.circular(40), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(40), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - child: Row( - children: [ - SizedBox( - width: 32, - height: 32, - child: ExcludeSemantics( - child: CheckButton(isChecked: selected, onPressed: onTap), + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Semantics( + selected: selected, + button: true, + child: Material( + color: selected + ? colors.primaryContainer + : colors.surfaceContainerLowest, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 1), + child: Icon( + selected + ? Icons.radio_button_checked + : Icons.radio_button_off, + color: selected ? colors.primary : AppColors.grey.shade700, + size: 24, + ), ), - ), - const SizedBox(width: 16), - Expanded( - child: Text( - label, - style: Theme.of(context).textTheme.titleSmall, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (description != null) ...[ + const SizedBox(height: 4), + Text( + description!, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: AppColors.grey.shade700), + ), + ], + if (child != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: child, + ), + ], + ), ), - ), - ], + ], + ), ), ), ), - ), - ); + ); + } } class RecurrenceValue extends StatelessWidget { @@ -106,29 +284,57 @@ class RecurrenceValue extends StatelessWidget { required this.label, required this.value, this.onTap, + this.icon, + this.compact = false, }); final String label; final String value; final VoidCallback? onTap; + final IconData? icon; + final bool compact; @override - Widget build(BuildContext context) => InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: Theme.of(context).colorScheme.primary, + Widget build(BuildContext context) => Material( + color: Theme.of(context).colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + if (icon != null) ...[ + Icon(icon, size: 24), + const SizedBox(width: 16), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!compact) + Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.grey.shade700, + ), + ), + if (!compact) const SizedBox(height: 6), + Text( + value, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ], + ), ), - ), - const SizedBox(height: 16), - Text(value, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - Divider(height: 1, color: Theme.of(context).colorScheme.outline), - ], + if (onTap != null) + const Padding( + padding: EdgeInsets.only(left: 8), + child: Icon(Icons.chevron_right, size: 20), + ), + ], + ), ), ), ); diff --git a/lib/presentation/recurring/recurrence_occurrence_sheet.dart b/lib/presentation/recurring/recurrence_occurrence_sheet.dart new file mode 100644 index 00000000..051bdad6 --- /dev/null +++ b/lib/presentation/recurring/recurrence_occurrence_sheet.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_labels.dart'; + +class RecurrenceOccurrenceSheet extends StatelessWidget { + const RecurrenceOccurrenceSheet({ + super.key, + required this.schedule, + this.onEdit, + this.onDelete, + }); + final ScheduleEntity schedule; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + @override + Widget build(BuildContext context) { + final unrecorded = + !schedule.isStarted && + schedule.doneStatus == ScheduleDoneStatus.notEnded && + schedule.occurrenceInstantUtc.isBefore(DateTime.now().toUtc()); + return RecurrenceSheet( + title: schedule.scheduleName, + footer: ScreenActions( + action: recurrenceText(context, '뒤로', 'Back'), + onAction: () => Navigator.of(context).pop(), + ), + children: [ + RecurrencePanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(recurrenceText(context, '이번 회차', 'This occurrence')), + const SizedBox(height: 8), + Text( + recurrenceDate(context, schedule.scheduleTime), + style: Theme.of(context).textTheme.titleLarge, + ), + if (schedule.recurringOverrides.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + recurrenceText( + context, + '이 회차는 개별 수정되었습니다.', + 'This occurrence has individual changes.', + ), + ), + ), + ], + ), + ), + const Divider(height: 24), + RecurrenceValue( + label: recurrenceText(context, '진행 기록', 'Preparation record'), + value: unrecorded + ? recurrenceText( + context, + '진행 기록이 없습니다.', + 'No preparation recorded.', + ) + : schedule.isStarted + ? recurrenceText( + context, + '준비 진행 기록이 있습니다.', + 'Preparation has been recorded.', + ) + : recurrenceText(context, '예정된 일정입니다.', 'Upcoming occurrence.'), + ), + if (unrecorded) + RecurrencePanel( + highlighted: true, + child: Text( + recurrenceText( + context, + '이 회차는 별도 출근 진행 기록을 남기지 않습니다. 따라서 완료, 지각 결과 및 점수가 자동으로 생성되지 않습니다.', + 'No completion, late result, or score is generated automatically for an unrecorded occurrence.', + ), + ), + ), + if (onEdit != null) + RecurrenceValue( + label: recurrenceText(context, '일정 수정', 'Edit'), + value: recurrenceText(context, '수정하기', 'Edit occurrence'), + onTap: () { + Navigator.of(context).pop(); + onEdit!(); + }, + ), + if (onDelete != null) + RecurrenceValue( + label: recurrenceText(context, '일정 삭제', 'Delete'), + value: recurrenceText(context, '이 회차 삭제하기', 'Delete occurrence'), + onTap: () { + Navigator.of(context).pop(); + onDelete!(); + }, + ), + ], + ); + } +} diff --git a/lib/presentation/recurring/recurrence_review_sheet.dart b/lib/presentation/recurring/recurrence_review_sheet.dart index 26b76947..04183c0e 100644 --- a/lib/presentation/recurring/recurrence_review_sheet.dart +++ b/lib/presentation/recurring/recurrence_review_sheet.dart @@ -1,3 +1,4 @@ +import 'package:intl/intl.dart'; import 'package:flutter/material.dart'; import 'package:on_time_front/core/time/civil_time_resolver.dart'; import 'package:on_time_front/domain/recurrence/recurrence_engine.dart'; @@ -5,7 +6,6 @@ import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; import 'package:on_time_front/presentation/recurring/recurrence_labels.dart'; import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; -import 'package:on_time_front/presentation/shared/components/modal_wide_button.dart'; class RecurrenceReviewSheet extends StatefulWidget { const RecurrenceReviewSheet({ @@ -42,75 +42,71 @@ class _RecurrenceReviewSheetState extends State { _excluded.contains(c.otherSlotKey), ) && (remaining.isNotEmpty || review.detached.isNotEmpty); + final title = review.persistentConflict + ? recurrenceText(context, '반복 시간 조정', 'Adjust recurrence') + : byKey.isNotEmpty + ? recurrenceText(context, '겹치는 일정 확인', 'Conflicting occurrences') + : review.detached.isNotEmpty + ? recurrenceText(context, '변경 내용 확인', 'Review changes') + : recurrenceText(context, '반복 일정 확인', 'Review recurrence'); return RecurrenceSheet( - title: recurrenceText(context, '반복 일정 확인', 'Review recurrence'), - action: recurrenceText(context, '저장', 'Save'), - onAction: canSave + title: title, + action: review.persistentConflict + ? recurrenceText(context, '반복 설정 수정', 'Change settings') + : recurrenceText( + context, + review.detached.isNotEmpty + ? '단독 일정으로 남기고 저장' + : byKey.isNotEmpty + ? '저장하기 (${remaining.length}개)' + : '저장하기', + 'Save', + ), + onAction: review.persistentConflict + ? () => Navigator.of(context).pop() + : canSave ? () => Navigator.of(context).pop(Set.from(_excluded)) : null, children: [ - Text( - form.scheduleName ?? '', - style: Theme.of(context).textTheme.titleLarge, - ), - if (form.recurrenceRule != null) - RecurrenceValue( - label: recurrenceText(context, '반복', 'Repeat'), - value: recurrenceLabel(context, form.recurrenceRule!), - ), - if (review.totalOccurrences != null) - Text( - recurrenceText( - context, - '반복 ${review.totalOccurrences! - _excluded.length}회' - '${review.detached.isEmpty ? '' : ' + 독립 일정 ${review.detached.length}회'}', - '${review.totalOccurrences! - _excluded.length} recurring occurrences' - '${review.detached.isEmpty ? '' : ' + ${review.detached.length} standalone schedules'}', + if (review.persistentConflict) ...[ + RecurrencePanel( + highlighted: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recurrenceText( + context, + '반복 일정이 계속 겹쳐요', + 'These recurring rules keep overlapping', + ), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + recurrenceText( + context, + '시간이나 요일을 수정해야 저장할 수 있어요.', + 'Change the time or repeat pattern before saving.', + ), + ), + ], ), ), - if (remaining.isNotEmpty) - Text( - '${recurrenceText(context, '첫 일정', 'First occurrence')} · ${recurrenceDate(context, _time(remaining.first))}', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, + if (form.recurrenceRule != null) + RecurrenceValue( + label: recurrenceText(context, '반복 요일', 'Repeat pattern'), + value: recurrenceLabel(context, form.recurrenceRule!), ), - ), - if (remaining.isNotEmpty) RecurrenceValue( - label: recurrenceText(context, '예정된 일정', 'Upcoming occurrences'), - value: remaining - .take(3) - .map((s) => recurrenceDate(context, _time(s))) - .join('\n'), - ), - if (remaining.isNotEmpty && - review.occurrences[remaining.first.key] != null) - Text( - '${recurrenceText(context, '준비 시작', 'Preparation starts')} · ${recurrenceDate(context, CivilTimeResolver.civilTimeAt(review.occurrences[remaining.first.key]!.preparationStartUtc, review.occurrences[remaining.first.key]!.schedule.timeZoneId))}', - ), - Text( - recurrenceText( - context, - '준비 ${form.totalPreparationTime.inMinutes}분 + 이동 ${form.moveTime?.inMinutes ?? 0}분 + 여유 ${form.scheduleSpareTime?.inMinutes ?? 0}분', - 'Preparation ${form.totalPreparationTime.inMinutes} min + travel ${form.moveTime?.inMinutes ?? 0} min + buffer ${form.scheduleSpareTime?.inMinutes ?? 0} min', + label: form.scheduleName ?? '', + value: recurrenceDate(context, form.scheduleTime!), ), - ), - if (review.persistentConflict) ...[ - Text( - recurrenceText( - context, - '반복 시간이나 요일이 계속 겹쳐요. 반복 설정을 조정해 주세요.', - 'These recurring rules keep overlapping. Change the time or repeat pattern.', + for (final conflict in review.conflicts.take(3)) + RecurrenceValue( + label: conflict.other.scheduleName, + value: recurrenceDate(context, conflict.other.scheduleTime), ), - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ModalWideButton( - text: recurrenceText(context, '설정 바꾸기', 'Change settings'), - variant: ModalWideButtonVariant.primary, - layout: ModalWideButtonLayout.full, - height: 52, - onPressed: () => Navigator.of(context).pop(), - ), ] else if (byKey.isNotEmpty) ...[ Text( recurrenceText( @@ -123,67 +119,223 @@ class _RecurrenceReviewSheetState extends State { : 'This overlaps another schedule. Adjust the time or preparation.', ), ), - for (final entry in byKey.entries) - CheckboxListTile( - contentPadding: EdgeInsets.zero, - value: _excluded.contains(entry.key), - onChanged: canExclude - ? (v) => setState( - () => v == true - ? _excluded.add(entry.key) - : _excluded.remove(entry.key), - ) - : null, - title: Text( - recurrenceDate( - context, - review.occurrences[entry.key]?.schedule.scheduleTime ?? - DateTime.parse(entry.key), + RecurrencePanel( + highlighted: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final entry in byKey.entries) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + '${recurrenceDate(context, review.occurrences[entry.key]?.schedule.scheduleTime ?? DateTime.parse(entry.key))} · ${entry.value.map((c) => c.other.scheduleName).toSet().join(' · ')}', + ), + ), + ], + ), + ), + Text( + recurrenceText( + context, + '전체 ${review.slots.length}회차 · 선택 ${_excluded.length}개 제외', + '${review.slots.length} occurrences · ${_excluded.length} excluded', + ), + style: Theme.of(context).textTheme.titleSmall, + ), + for (final slot in review.slots) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: RecurrencePanel( + padding: EdgeInsets.zero, + highlighted: _excluded.contains(slot.key), + child: CheckboxListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + dense: true, + value: _excluded.contains(slot.key), + onChanged: canExclude + ? (v) => setState( + () => v == true + ? _excluded.add(slot.key) + : _excluded.remove(slot.key), + ) + : null, + title: Text( + recurrenceDate(context, _time(slot)), + style: Theme.of(context).textTheme.bodyMedium, + ), + subtitle: byKey.containsKey(slot.key) + ? Text( + byKey[slot.key]! + .map((c) => c.other.scheduleName) + .toSet() + .join(' · '), + ) + : null, ), ), - subtitle: Text( - entry.value - .map((c) => c.other.scheduleName) - .toSet() - .join(' · '), + ), + RecurrencePanel( + child: Text( + recurrenceText( + context, + '모든 회차를 제외하면 저장할 수 없습니다. 제외한 회차는 총횟수에서 차감되며 다른 날짜로 채우지 않아요.', + 'At least one occurrence must remain. Excluded occurrences consume the count and are not replaced.', ), + style: Theme.of(context).textTheme.bodySmall, ), - if (canExclude) + ), + ] else ...[ + if (review.detached.isEmpty) ...[ Text( recurrenceText( context, - '제외한 회차는 총횟수에서 차감되며 다른 날짜로 채우지 않아요.', - 'Excluded occurrences consume the count and are not replaced.', + '아래 내용으로 반복 일정을 저장할까요?', + 'Save this recurring schedule?', ), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, ), - ], - if (review.detached.isNotEmpty) ...[ - Text( - recurrenceText( - context, - '개별 수정한 일정은 독립 일정으로 유지해요.', - 'Individually edited occurrences remain as standalone schedules.', + RecurrencePanel( + highlighted: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + form.scheduleName ?? '', + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontSize: 24), + ), + const SizedBox(height: 8), + if (form.recurrenceRule != null) + Text( + recurrenceLabel(context, form.recurrenceRule!), + style: Theme.of(context).textTheme.titleSmall, + ), + if (remaining.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + '${recurrenceText(context, '첫 일정', 'First occurrence')}: ${recurrenceDate(context, _time(remaining.first))}', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + const Divider(height: 28), + Text( + recurrenceText( + context, + '준비 ${form.totalPreparationTime.inMinutes}분 · 이동 ${form.moveTime?.inMinutes ?? 0}분 · 여유 ${form.scheduleSpareTime?.inMinutes ?? 0}분', + 'Preparation ${form.totalPreparationTime.inMinutes} min · travel ${form.moveTime?.inMinutes ?? 0} min · buffer ${form.scheduleSpareTime?.inMinutes ?? 0} min', + ), + ), + if (remaining.isNotEmpty && + review.occurrences[remaining.first.key] != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + '${recurrenceText(context, '준비 시작', 'Preparation starts')} ${recurrenceDate(context, CivilTimeResolver.civilTimeAt(review.occurrences[remaining.first.key]!.preparationStartUtc, review.occurrences[remaining.first.key]!.schedule.timeZoneId))}', + ), + ), + ], + ), ), - style: Theme.of(context).textTheme.titleSmall, - ), - for (final value in review.detached) + if (remaining.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + recurrenceText(context, '예정된 날짜', 'Upcoming occurrences'), + style: Theme.of(context).textTheme.titleSmall, + ), + RecurrencePanel( + child: Column( + children: [ + for (final slot in remaining.take(3)) ...[ + if (slot != remaining.first) const Divider(height: 16), + Align( + alignment: Alignment.centerLeft, + child: Row( + children: [ + Expanded( + child: Text( + DateFormat( + 'M/d (E)', + Localizations.localeOf(context).toString(), + ).format(_time(slot)), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + Text( + DateFormat.jm( + Localizations.localeOf(context).toString(), + ).format(_time(slot)), + style: const TextStyle(fontSize: 12), + ), + ], + ), + ), + ], + ], + ), + ), + ], + ], + if (review.detached.isNotEmpty) ...[ Text( - '${value.scheduleName}\n${recurrenceDate(context, value.scheduleTime)}', + recurrenceText( + context, + '규칙 변경으로 기존과 다른 일정이 발생하는 회차가 있습니다. 아래 일정을 단독 일정으로 유지하고 저장할까요?', + 'Individually edited occurrences differ from the new rule. Keep these as standalone schedules?', + ), ), - Text( - recurrenceText( - context, - '분리되는 일정도 남은 횟수에 포함돼요. 현재 시간과 준비과정을 유지합니다.', - 'Detached schedules count toward the remaining total and keep their time and preparation.', + for (final value in review.detached) + RecurrenceValue( + label: value.scheduleName, + value: recurrenceDate(context, value.scheduleTime), + ), + RecurrencePanel( + highlighted: true, + child: Text( + recurrenceText( + context, + '개별 수정한 일정은 기존 시간과 준비과정을 그대로 유지합니다. 분리되는 일정도 남은 횟수에 포함돼요.', + 'Detached schedules count toward the remaining total and keep their time and preparation.', + ), + ), + ), + ], + if (review.totalOccurrences != null) + Text( + recurrenceText( + context, + '반복 ${review.totalOccurrences! - _excluded.length}회${review.detached.isEmpty ? '' : ' + 독립 일정 ${review.detached.length}회'}', + '${review.totalOccurrences! - _excluded.length} recurring occurrences', + ), + style: Theme.of(context).textTheme.bodySmall, ), - ), ], if (review.skipped.isNotEmpty) ...[ - Text( - recurrenceText(context, '건너뛴 날짜', 'Skipped dates'), - style: Theme.of(context).textTheme.titleSmall, + RecurrencePanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recurrenceText(context, '건너뛴 날짜', 'Skipped dates'), + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + for (final skip in review.skipped.take(5)) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text( + _skip(context, skip), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), ), - for (final skip in review.skipped.take(5)) Text(_skip(context, skip)), if (review.skipped.length > 5) TextButton( onPressed: () => showModalBottomSheet( @@ -191,13 +343,13 @@ class _RecurrenceReviewSheetState extends State { isScrollControlled: true, useSafeArea: true, builder: (context) => SizedBox( - height: MediaQuery.sizeOf(context).height * .8, - child: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: review.skipped.length, - itemBuilder: (context, i) => ListTile( - title: Text(_skip(context, review.skipped[i])), - ), + height: MediaQuery.sizeOf(context).height * .9, + child: RecurrenceSheet( + title: recurrenceText(context, '건너뛴 날짜', 'Skipped dates'), + children: [ + for (final skip in review.skipped) + Text(_skip(context, skip)), + ], ), ), ), diff --git a/lib/presentation/recurring/recurrence_scope_sheet.dart b/lib/presentation/recurring/recurrence_scope_sheet.dart index 291543a6..6f0c1a25 100644 --- a/lib/presentation/recurring/recurrence_scope_sheet.dart +++ b/lib/presentation/recurring/recurrence_scope_sheet.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; import 'package:on_time_front/presentation/recurring/recurrence_labels.dart'; -import 'package:on_time_front/presentation/shared/components/modal_wide_button.dart'; Future showRecurrenceScope( BuildContext context, { @@ -11,8 +10,10 @@ Future showRecurrenceScope( context: context, isScrollControlled: true, useSafeArea: true, - builder: (context) => - SizedBox(height: 440, child: _ScopeSheet(deleting: deleting)), + builder: (context) => SizedBox( + height: MediaQuery.sizeOf(context).height * .94, + child: _ScopeSheet(deleting: deleting), + ), ); class _ScopeSheet extends StatefulWidget { @@ -31,9 +32,19 @@ class _ScopeSheetState extends State<_ScopeSheet> { widget.deleting ? '삭제 범위' : '변경 범위', widget.deleting ? 'Delete scope' : 'Edit scope', ), + footer: ScreenActions( + action: recurrenceText( + context, + widget.deleting ? '선택한 범위 삭제' : '선택한 범위로 수정', + widget.deleting ? 'Delete selected scope' : 'Edit selected scope', + ), + destructive: widget.deleting, + onBack: () => Navigator.of(context).pop(), + onAction: () => Navigator.of(context).pop(_scope), + ), children: [ Text( - recurrenceText(context, '어떤 일정에 적용할까요?', 'Which occurrences?'), + recurrenceText(context, '어떤 일정까지 변경할지 선택해 주세요.', 'Which occurrences?'), style: Theme.of(context).textTheme.titleLarge, ), for (final scope in RecurringEditScope.values) @@ -45,6 +56,15 @@ class _ScopeSheetState extends State<_ScopeSheet> { ? 'This occurrence' : 'This and following', ), + description: recurrenceText( + context, + scope == RecurringEditScope.occurrence + ? '선택한 이번 일정만 수정하거나 삭제합니다.' + : '이번 일정부터 이후의 모든 일정을 수정하거나 삭제합니다.', + scope == RecurringEditScope.occurrence + ? 'Applies only to the selected occurrence.' + : 'Applies to this and all following occurrences.', + ), selected: _scope == scope, onTap: () => setState(() => _scope = scope), ), @@ -59,19 +79,6 @@ class _ScopeSheetState extends State<_ScopeSheet> { : 'Individual overrides, active preparations and history are preserved.', ), ), - ModalWideButton( - text: recurrenceText( - context, - widget.deleting ? '선택한 범위 삭제' : '선택한 범위로 수정', - widget.deleting ? 'Delete selected scope' : 'Edit selected scope', - ), - variant: widget.deleting - ? ModalWideButtonVariant.destructive - : ModalWideButtonVariant.primary, - layout: ModalWideButtonLayout.full, - height: 52, - onPressed: () => Navigator.of(context).pop(_scope), - ), ], ); } diff --git a/lib/presentation/recurring/recurrence_settings_sheet.dart b/lib/presentation/recurring/recurrence_settings_sheet.dart index 22a95965..fcfb1023 100644 --- a/lib/presentation/recurring/recurrence_settings_sheet.dart +++ b/lib/presentation/recurring/recurrence_settings_sheet.dart @@ -1,9 +1,10 @@ +import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:on_time_front/domain/recurrence/recurrence_engine.dart'; -import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; import 'package:on_time_front/presentation/recurring/recurrence_labels.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; class RecurrenceSettingsResult { const RecurrenceSettingsResult(this.rule, this.countChanged); @@ -43,11 +44,13 @@ class _RecurrenceSettingsSheetState extends State { late int _end; late DateTime _until; String? _error; + String _page = 'settings'; @override void initState() { super.initState(); final r = widget.initial; - _frequency = r?.frequency.index ?? -1; + _frequency = r?.frequency.index ?? (widget.allowNone ? -1 : 0); + if (r == null) _page = 'frequency'; _interval = TextEditingController(text: '${r?.interval ?? 1}'); _count = TextEditingController(text: '${r?.count ?? 10}'); _monthDay = TextEditingController( @@ -137,9 +140,16 @@ class _RecurrenceSettingsSheetState extends State { TextEditingController controller, String label, { bool day = false, + bool compact = false, }) => TextFormField( controller: controller, - decoration: InputDecoration(labelText: label), + style: const TextStyle(fontSize: 16), + textAlign: compact ? TextAlign.center : TextAlign.start, + decoration: InputDecoration( + labelText: compact ? null : label, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 4), + ), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, @@ -186,218 +196,468 @@ class _RecurrenceSettingsSheetState extends State { } } + void _back() { + if (_page != 'settings') { + setState(() => _page = 'settings'); + } else { + Navigator.of(context).pop(); + } + } + + Widget _heading(String ko, String en) => Text( + recurrenceText(context, ko, en), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700), + ); + @override Widget build(BuildContext context) => Form( key: _form, child: RecurrenceSheet( - title: recurrenceText(context, '반복 설정', 'Repeat'), + title: recurrenceText( + context, + switch (_page) { + 'frequency' => '반복 단위', + 'ending' => '반복 종료', + 'monthly' => '월간 규칙 입력', + _ => '반복 설정', + }, + switch (_page) { + 'frequency' => 'Frequency', + 'ending' => 'Ends', + 'monthly' => 'Monthly rule', + _ => 'Repeat', + }, + ), action: recurrenceText(context, '적용', 'Apply'), - onAction: _apply, + onBack: _back, + onAction: () { + if (_page == 'settings' || _frequency == -1) { + _apply(); + } else if (_form.currentState!.validate()) { + setState(() => _page = 'settings'); + } + }, children: [ - DropdownButtonFormField( - initialValue: _frequency, - decoration: InputDecoration( - labelText: recurrenceText(context, '반복 단위', 'Frequency'), + if (_page == 'frequency') + ..._frequencyFields() + else if (_page == 'ending') + ..._endingFields() + else if (_page == 'monthly') + ..._monthlyFields() + else + ..._settingsFields(), + if (_error != null) + Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), ), - items: [ - if (widget.allowNone) - DropdownMenuItem( - value: -1, - child: Text( - recurrenceText(context, '반복 안 함', 'Does not repeat'), + ], + ), + ); + + List _frequencyFields() => [ + for (var i = widget.allowNone ? -1 : 0; i < 3; i++) + RecurrenceChoice( + label: recurrenceText( + context, + i == -1 ? '반복 안 함' : ['매일', '매주', '매월'][i], + i == -1 ? 'Does not repeat' : ['Daily', 'Weekly', 'Monthly'][i], + ), + selected: _frequency == i, + onTap: () => setState(() { + _frequency = i; + _error = null; + }), + ), + if (_frequency >= 0) ...[ + const SizedBox(height: 8), + _number( + _interval, + recurrenceText( + context, + '간격 (${['일', '주', '월'][_frequency]})', + 'Interval', + ), + ), + Text( + recurrenceText( + context, + '첫 일정부터 선택한 간격으로 반복됩니다.', + 'Repeats at the selected interval from the first occurrence.', + ), + ), + ], + ]; + + List _settingsFields() => [ + Text( + recurrenceText( + context, + '일정을 언제, 얼마나 자주 반복할지 설정하세요.', + 'Choose when and how often to repeat.', + ), + style: Theme.of(context).textTheme.bodySmall, + ), + _heading('반복 단위', 'Frequency'), + Row( + children: [ + for (var i = 0; i < 3; i++) ...[ + if (i > 0) const SizedBox(width: 8), + Expanded( + child: TextButton( + style: TextButton.styleFrom( + minimumSize: const Size(44, 44), + backgroundColor: _frequency == i + ? AppColors.blue.shade600 + : Theme.of(context).colorScheme.surfaceContainerLowest, + foregroundColor: _frequency == i + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), ), - for (var i = 0; i < 3; i++) - DropdownMenuItem( - value: i, - child: Text( - recurrenceText( - context, - ['매일', '매주', '매월'][i], - ['Daily', 'Weekly', 'Monthly'][i], - ), + onPressed: () => setState(() { + _frequency = i; + _error = null; + }), + child: Text( + recurrenceText( + context, + ['일', '주', '월'][i], + ['Day', 'Week', 'Month'][i], + ), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, ), ), - ], - onChanged: (v) => setState(() => _frequency = v!), - ), - if (_frequency != -1) ...[ - _number( - _interval, - recurrenceText( - context, - '간격 (${['일', '주', '개월'][_frequency]})', - 'Interval (${['days', 'weeks', 'months'][_frequency]})', ), ), - if (_frequency == 1) ...[ - Text( - recurrenceText(context, '반복할 요일', 'Repeat on'), - style: Theme.of(context).textTheme.titleSmall, + ], + ], + ), + if (_frequency >= 0) ...[ + _heading('${['일', '주', '월'][_frequency]} 반복 간격', 'Repeat interval'), + Row( + children: [ + Text(recurrenceText(context, '매', 'Every')), + const SizedBox(width: 8), + SizedBox( + width: 64, + child: _number( + _interval, + recurrenceText(context, '간격', 'Interval'), + compact: true, ), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (var day = 1; day <= 7; day++) - Semantics( - selected: _weekdays.contains(day), - child: SizedBox( - width: 44, - height: 44, - child: TextButton( - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: const Size(44, 44), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - backgroundColor: _weekdays.contains(day) - ? Theme.of(context).colorScheme.primary - : Theme.of( - context, - ).colorScheme.surfaceContainerLowest, - foregroundColor: _weekdays.contains(day) - ? Theme.of(context).colorScheme.onPrimary - : Theme.of(context).colorScheme.onSurface, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + recurrenceText( + context, + '${['일', '주', '개월'][_frequency]}마다 반복', + ['days', 'weeks', 'months'][_frequency], + ), + ), + ), + ], + ), + if (_frequency == 1) ...[ + _heading('반복 요일', 'Repeat on'), + LayoutBuilder( + builder: (context, constraints) => Wrap( + spacing: constraints.maxWidth >= 344 + ? (constraints.maxWidth - 7 * 44) / 6 + : 6, + runSpacing: 8, + children: [ + for (var day = 1; day <= 7; day++) + Semantics( + key: ValueKey('recurrence-weekday-$day'), + selected: _weekdays.contains(day), + child: SizedBox( + width: 44, + height: 44, + child: TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size(44, 44), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), - onPressed: () => setState(() { - _weekdays.contains(day) - ? _weekdays.remove(day) - : _weekdays.add(day); - _error = null; - }), - child: Text(weekdayLabel(context, day)), + backgroundColor: _weekdays.contains(day) + ? AppColors.blue.shade600 + : Theme.of( + context, + ).colorScheme.surfaceContainerLowest, + foregroundColor: _weekdays.contains(day) + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurface, ), - ), - ), - ], - ), - ], - if (_frequency == 2) ...[ - for (final option in MonthlyRecurrence.values) - RecurrenceChoice( - label: recurrenceText( - context, - switch (option) { - MonthlyRecurrence.dayOfMonth => '날짜로 반복', - MonthlyRecurrence.nthWeekday => '몇 번째 요일 / 마지막 요일', - MonthlyRecurrence.lastDay => '매월 마지막 날', - }, - switch (option) { - MonthlyRecurrence.dayOfMonth => 'Day of month', - MonthlyRecurrence.nthWeekday => 'Nth or last weekday', - MonthlyRecurrence.lastDay => 'Last day of month', - }, - ), - selected: _monthly == option, - onTap: () => setState(() => _monthly = option), - ), - if (_monthly == MonthlyRecurrence.dayOfMonth) - _number( - _monthDay, - recurrenceText(context, '매월 며칠', 'Day of month'), - day: true, - ), - if (_monthly == MonthlyRecurrence.nthWeekday) ...[ - DropdownButtonFormField( - initialValue: _ordinal, - decoration: InputDecoration( - labelText: recurrenceText(context, '순서', 'Ordinal'), - ), - items: [ - for (final n in [1, 2, 3, 4, 5, -1]) - DropdownMenuItem( - value: n, + onPressed: () => setState(() { + _weekdays.contains(day) + ? _weekdays.remove(day) + : _weekdays.add(day); + _error = null; + }), child: Text( - n == -1 - ? recurrenceText(context, '마지막', 'Last') - : recurrenceText(context, '$n번째', '#$n'), + weekdayLabel(context, day), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), ), ), - ], - onChanged: (v) => setState(() => _ordinal = v!), - ), - DropdownButtonFormField( - initialValue: _monthWeekday, - decoration: InputDecoration( - labelText: recurrenceText(context, '요일', 'Weekday'), + ), ), - items: [ - for (var d = 1; d <= 7; d++) - DropdownMenuItem( - value: d, - child: Text(weekdayLabel(context, d)), - ), - ], - onChanged: (v) => setState(() => _monthWeekday = v!), - ), ], + ), + ), + ], + if (_frequency == 2) + RecurrenceValue( + label: recurrenceText(context, '월 규칙', 'Monthly rule'), + value: recurrenceText(context, switch (_monthly) { + MonthlyRecurrence.dayOfMonth => '매월 ${_monthDay.text}일', + MonthlyRecurrence.nthWeekday => + '${_ordinal == -1 ? '마지막' : '$_ordinal번째'} ${weekdayLabel(context, _monthWeekday)}요일', + MonthlyRecurrence.lastDay => '매월 마지막 날', + }, 'Configure monthly rule'), + onTap: () => setState(() => _page = 'monthly'), + ), + _heading('종료 조건', 'Ends'), + RecurrenceValue( + label: recurrenceText(context, '반복 종료', 'Repeat ends'), + compact: true, + value: recurrenceText( + context, + _end == 0 + ? '종료 없음' + : _end == 1 + ? recurrenceDay(context, _until) + : '총 ${_count.text}회', + _end == 0 + ? 'Never' + : _end == 1 + ? recurrenceDay(context, _until) + : 'After ${_count.text} occurrences', + ), + onTap: () => setState(() => _page = 'ending'), + ), + RecurrencePanel( + highlighted: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - recurrenceText( - context, - '없는 날짜나 다섯 번째 요일은 건너뛰고 총횟수에 포함하지 않아요.', - 'Missing dates and fifth weekdays are skipped without consuming the count.', + recurrenceText(context, '반복 예시', 'Preview'), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.blue.shade600, + fontWeight: FontWeight.w700, ), ), - ], - DropdownButtonFormField( - initialValue: _end, - decoration: InputDecoration( - labelText: recurrenceText(context, '반복 종료', 'Ends'), + const SizedBox(height: 12), + Text( + _preview(), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(height: 1.8), ), - items: [ - for (var i = 0; i < 3; i++) - DropdownMenuItem( - value: i, - child: Text( - recurrenceText( - context, - ['종료 없음', '날짜 지정', '횟수 지정'][i], - ['Never', 'On date', 'After count'][i], - ), - ), - ), - ], - onChanged: (v) => setState(() => _end = v!), + ], + ), + ), + ], + if (widget.allowNone) + TextButton( + onPressed: () => setState(() => _page = 'frequency'), + child: Text( + recurrenceText( + context, + '반복 단위 변경 / 반복 안 함', + 'Change frequency / do not repeat', ), - if (_end == 2) - _number( - _count, - recurrenceText(context, '반복 횟수', 'Occurrence count'), - ), - if (_end == 1) - RecurrenceValue( - label: recurrenceText(context, '이 날짜까지 포함', 'Inclusive end date'), - value: recurrenceDay(context, _until), - onTap: () async { - final day = DateTime( - widget.start.year, - widget.start.month, - widget.start.day, - ); - final picked = await showDatePicker( - context: context, - initialDate: _until.isBefore(day) ? day : _until, - firstDate: day, - lastDate: DateTime(9999, 12, 31), - ); - if (picked != null) setState(() => _until = picked); - }, - ), - Text( - _first(), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.primary, + style: const TextStyle(fontSize: 12), + ), + ), + ]; + + String _preview() { + try { + return '${recurrenceLabel(context, _rule())}\n${_first()}'; + } catch (_) { + return _first(); + } + } + + List _monthlyFields() => [ + _heading('반복 방식', 'Repeat pattern'), + RecurrenceChoice( + label: recurrenceText(context, '매월 특정 날짜에 반복', 'Day of month'), + description: recurrenceText( + context, + '예) 매월 10일', + 'For example, the 10th', + ), + selected: _monthly == MonthlyRecurrence.dayOfMonth, + onTap: () => setState(() => _monthly = MonthlyRecurrence.dayOfMonth), + child: _monthly == MonthlyRecurrence.dayOfMonth + ? _number( + _monthDay, + recurrenceText(context, '매월 며칠', 'Day of month'), + day: true, + ) + : null, + ), + RecurrenceChoice( + label: recurrenceText(context, '특정 순번의 요일에 반복', 'Nth weekday'), + description: recurrenceText( + context, + '예) 두 번째 수요일', + 'For example, the second Wednesday', + ), + selected: _monthly == MonthlyRecurrence.nthWeekday && _ordinal != -1, + onTap: () => setState(() { + _monthly = MonthlyRecurrence.nthWeekday; + if (_ordinal == -1) _ordinal = 1; + }), + ), + if (_monthly == MonthlyRecurrence.nthWeekday && _ordinal != -1) + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + initialValue: _ordinal, + decoration: InputDecoration( + labelText: recurrenceText(context, '순번', 'Ordinal'), + ), + items: [ + for (var n = 1; n <= 5; n++) + DropdownMenuItem( + value: n, + child: Text(recurrenceText(context, '$n번째', '#$n')), + ), + ], + onChanged: (v) => setState(() => _ordinal = v!), ), ), + const SizedBox(width: 24), + Expanded(child: _weekdayField()), ], - if (_error != null) - Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ], + ), + RecurrenceChoice( + label: recurrenceText(context, '매월 마지막 날에 반복', 'Last day of month'), + selected: _monthly == MonthlyRecurrence.lastDay, + onTap: () => setState(() => _monthly = MonthlyRecurrence.lastDay), + ), + RecurrenceChoice( + label: recurrenceText( + context, + '특정 요일의 마지막 날에 반복', + 'Last weekday of month', + ), + selected: _monthly == MonthlyRecurrence.nthWeekday && _ordinal == -1, + onTap: () => setState(() { + _monthly = MonthlyRecurrence.nthWeekday; + _ordinal = -1; + }), + child: _monthly == MonthlyRecurrence.nthWeekday && _ordinal == -1 + ? _weekdayField() + : null, + ), + RecurrencePanel( + child: Text( + recurrenceText( + context, + '해당 월에 선택한 날짜나 순번이 없으면 자동으로 건너뜁니다. 건너뛴 날짜는 총횟수에 포함하지 않아요.', + 'Missing dates and fifth weekdays are skipped without consuming the count.', + ), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ]; + + Widget _weekdayField() => DropdownButtonFormField( + initialValue: _monthWeekday, + decoration: InputDecoration( + labelText: recurrenceText(context, '요일', 'Weekday'), ), + items: [ + for (var d = 1; d <= 7; d++) + DropdownMenuItem(value: d, child: Text(weekdayLabel(context, d))), + ], + onChanged: (v) => setState(() => _monthWeekday = v!), ); + + List _endingFields() => [ + Text( + recurrenceText( + context, + '언제까지 반복할지 선택하세요.', + 'Choose when the series ends.', + ), + ), + for (var i = 0; i < 3; i++) + RecurrenceChoice( + label: recurrenceText( + context, + ['종료 없음', '날짜 지정', '총횟수'][i], + ['Never', 'On date', 'After count'][i], + ), + description: recurrenceText( + context, + ['계속 반복합니다.', '지정한 날짜까지 반복합니다.', '지정한 횟수만큼 반복합니다.'][i], + [ + 'Keeps repeating.', + 'Includes the selected date.', + 'Repeats for the selected count.', + ][i], + ), + selected: _end == i, + onTap: () => setState(() => _end = i), + child: i == 2 && _end == 2 + ? _number( + _count, + recurrenceText(context, '반복 횟수', 'Occurrence count'), + ) + : null, + ), + if (_end == 1) + RecurrenceValue( + label: recurrenceText(context, '마지막 날짜', 'Inclusive end date'), + value: recurrenceDay(context, _until), + icon: Icons.calendar_today_outlined, + onTap: () async { + final day = DateTime( + widget.start.year, + widget.start.month, + widget.start.day, + ); + final picked = await showDatePicker( + context: context, + initialDate: _until.isBefore(day) ? day : _until, + firstDate: day, + lastDate: DateTime(9999, 12, 31), + ); + if (picked != null && mounted) setState(() => _until = picked); + }, + ), + RecurrencePanel( + highlighted: _end == 1, + child: Text( + recurrenceText( + context, + _end == 1 + ? '지정한 날짜가 포함되어 반복이 종료됩니다. 일정의 시간대를 기준으로 적용됩니다.' + : '실제로 존재하는 회차만 횟수에 포함됩니다. 삭제한 회차는 보충되지 않습니다.', + _end == 1 + ? 'The end date is inclusive, in the schedule time zone.' + : 'Only existing occurrences count. Deleted occurrences are not replaced.', + ), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ]; } diff --git a/lib/presentation/recurring/recurrence_time_choice_sheet.dart b/lib/presentation/recurring/recurrence_time_choice_sheet.dart new file mode 100644 index 00000000..ef6e68ad --- /dev/null +++ b/lib/presentation/recurring/recurrence_time_choice_sheet.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_labels.dart'; + +class RecurrenceTimeChoiceSheet extends StatefulWidget { + const RecurrenceTimeChoiceSheet({ + super.key, + required this.date, + required this.timeZoneId, + }); + final DateTime date; + final String timeZoneId; + @override + State createState() => + _RecurrenceTimeChoiceSheetState(); +} + +class _RecurrenceTimeChoiceSheetState extends State { + RepeatedCivilTime _choice = RepeatedCivilTime.first; + @override + Widget build(BuildContext context) => RecurrenceSheet( + title: recurrenceText(context, '반복 시각 확인', 'Repeated time'), + action: recurrenceText(context, '적용', 'Apply'), + onAction: () => Navigator.of(context).pop(_choice), + children: [ + const SizedBox(height: 24), + Text( + recurrenceText(context, '반복 시각 확인', 'Choose the repeated time'), + style: Theme.of(context).textTheme.headlineSmall, + ), + Text( + recurrenceText( + context, + '${widget.timeZoneId}의 ${recurrenceDate(context, widget.date)}은 서머타임 종료로 두 번 발생합니다. 반복에 사용할 시각을 선택해 주세요.', + '${recurrenceDate(context, widget.date)} occurs twice in ${widget.timeZoneId}. Choose which occurrence to use.', + ), + ), + for (final choice in RepeatedCivilTime.values) + RecurrenceChoice( + label: recurrenceText( + context, + choice == RepeatedCivilTime.first ? '첫 번째 시각' : '두 번째 시각', + choice == RepeatedCivilTime.first + ? 'First occurrence' + : 'Second occurrence', + ), + selected: _choice == choice, + onTap: () => setState(() => _choice = choice), + ), + RecurrencePanel( + child: Text( + recurrenceText( + context, + '반복되는 모든 날짜에 이 선택이 그대로 적용됩니다. 해당 현지 시각이 존재하지 않는 경우에는 그 회차를 건너뛰며, 총 반복 횟수에서 제외됩니다.', + 'This choice applies to future repeated times. Nonexistent local times are skipped without consuming the count.', + ), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ); +} + +class RecurrenceSaveErrorSheet extends StatelessWidget { + const RecurrenceSaveErrorSheet({super.key, required this.message}); + final String message; + @override + Widget build(BuildContext context) => RecurrenceSheet( + title: recurrenceText(context, '저장할 수 없어요', 'Could not save'), + action: recurrenceText(context, '다시 저장하기', 'Retry saving'), + onAction: () => Navigator.of(context).pop(true), + children: [ + const SizedBox(height: 12), + RecurrencePanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(message, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + Text( + recurrenceText( + context, + '입력한 내용은 유지되었어요. 조건을 확인하거나 다시 시도해 주세요.', + 'Your input is preserved. Check the conditions or retry.', + ), + ), + ], + ), + ), + ], + ); +} diff --git a/lib/presentation/recurring/recurring_management_screen.dart b/lib/presentation/recurring/recurring_management_screen.dart index 7165522e..5eae2cd4 100644 --- a/lib/presentation/recurring/recurring_management_screen.dart +++ b/lib/presentation/recurring/recurring_management_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; import 'package:on_time_front/domain/use-cases/recurring_schedules_use_case.dart'; @@ -6,7 +7,6 @@ import 'package:on_time_front/presentation/recurring/recurrence_components.dart' import 'package:on_time_front/presentation/recurring/recurrence_labels.dart'; import 'package:on_time_front/presentation/schedule_create/screens/schedule_edit_screen.dart'; import 'package:on_time_front/presentation/shared/components/modal_wide_button.dart'; -import 'package:on_time_front/presentation/shared/components/two_action_dialog.dart'; class RecurringManagementScreen extends StatefulWidget { const RecurringManagementScreen({super.key, this.useCase}); @@ -38,7 +38,7 @@ class _RecurringManagementScreenState extends State { isScrollControlled: true, useSafeArea: true, builder: (context) => SizedBox( - height: MediaQuery.sizeOf(context).height * .9, + height: MediaQuery.sizeOf(context).height * .94, child: _SeriesDetail(summary: summary, useCase: _useCase), ), ); @@ -51,6 +51,15 @@ class _RecurringManagementScreenState extends State { future: _list, builder: (context, snapshot) => RecurrenceSheet( title: recurrenceText(context, '반복 일정 관리', 'Recurring schedules'), + footer: snapshot.hasData && snapshot.data!.isEmpty + ? ScreenActions( + action: recurrenceText(context, '약속 추가', 'Add appointment'), + onAction: () async { + await context.push('/scheduleCreate'); + if (mounted) _reload(); + }, + ) + : null, children: [ if (snapshot.connectionState != ConnectionState.done) const Center(child: CircularProgressIndicator()) @@ -67,32 +76,47 @@ class _RecurringManagementScreenState extends State { child: Text(recurrenceText(context, '다시 시도', 'Retry')), ), ] else if (snapshot.data!.isEmpty) ...[ - Text( - recurrenceText( - context, - '아직 반복 일정이 없어요.', - 'No recurring schedules yet.', + RecurrencePanel( + highlighted: true, + child: Text( + recurrenceText( + context, + '약속을 생성할 때 날짜·시간 단계에서 반복을 설정할 수 있습니다. 반복으로 등록하면 각 일정별 준비과정도 함께 관리할 수 있습니다.', + 'Choose Repeat in the date and time step when creating a schedule. Each series has its own preparation.', + ), ), - style: Theme.of(context).textTheme.titleLarge, ), + const SizedBox(height: 8), Text( recurrenceText( context, - '일정을 만들 때 날짜·시간 단계에서 반복을 설정해 보세요.', - 'Choose Repeat in the date and time step when creating a schedule.', + '등록된 반복 일정 · 0개', + 'Recurring schedules · 0', + ), + style: Theme.of(context).textTheme.titleSmall, + ), + RecurrencePanel( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 44), + child: Text( + recurrenceText( + context, + '아직 등록된 반복 일정이 없습니다.\n지금 약속을 추가하고 반복 일정을 관리해보세요.', + 'No recurring schedules yet.\nAdd an appointment to get started.', + ), + textAlign: TextAlign.center, ), ), ] else ...[ Text( recurrenceText(context, '반복 중인 일정', 'Active schedules'), - style: Theme.of(context).textTheme.titleLarge, + style: Theme.of(context).textTheme.titleSmall, ), for (final summary in snapshot.data!.where((s) => s.next != null)) _row(context, summary), if (snapshot.data!.any((s) => s.next == null)) Text( recurrenceText(context, '종료된 일정', 'Ended schedules'), - style: Theme.of(context).textTheme.titleLarge, + style: Theme.of(context).textTheme.titleSmall, ), for (final summary in snapshot.data!.where((s) => s.next == null)) _row(context, summary), @@ -101,26 +125,56 @@ class _RecurringManagementScreenState extends State { ), ), ); - Widget _row(BuildContext context, RecurringScheduleSummary summary) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RecurrenceValue( - label: summary.segment.schedule.scheduleName, - value: recurrenceLabel(context, summary.segment.rule), - onTap: () => _open(summary), - ), - const SizedBox(height: 8), - Text( - summary.next == null - ? recurrenceText(context, '예정된 회차 없음', 'No upcoming occurrences') - : recurrenceText( - context, - '다음 일정 ${recurrenceDate(context, summary.next!.scheduleTime)} · 준비 ${summary.segment.preparation.totalDuration.inMinutes}분', - 'Next ${recurrenceDate(context, summary.next!.scheduleTime)} · preparation ${summary.segment.preparation.totalDuration.inMinutes} min', + Widget _row( + BuildContext context, + RecurringScheduleSummary summary, + ) => Material( + color: Theme.of(context).colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: () => _open(summary), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + summary.segment.schedule.scheduleName, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + recurrenceLabel(context, summary.segment.rule), + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Text( + summary.next == null + ? recurrenceText( + context, + '예정된 회차 없음', + 'No upcoming occurrences', + ) + : recurrenceText( + context, + '다음 일정 ${recurrenceDate(context, summary.next!.scheduleTime)} · 준비 ${summary.segment.preparation.totalDuration.inMinutes}분', + 'Next ${recurrenceDate(context, summary.next!.scheduleTime)} · preparation ${summary.segment.preparation.totalDuration.inMinutes} min', + ), + style: Theme.of(context).textTheme.bodySmall, + ), + ], ), - style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right, size: 20), + ], + ), ), - ], + ), ); } @@ -148,25 +202,70 @@ class _SeriesDetailState extends State<_SeriesDetail> { } Future _end() async { - final confirmed = await showTwoActionDialog( - context, - config: TwoActionDialogConfig( - title: recurrenceText(context, '반복을 종료할까요?', 'End this series?'), - description: recurrenceText( - context, - '다음 회차부터 개별 수정한 회차를 포함해 삭제합니다. 진행 중인 준비와 지난 기록은 유지합니다.', - 'Deletes the next and following occurrences, including individual edits. Active preparation and history are preserved.', - ), - secondaryAction: DialogActionConfig( - label: recurrenceText(context, '취소', 'Cancel'), - ), - primaryAction: DialogActionConfig( - label: recurrenceText(context, '반복 종료', 'End series'), - variant: ModalWideButtonVariant.destructive, + final next = widget.summary.next!; + final confirmed = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => SizedBox( + height: MediaQuery.sizeOf(context).height * .94, + child: RecurrenceSheet( + title: recurrenceText(context, '반복 종료', 'End series'), + footer: ScreenActions( + action: recurrenceText(context, '반복 종료', 'End series'), + destructive: true, + backLabel: recurrenceText(context, '취소', 'Cancel'), + onBack: () => Navigator.pop(context, false), + onAction: () => Navigator.pop(context, true), + ), + children: [ + const SizedBox(height: 16), + Text( + recurrenceText( + context, + '${recurrenceDay(context, next.scheduleTime)}부터 이후 회차의 반복을 종료할까요?', + 'End the series from ${recurrenceDay(context, next.scheduleTime)}?', + ), + style: Theme.of(context).textTheme.titleSmall, + ), + Text( + recurrenceText( + context, + '아직 시작하지 않은 이후 회차는 모두 삭제됩니다. 이미 진행 중이거나 완료된 기록은 그대로 유지됩니다.', + 'Deletes upcoming occurrences. Active preparations and history are preserved.', + ), + ), + RecurrencePanel( + highlighted: true, + child: Text( + recurrenceText( + context, + '개별로 수정한 이후 회차도 삭제 대상에 포함됩니다.', + 'Individually edited following occurrences are also deleted.', + ), + ), + ), + RecurrenceValue( + label: recurrenceText(context, '종료되는 범위', 'Deleted occurrences'), + value: recurrenceText( + context, + '다음 회차부터 이후의 모든 회차', + 'The next and all following occurrences', + ), + ), + RecurrenceValue( + label: recurrenceText(context, '계속 유지되는 항목', 'Preserved'), + value: recurrenceText( + context, + '지난 기록과 현재 진행 중인 준비', + 'History and active preparations', + ), + ), + ], ), ), ); - if (confirmed != DialogActionResult.primary || !mounted) return; + if (confirmed != true || !mounted) return; setState(() => _busy = true); try { await widget.useCase.delete( @@ -192,59 +291,93 @@ class _SeriesDetailState extends State<_SeriesDetail> { Widget build(BuildContext context) { final segment = widget.summary.segment; return RecurrenceSheet( - title: recurrenceText(context, '반복 일정', 'Recurring schedule'), + title: segment.schedule.scheduleName, + spacing: 10, + footer: ScreenActions( + action: recurrenceText(context, '뒤로', 'Back'), + onAction: () => Navigator.of(context).pop(), + ), children: [ - Text( - segment.schedule.scheduleName, - style: Theme.of(context).textTheme.titleLarge, - ), - RecurrenceValue( - label: recurrenceText(context, '반복', 'Repeat'), - value: recurrenceLabel(context, segment.rule), + RecurrencePanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recurrenceText(context, '반복 규칙', 'Repeat'), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 6), + Text( + recurrenceLabel(context, segment.rule), + style: Theme.of(context).textTheme.titleSmall, + ), + Text( + recurrenceEndLabel(context, segment.rule), + style: Theme.of(context).textTheme.bodySmall, + ), + if (widget.summary.next != null) ...[ + const SizedBox(height: 16), + Text( + recurrenceText(context, '다음 일정', 'Next occurrence'), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 6), + Text( + recurrenceDate(context, widget.summary.next!.scheduleTime), + style: Theme.of(context).textTheme.titleSmall, + ), + ], + ], + ), ), - RecurrenceValue( - label: recurrenceText(context, '종료 조건', 'Ends'), - value: recurrenceEndLabel(context, segment.rule), + Text( + recurrenceText(context, '전용 준비', 'Preparation for this series'), + style: Theme.of(context).textTheme.titleSmall, ), - if (widget.summary.next != null) - RecurrenceValue( - label: recurrenceText(context, '다음 일정', 'Next occurrence'), - value: recurrenceDate(context, widget.summary.next!.scheduleTime), + for (final step in segment.preparation.ordered.preparationStepList) + RecurrencePanel( + child: Row( + children: [ + Expanded(child: Text(step.preparationName)), + Text( + '${step.preparationTime.inMinutes}${recurrenceText(context, '분', ' min')}', + ), + ], + ), ), + const Divider(height: 24), + Text( + recurrenceText(context, '독립 관리 안내', 'Manage independently'), + style: Theme.of(context).textTheme.titleSmall, + ), Text( recurrenceText( context, - '이 반복 일정의 준비과정', - 'Preparation for this series', + '반복 규칙과 준비 과정은 각각 독립적으로 관리할 수 있어요. 필요에 따라 따로 수정할 수 있습니다.', + 'You can edit the recurrence rule and preparation independently.', ), - style: Theme.of(context).textTheme.titleSmall, ), - for (final step in segment.preparation.ordered.preparationStepList) - Row( - children: [ - Expanded(child: Text(step.preparationName)), - Text( - '${step.preparationTime.inMinutes}${recurrenceText(context, '분', ' min')}', - ), - ], - ), if (widget.summary.next != null) ...[ ModalWideButton( - text: recurrenceText( - context, - '이후 일정 수정', - 'Edit following occurrences', - ), - variant: ModalWideButtonVariant.primary, + text: recurrenceText(context, '반복 규칙 수정', 'Edit recurrence'), + variant: ModalWideButtonVariant.subtle, + layout: ModalWideButtonLayout.full, + height: 48, + onPressed: _busy ? null : _edit, + ), + ModalWideButton( + text: recurrenceText(context, '준비과정 수정', 'Edit preparation'), + variant: ModalWideButtonVariant.subtle, layout: ModalWideButtonLayout.full, - height: 52, + height: 48, onPressed: _busy ? null : _edit, ), + const Divider(), ModalWideButton( text: recurrenceText(context, '반복 종료', 'End series'), - variant: ModalWideButtonVariant.destructive, + variant: ModalWideButtonVariant.neutral, layout: ModalWideButtonLayout.full, - height: 52, + height: 48, onPressed: _busy ? null : _end, isLoading: _busy, ), diff --git a/lib/presentation/schedule_create/components/schedule_multi_page_form.dart b/lib/presentation/schedule_create/components/schedule_multi_page_form.dart index 353246be..3db0f405 100644 --- a/lib/presentation/schedule_create/components/schedule_multi_page_form.dart +++ b/lib/presentation/schedule_create/components/schedule_multi_page_form.dart @@ -1,3 +1,5 @@ +import 'package:on_time_front/presentation/recurring/recurrence_time_choice_sheet.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; import 'package:on_time_front/presentation/recurring/recurrence_labels.dart'; import 'package:on_time_front/presentation/recurring/recurrence_review_sheet.dart'; @@ -14,9 +16,7 @@ import 'package:on_time_front/presentation/schedule_create/schedule_place_moving import 'package:on_time_front/presentation/schedule_create/schedule_place_moving_time/screens/schedule_place_moving_time_form.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_cubit.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form.dart'; -import 'package:on_time_front/presentation/shared/components/modal_wide_button.dart'; import 'package:on_time_front/presentation/shared/components/step_progress.dart'; -import 'package:on_time_front/presentation/shared/components/two_action_dialog.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; class ScheduleMultiPageForm extends StatefulWidget { @@ -71,7 +71,7 @@ class _ScheduleMultiPageFormState extends State isScrollControlled: true, useSafeArea: true, builder: (context) => SizedBox( - height: MediaQuery.sizeOf(context).height * .9, + height: MediaQuery.sizeOf(context).height * .94, child: RecurrenceReviewSheet( review: state.recurrenceReview!, form: state, @@ -93,39 +93,16 @@ class _ScheduleMultiPageFormState extends State } else if (state.submissionStatus == ScheduleFormSubmissionStatus.timeChoice) { final bloc = context.read(); - final choice = await showDialog( + final choice = await showModalBottomSheet( context: context, - builder: (context) => SimpleDialog( - title: Text( - recurrenceText(context, '두 번 발생하는 시각', 'Repeated time'), + isScrollControlled: true, + useSafeArea: true, + builder: (context) => SizedBox( + height: MediaQuery.sizeOf(context).height * .94, + child: RecurrenceTimeChoiceSheet( + date: state.repeatedTimeDate!, + timeZoneId: state.timeZoneId, ), - children: [ - Padding( - padding: const EdgeInsets.all(24), - child: Text( - recurrenceText( - context, - '${recurrenceDate(context, state.repeatedTimeDate!)}은 두 번 발생해요. 이후 같은 경우에도 선택한 순서를 적용합니다.', - '${recurrenceDate(context, state.repeatedTimeDate!)} occurs twice. This choice also applies to future repeated times.', - ), - ), - ), - for (final choice in RepeatedCivilTime.values) - SimpleDialogOption( - onPressed: () => Navigator.of(context).pop(choice), - child: Text( - recurrenceText( - context, - choice == RepeatedCivilTime.first - ? '첫 번째 시각' - : '두 번째 시각', - choice == RepeatedCivilTime.first - ? 'First occurrence' - : 'Second occurrence', - ), - ), - ), - ], ), ); if (bloc.isClosed) return; @@ -139,17 +116,20 @@ class _ScheduleMultiPageFormState extends State Navigator.of(context).pop(true); } else if (state.submissionStatus == ScheduleFormSubmissionStatus.failure) { - final l10n = AppLocalizations.of(context)!; - showTwoActionDialog( - context, - config: TwoActionDialogConfig( - title: state.submissionError ?? l10n.error, - primaryAction: DialogActionConfig( - label: l10n.ok, - variant: ModalWideButtonVariant.destructive, + final retry = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => SizedBox( + height: MediaQuery.sizeOf(context).height * .94, + child: RecurrenceSaveErrorSheet( + message: + state.submissionError ?? + AppLocalizations.of(context)!.error, ), ), ); + if (mounted && retry == true) widget.onSaved?.call(); } }, child: BlocBuilder( @@ -196,10 +176,11 @@ class _ScheduleMultiPageFormState extends State child: Column( children: [ TopBar( + showAction: false, actionLabel: _tabController.index == 3 ? recurrenceText( context, - state.recurrenceRule == null ? '저장' : '확인', + state.recurrenceRule == null ? '저장' : '검토하기', state.recurrenceRule == null ? 'Save' : 'Review', @@ -220,8 +201,14 @@ class _ScheduleMultiPageFormState extends State currentStep: _tabController.index, totalSteps: _tabController.length, singleLine: true, + labels: [ + recurrenceText(context, '기본 정보', 'Details'), + recurrenceText(context, '날짜와 시간', 'Date & time'), + recurrenceText(context, '장소와 이동', 'Travel'), + recurrenceText(context, '준비 과정', 'Preparation'), + ], ), - SizedBox(height: 41), + SizedBox(height: 24), Expanded( child: PageView( physics: const NeverScrollableScrollPhysics(), @@ -235,6 +222,25 @@ class _ScheduleMultiPageFormState extends State ], ), ), + const SizedBox(height: 12), + ScreenActions( + action: _tabController.index == 3 + ? recurrenceText( + context, + state.recurrenceRule == null ? '저장' : '검토하기', + state.recurrenceRule == null + ? 'Save' + : 'Review', + ) + : AppLocalizations.of(context)!.next, + onAction: state.isValid && !isSubmitting + ? () => _onNextPageButtonClicked(context) + : null, + onBack: isSubmitting + ? null + : _onPreviousPageButtonClicked, + loading: isSubmitting, + ), ], ), ); diff --git a/lib/presentation/schedule_create/components/top_bar.dart b/lib/presentation/schedule_create/components/top_bar.dart index 8e9ae740..3ff213ca 100644 --- a/lib/presentation/schedule_create/components/top_bar.dart +++ b/lib/presentation/schedule_create/components/top_bar.dart @@ -9,12 +9,14 @@ class TopBar extends StatelessWidget { required this.isNextButtonEnabled, this.title, this.actionLabel, + this.showAction = true, }); final void Function()? onNextPageButtonClicked; final void Function()? onPreviousPageButtonClicked; final String? title; final String? actionLabel; + final bool showAction; final bool isNextButtonEnabled; // 버튼 활성화 여부 @override @@ -27,6 +29,7 @@ class TopBar extends StatelessWidget { IconButton( icon: Icon( Icons.arrow_back_ios_rounded, + size: 18, color: colorScheme.outlineVariant, ), onPressed: onPreviousPageButtonClicked, @@ -35,16 +38,21 @@ class TopBar extends StatelessWidget { child: Center( child: Text( title ?? AppLocalizations.of(context)!.addAppointment, - style: Theme.of(context).textTheme.titleLarge, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontSize: showAction ? 20 : 18), ), ), ), // 다음 페이지 버튼 // 버튼 활성화 여부에 따라 색상 변화 추후 추가 가능 - TextButton( - onPressed: isNextButtonEnabled ? onNextPageButtonClicked : null, - child: Text(actionLabel ?? AppLocalizations.of(context)!.next), - ), + if (!showAction) + const SizedBox(width: 48) + else + TextButton( + onPressed: isNextButtonEnabled ? onNextPageButtonClicked : null, + child: Text(actionLabel ?? AppLocalizations.of(context)!.next), + ), ], ); } diff --git a/lib/presentation/schedule_create/schedule_date_time/screens/schedule_date_time_form.dart b/lib/presentation/schedule_create/schedule_date_time/screens/schedule_date_time_form.dart index 58e53dc1..8030434b 100644 --- a/lib/presentation/schedule_create/schedule_date_time/screens/schedule_date_time_form.dart +++ b/lib/presentation/schedule_create/schedule_date_time/screens/schedule_date_time_form.dart @@ -36,91 +36,130 @@ class ScheduleDateTimeForm extends StatelessWidget { .state; return ListView( children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 2, - child: TextField( - readOnly: true, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.appointmentTime, - hintText: _localizedDateString(context, DateTime.now()), - hintStyle: fadedHintStyle, - ), - controller: TextEditingController( - text: state.scheduleDate.value == null - ? null - : _localizedDateString( - context, - state.scheduleDate.value!, - ), - ), - onTap: () { - context.showCupertinoDatePickerModal( - title: AppLocalizations.of(context)!.enterDate, - mode: CupertinoDatePickerMode.date, - initialValue: - state.scheduleDate.value ?? DateTime.now(), - onDisposed: () { - context - .read() - .validateCurrentSelection(); - }, - onSaved: (DateTime newDateTime) { - context - .read() - .scheduleDateChanged(newDateTime); - }, - ); - }, - ), + Text( + recurrenceText(context, '날짜와 시간', 'Date and time'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + recurrenceText( + context, + '약속이 언제 시작되는지, 반복할지 선택하세요.', + 'Choose when the appointment starts and whether it repeats.', + ), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + TextField( + readOnly: true, + decoration: InputDecoration( + filled: true, + labelStyle: const TextStyle( + fontSize: 12, + color: Color(0xFF545454), ), - SizedBox(width: 30), - Expanded( - flex: 1, - child: TextField( - readOnly: true, - decoration: InputDecoration( - labelText: '', - hintText: DateFormat.jm( - Localizations.localeOf(context).toString(), - ).format(DateTime.now()), - hintStyle: fadedHintStyle, - ), - controller: TextEditingController( - text: state.scheduleTime.value == null - ? null - : DateFormat.jm( - Localizations.localeOf(context).toString(), - ).format(state.scheduleTime.value!), - ), - onTap: () { - context.showCupertinoDatePickerModal( - title: AppLocalizations.of(context)!.enterTime, - mode: CupertinoDatePickerMode.time, - initialValue: - state.scheduleTime.value ?? DateTime.now(), - onDisposed: () { - context - .read() - .validateCurrentSelection(); - }, - onSaved: (DateTime newDateTime) { - context - .read() - .scheduleTimeChanged(newDateTime); - }, - ); - }, - ), + floatingLabelStyle: const TextStyle( + fontSize: 12, + color: Color(0xFF545454), + ), + fillColor: Theme.of(context).colorScheme.surfaceContainerLowest, + border: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(8), + ), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(8), + ), + prefixIcon: const Icon(Icons.calendar_today_outlined, size: 24), + suffixIcon: const Icon(Icons.chevron_right, size: 20), + labelText: AppLocalizations.of(context)!.appointmentTime, + hintText: _localizedDateString(context, DateTime.now()), + hintStyle: fadedHintStyle, + ), + controller: TextEditingController( + text: state.scheduleDate.value == null + ? null + : _localizedDateString(context, state.scheduleDate.value!), + ), + onTap: () { + context.showCupertinoDatePickerModal( + title: AppLocalizations.of(context)!.enterDate, + mode: CupertinoDatePickerMode.date, + initialValue: state.scheduleDate.value ?? DateTime.now(), + onDisposed: () { + context + .read() + .validateCurrentSelection(); + }, + onSaved: (DateTime newDateTime) { + context.read().scheduleDateChanged( + newDateTime, + ); + }, + ); + }, + ), + const SizedBox(height: 8), + TextField( + readOnly: true, + decoration: InputDecoration( + filled: true, + labelStyle: const TextStyle( + fontSize: 12, + color: Color(0xFF545454), + ), + floatingLabelStyle: const TextStyle( + fontSize: 12, + color: Color(0xFF545454), + ), + fillColor: Theme.of(context).colorScheme.surfaceContainerLowest, + border: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(8), ), - ], + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(8), + ), + prefixIcon: const Icon(Icons.schedule, size: 24), + suffixIcon: const Icon(Icons.chevron_right, size: 20), + labelText: recurrenceText(context, '시간', 'Time'), + hintText: DateFormat.jm( + Localizations.localeOf(context).toString(), + ).format(DateTime.now()), + hintStyle: fadedHintStyle, + ), + controller: TextEditingController( + text: state.scheduleTime.value == null + ? null + : DateFormat.jm( + Localizations.localeOf(context).toString(), + ).format(state.scheduleTime.value!), + ), + onTap: () { + context.showCupertinoDatePickerModal( + title: AppLocalizations.of(context)!.enterTime, + mode: CupertinoDatePickerMode.time, + initialValue: state.scheduleTime.value ?? DateTime.now(), + onDisposed: () { + context + .read() + .validateCurrentSelection(); + }, + onSaved: (DateTime newDateTime) { + context.read().scheduleTimeChanged( + newDateTime, + ); + }, + ); + }, ), const SizedBox(height: 24), if (form.originalSchedule == null || form.recurrenceRule != null) RecurrenceValue( - label: recurrenceText(context, '반복', 'Repeat'), + label: recurrenceText(context, '반복 설정', 'Repeat'), + icon: Icons.repeat, value: form.recurrenceRule == null ? recurrenceText(context, '반복 안 함', 'Does not repeat') : recurrenceLabel(context, form.recurrenceRule!), @@ -137,7 +176,7 @@ class ScheduleDateTimeForm extends StatelessWidget { isScrollControlled: true, useSafeArea: true, builder: (context) => SizedBox( - height: MediaQuery.sizeOf(context).height * .9, + height: MediaQuery.sizeOf(context).height * .94, child: RecurrenceSettingsSheet( start: state.selectedScheduleDateTime!, timeZoneId: form.timeZoneId, @@ -165,14 +204,41 @@ class ScheduleDateTimeForm extends StatelessWidget { } }, ), - if (state.isRecurring) - Text( - recurrenceText( - context, - '시작일 이후 조건에 맞는 날짜부터 반복해요. 실제 첫 일정은 준비시간을 반영해 저장 전에 확인합니다.', - 'Repeats from the first matching date. Review the actual first occurrence after setting preparation time.', + if (state.isRecurring && form.recurrenceRule != null) ...[ + const SizedBox(height: 16), + RecurrencePanel( + highlighted: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recurrenceText(context, '반복 요약', 'Recurrence summary'), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 6), + Text(recurrenceLabel(context, form.recurrenceRule!)), + const SizedBox(height: 14), + Text( + recurrenceText(context, '첫 일정 날짜', 'First occurrence'), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 6), + Text( + recurrenceText( + context, + '실제 첫 일정은 준비시간을 반영해 저장 전에 확인합니다.', + 'Review the first occurrence after setting preparation time.', + ), + style: Theme.of(context).textTheme.bodySmall, + ), + ], ), ), + ], if (!state.isRecurring && state.hasPreviousOverlapMessage) Padding( padding: const EdgeInsets.only(top: 8.0, left: 16.0), @@ -208,27 +274,24 @@ class ScheduleDateTimeForm extends StatelessWidget { style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 8), - Wrap( + Column( spacing: 8, - runSpacing: 8, children: [ for ( var index = 0; index < state.occurrenceOffsetOptions.length; index++ ) - ChoiceChip( - label: Text( - _occurrenceLabel( - context, - index, - state.occurrenceOffsetOptions[index], - ), + RecurrenceChoice( + label: _occurrenceLabel( + context, + index, + state.occurrenceOffsetOptions[index], ), selected: state.selectedOccurrenceOffsetSeconds == state.occurrenceOffsetOptions[index], - onSelected: (_) => context + onTap: () => context .read() .occurrenceOffsetSelected( state.occurrenceOffsetOptions[index], diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form.dart index 939829fd..1b215116 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form.dart @@ -1,3 +1,5 @@ +import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; import 'package:on_time_front/presentation/recurring/recurrence_labels.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -29,6 +31,14 @@ class _ScheduleSpareAndPreparingTimeFormState Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { + if (context + .read() + .scheduleFormBloc + .state + .recurrenceRule != + null) { + return _recurringPreparation(context, state); + } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -145,4 +155,138 @@ class _ScheduleSpareAndPreparingTimeFormState }, ); } + + Future _editPreparation(ScheduleFormSpareTimeState state) async { + final draft = getIt(); + final cubit = context.read(); + final before = + state.preparation ?? const PreparationEntity(preparationStepList: []); + draft.setDraft(before); + await context.push('/preparationEdit'); + if (!mounted) return; + final after = draft.state; + if (after != null && after != before) cubit.preparationChanged(after); + draft.clear(); + } + + Widget _recurringPreparation( + BuildContext context, + ScheduleFormSpareTimeState state, + ) { + final steps = + state.preparation?.ordered.preparationStepList ?? + []; + return ListView( + children: [ + Text( + recurrenceText( + context, + '준비 과정을 확인하고\n필요한 항목을 수정하세요.', + 'Review your preparation\nand edit as needed.', + ), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontSize: 23, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 16), + Text( + recurrenceText( + context, + '이 약속의 반복에만 적용되는 준비 과정입니다.\n기본 준비과정의 복사본으로 생성되어\n이곳에서 수정해도 기존 설정에는 영향을 주지 않습니다.', + 'This preparation belongs only to this series. Changes do not affect your default preparation.', + ), + style: Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.6), + ), + const SizedBox(height: 20), + ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + itemCount: steps.length, + onReorderItem: (oldIndex, newIndex) { + final reordered = [...steps]; + reordered.insert(newIndex, reordered.removeAt(oldIndex)); + context.read().preparationChanged( + PreparationEntity( + preparationStepList: [ + for (var i = 0; i < reordered.length; i++) + PreparationStepEntity( + id: reordered[i].id, + preparationName: reordered[i].preparationName, + preparationTime: reordered[i].preparationTime, + nextPreparationId: i + 1 < reordered.length + ? reordered[i + 1].id + : null, + ), + ], + ), + ); + }, + itemBuilder: (context, i) => Padding( + key: ValueKey(steps[i].id), + padding: const EdgeInsets.only(bottom: 8), + child: RecurrencePanel( + padding: EdgeInsets.zero, + child: ListTile( + leading: ReorderableDragStartListener( + index: i, + child: const Icon(Icons.drag_handle), + ), + title: Text(steps[i].preparationName), + trailing: Text( + formatDurationAsMinutes(context, steps[i].preparationTime), + ), + onTap: () => _editPreparation(state), + ), + ), + ), + ), + if (steps.isEmpty) + TextButton( + onPressed: () => _editPreparation(state), + child: Text(recurrenceText(context, '준비 과정 추가', 'Add preparation')), + ), + const SizedBox(height: 4), + RecurrenceValue( + label: recurrenceText( + context, + '여유 (준비 합계 제외)', + 'Buffer (excluded from preparation total)', + ), + value: formatDurationAsMinutes( + context, + state.spareTime.value ?? Duration.zero, + ), + icon: Icons.timer_outlined, + onTap: () => context.showCupertinoMinutePickerModal( + title: AppLocalizations.of(context)!.enterTime, + initialValue: state.spareTime.value ?? Duration.zero, + onSaved: (value) => context + .read() + .spareTimeChanged(value), + ), + ), + const SizedBox(height: 16), + RecurrencePanel( + highlighted: true, + child: Row( + children: [ + Expanded( + child: Text( + recurrenceText(context, '총 준비 시간', 'Total preparation'), + ), + ), + Text( + formatDurationAsMinutes(context, state.totalPreparationTime), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ), + ], + ); + } } diff --git a/lib/presentation/shared/components/bottom_nav_bar_scaffold.dart b/lib/presentation/shared/components/bottom_nav_bar_scaffold.dart index 28b0a4de..b9f931b9 100644 --- a/lib/presentation/shared/components/bottom_nav_bar_scaffold.dart +++ b/lib/presentation/shared/components/bottom_nav_bar_scaffold.dart @@ -54,6 +54,80 @@ class _BottomNavigationBarScaffoldState extends State { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final currentLocation = GoRouterState.of(context).uri.path; + if (currentLocation == '/myPage') { + return Scaffold( + body: widget.child, + bottomNavigationBar: SafeArea( + top: false, + child: SizedBox( + height: 56, + child: Row( + children: [ + Expanded( + child: InkWell( + onTap: () => onDestinationSelected(0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.home_outlined, size: 26), + Text( + AppLocalizations.of(context)!.home, + style: const TextStyle(fontSize: 11), + ), + ], + ), + ), + ), + Expanded( + child: InkWell( + onTap: () => showModalBottomSheet( + context: context, + isDismissible: false, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => const ScheduleCreateScreen(), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_circle, + color: colorScheme.primary, + size: 30, + ), + Text( + AppLocalizations.of(context)!.addAppointment, + style: const TextStyle(fontSize: 10), + ), + ], + ), + ), + ), + Expanded( + child: InkWell( + onTap: () => onDestinationSelected(1), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.person_outline, + color: colorScheme.primary, + size: 26, + ), + Text( + AppLocalizations.of(context)!.myPage, + style: const TextStyle(fontSize: 11), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } return Scaffold( body: widget.child, backgroundColor: _getBackgroundColor(context), diff --git a/lib/presentation/shared/components/modal_wide_button.dart b/lib/presentation/shared/components/modal_wide_button.dart index e92f59f5..562b3b1b 100644 --- a/lib/presentation/shared/components/modal_wide_button.dart +++ b/lib/presentation/shared/components/modal_wide_button.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -enum ModalWideButtonVariant { neutral, primary, destructive } +enum ModalWideButtonVariant { neutral, primary, destructive, subtle } enum ModalWideButtonLayout { fixed, full, flex } @@ -36,6 +36,10 @@ class ModalWideButton extends StatelessWidget { colorScheme.surfaceContainerLow, colorScheme.outline, ), + ModalWideButtonVariant.subtle => ( + colorScheme.primaryContainer, + colorScheme.onPrimaryContainer, + ), ModalWideButtonVariant.primary => ( colorScheme.primary, colorScheme.onPrimary, @@ -54,10 +58,13 @@ class ModalWideButton extends StatelessWidget { color: foregroundColor, ); + final scaledHeight = MediaQuery.textScalerOf(context).scale(16) * 1.4 + 20; + final effectiveHeight = scaledHeight > height ? scaledHeight : height; + final minSize = switch (layout) { ModalWideButtonLayout.flex => Size.zero, - ModalWideButtonLayout.fixed => Size(fixedWidth, height), - ModalWideButtonLayout.full => Size(double.infinity, height), + ModalWideButtonLayout.fixed => Size(fixedWidth, effectiveHeight), + ModalWideButtonLayout.full => Size(double.infinity, effectiveHeight), }; final button = SizedBox( @@ -66,11 +73,15 @@ class ModalWideButton extends StatelessWidget { ModalWideButtonLayout.full => double.infinity, ModalWideButtonLayout.flex => null, }, - height: height, + height: effectiveHeight, child: TextButton( onPressed: isLoading ? null : onPressed, style: ButtonStyle( - backgroundColor: WidgetStateProperty.all(backgroundColor), + backgroundColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.disabled) + ? colorScheme.surfaceDim + : backgroundColor, + ), shape: WidgetStateProperty.all( RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), diff --git a/lib/presentation/shared/components/step_progress.dart b/lib/presentation/shared/components/step_progress.dart index f530611b..aa87d625 100644 --- a/lib/presentation/shared/components/step_progress.dart +++ b/lib/presentation/shared/components/step_progress.dart @@ -7,8 +7,10 @@ class StepProgress extends StatelessWidget { required this.currentStep, required this.totalSteps, this.singleLine = false, + this.labels, }); + final List? labels; final int currentStep; final int totalSteps; @@ -50,6 +52,62 @@ class StepProgress extends StatelessWidget { @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; + if (labels != null) { + final colors = Theme.of(context).colorScheme; + return Stack( + children: [ + Positioned( + top: 12, + left: 24, + right: 24, + child: Container(height: 1, color: colors.primaryContainer), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < totalSteps; i++) + Expanded( + child: Column( + children: [ + Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: i <= currentStep + ? colors.primary + : colors.primaryContainer, + ), + child: Text( + i < currentStep ? '✓' : '${i + 1}', + style: TextStyle( + fontSize: 12, + color: i <= currentStep + ? colors.onPrimary + : colors.onPrimaryContainer, + ), + ), + ), + const SizedBox(height: 8), + Text( + labels![i], + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + color: i == currentStep + ? colors.primary + : colors.outline, + ), + ), + ], + ), + ), + ], + ), + ], + ); + } return Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/presentation/shared/router/go_router.dart b/lib/presentation/shared/router/go_router.dart index 761b8263..d4dc28a2 100644 --- a/lib/presentation/shared/router/go_router.dart +++ b/lib/presentation/shared/router/go_router.dart @@ -137,8 +137,12 @@ GoRouter goRouterConfig( ), GoRoute( path: '/myData', - pageBuilder: (context, state) => - _buildAppRoutePage(state: state, child: const MyDataScreen()), + pageBuilder: (context, state) => _buildAppRoutePage( + state: state, + child: MyDataScreen( + initialAction: state.uri.queryParameters['action'], + ), + ), ), GoRoute( path: '/privacyPolicy', diff --git a/lib/presentation/startup/screens/local_data_recovery_screen.dart b/lib/presentation/startup/screens/local_data_recovery_screen.dart index 47b130ac..8d024321 100644 --- a/lib/presentation/startup/screens/local_data_recovery_screen.dart +++ b/lib/presentation/startup/screens/local_data_recovery_screen.dart @@ -1,13 +1,23 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:on_time_front/core/database/local_data_reset_service.dart'; import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; +import 'package:on_time_front/presentation/shared/components/modal_wide_button.dart'; class LocalDataRecoveryScreen extends StatefulWidget { - const LocalDataRecoveryScreen({super.key}); - + const LocalDataRecoveryScreen({ + super.key, + this.reset, + this.onRetry, + this.onResetComplete, + }); + final Future Function()? reset; + final VoidCallback? onRetry; + final VoidCallback? onResetComplete; @override State createState() => _LocalDataRecoveryScreenState(); @@ -15,87 +25,214 @@ class LocalDataRecoveryScreen extends StatefulWidget { class _LocalDataRecoveryScreenState extends State { bool _busy = false; - + String? _error; @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, + Widget build(BuildContext context) => PopScope( + canPop: !_busy, + child: Scaffold( + body: RecurrenceSheet( + title: '데이터 복구', + showBack: false, + footer: _busy + ? const Column( children: [ - const Icon(Icons.storage_rounded, size: 64), - const SizedBox(height: 20), - Text( - '로컬 데이터를 열 수 없습니다.', - style: Theme.of(context).textTheme.titleLarge, - textAlign: TextAlign.center, + ModalWideButton( + onPressed: null, + text: '다시 시도', + layout: ModalWideButtonLayout.full, + height: 44, + ), + SizedBox(height: 8), + ModalWideButton( + onPressed: null, + text: '초기화 실행', + layout: ModalWideButtonLayout.full, + height: 44, ), - const SizedBox(height: 12), - const Text( - 'OnTime은 데이터를 자동으로 삭제하지 않았습니다. 잠시 후 다시 시도하거나, ' - '복구할 수 없는 경우에만 모든 로컬 데이터를 초기화하세요.', - textAlign: TextAlign.center, + ], + ) + : null, + children: _busy + ? [ + SizedBox(height: MediaQuery.sizeOf(context).height * .30), + const Center( + child: SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(strokeWidth: 3), + ), + ), + const SizedBox(height: 8), + const Text( + '로컬 데이터 초기화 중', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + const Text( + '처리가 끝날 때까지 잠시 기다려 주세요.', + textAlign: TextAlign.center, + ), + ] + : [ + const SizedBox(height: 36), + Center( + child: SvgPicture.asset( + 'assets/design/recovery_warning.svg', + width: 68, + height: 68, ), - const SizedBox(height: 24), - FilledButton( - onPressed: _busy - ? null - : () { - context.read().add( - const AuthUserSubscriptionRequested(), - ); - }, - child: const Text('다시 시도'), + ), + const Text( + '기기에서 로컬 데이터를\n열 수 없습니다', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24, + height: 1.3, + fontWeight: FontWeight.w700, ), - TextButton( - onPressed: _busy ? null : _confirmReset, - child: Text( - '모든 로컬 데이터 초기화', - style: TextStyle( - color: Theme.of(context).colorScheme.error, + ), + const Text( + '기기에서 데이터를 열거나 마이그레이션할 수 없는 상태입니다.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, height: 1.5), + ), + const SizedBox(height: 4), + const RecurrencePanel( + highlighted: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '데이터는 자동으로 삭제되지 않았습니다.', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), ), - ), + SizedBox(height: 6), + Text( + '기기에 그대로 보존되어 있으니, 아래 방법을 시도해 주세요.', + style: TextStyle(fontSize: 12, height: 1.5), + ), + ], ), - if (_busy) - const Padding( - padding: EdgeInsets.only(top: 12), - child: CircularProgressIndicator(), + ), + const SizedBox(height: 4), + ModalWideButton( + text: '다시 시도하기', + layout: ModalWideButtonLayout.full, + variant: ModalWideButtonVariant.primary, + height: 44, + onPressed: + widget.onRetry ?? + () => context.read().add( + const AuthUserSubscriptionRequested(), + ), + ), + ModalWideButton( + text: '모든 로컬 데이터 초기화', + layout: ModalWideButtonLayout.full, + variant: ModalWideButtonVariant.subtle, + height: 44, + onPressed: _confirmReset, + ), + const RecurrencePanel( + child: Text( + '위 방법으로도 복구할 수 없는 경우,\n기기의 모든 로컬 데이터를 초기화할 수 있습니다.\n\n원격 복구나 로그인 기능은 제공하지 않습니다.', + style: TextStyle(fontSize: 12, height: 1.5), + ), + ), + if (_error != null) + Text( + _error!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, ), - ], - ), - ), - ), - ), + ), + ], ), - ); - } + ), + ); Future _confirmReset() async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('복구하지 않고 삭제할까요?'), - content: const Text('현재 설치의 모든 데이터와 암호화 키가 삭제되며 되돌릴 수 없습니다.'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('취소'), - ), - FilledButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('모두 삭제'), - ), - ], + if (_busy) return; + final confirmed = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const LocalDataResetConfirmationScreen(), ), ); - if (confirmed != true) return; - setState(() => _busy = true); - await getIt().reset(); - if (mounted) context.go('/resetComplete'); + if (confirmed != true || !mounted) return; + setState(() { + _busy = true; + _error = null; + }); + try { + await (widget.reset ?? getIt().reset)(); + if (!mounted) return; + if (widget.onResetComplete != null) { + widget.onResetComplete!(); + } else { + context.go('/resetComplete'); + } + } catch (_) { + if (mounted) { + setState(() { + _busy = false; + _error = '초기화하지 못했습니다. 다시 시도해 주세요.'; + }); + } + } } } + +class LocalDataResetConfirmationScreen extends StatelessWidget { + const LocalDataResetConfirmationScreen({super.key}); + @override + Widget build(BuildContext context) => Scaffold( + body: RecurrenceSheet( + title: '데이터 초기화', + footer: ScreenActions( + action: '모두 삭제', + destructive: true, + backLabel: '취소', + onBack: () => Navigator.of(context).pop(false), + onAction: () => Navigator.of(context).pop(true), + ), + children: [ + const SizedBox(height: 48), + Text( + '복구하지 않고 삭제하시겠습니까?', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + const Text( + '이 설치의 모든 로컬 데이터와 암호화 키가 삭제됩니다. 삭제된 데이터는 되돌릴 수 없습니다.', + style: TextStyle(height: 1.6), + ), + const RecurrencePanel( + child: Text( + '• 이 기기에 저장된 모든 데이터가 삭제됩니다.\n\n• 암호화 키도 함께 삭제되어 복구할 수 없습니다.\n\n• 이 작업은 되돌릴 수 없습니다.', + style: TextStyle(fontSize: 14), + ), + ), + const RecurrencePanel( + highlighted: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '백업 파일은 직접 삭제해야 합니다.', + style: TextStyle(fontWeight: FontWeight.w700), + ), + SizedBox(height: 8), + Text( + '다른 기기나 클라우드 등 외부에 저장한 백업 파일은 이 작업으로 삭제되지 않습니다. 필요 시 직접 삭제해 주세요.', + style: TextStyle(fontSize: 12, height: 1.5), + ), + ], + ), + ), + ], + ), + ); +} diff --git a/plans/figma-code-refresh-20260923.md b/plans/figma-code-refresh-20260923.md new file mode 100644 index 00000000..15e7a431 --- /dev/null +++ b/plans/figma-code-refresh-20260923.md @@ -0,0 +1,19 @@ +# Figma v3 화면을 Flutter에 반영 + +기준: FIdR6bUMHScn9FWbwqrBsA 원본 26개 화면(2026-09-23 교체 완료), docs/design/redesign-20260923/original-replacement. + +- 공통 화면 헤더, 하단 액션, 선택 카드, 정보 패널을 기존 위젯과 색상 토큰으로 구현한다. +- 반복 설정(단위/주간/월간/종료), 날짜·시간, 준비과정, 검토(충돌/분리/시각 선택/오류), 관리/상세/빈 상태/종료/회차를 실제 상태에 연결한다. +- 마이페이지 그룹과 복구/초기화/완료/개인정보 화면을 같은 패턴으로 맞춘다. +- 디자인의 날짜/시간/스위치 예시는 테스트 fixture로만 사용하며 실제 데이터와 기본값은 유지한다. +- 기기 상태 표시줄은 운영체제가 소유한다. 본문은 스크롤, 액션은 하단 고정, 큰 글자/좁은 화면에서 잘리지 않도록 구성한다. +- 생성 파일을 갱신하고 analyzer 및 관련 동작 테스트를 실행한다. 실제 Flutter 렌더를 저장해 Figma와 대조한다. + +## 완료 결과 + +- 26개 Figma 상태를 기존 Flutter 경로에 반영하고 화면별 코드 지도를 작성했다. +- Flutter 위젯 렌더 26개, 화면 모음 7개, Figma 비교판 7개를 `docs/design/redesign-20260923/flutter/`에 저장했다. +- `flutter analyze --no-pub`: No issues found. +- 최종 `flutter test --no-pub --concurrency=1 --dart-define=CAPTURE_RECURRING_SCREENSHOTS=true`: 603개 모두 통과. +- `git diff --check` 통과. 개인정보 본문 6개 단락이 기존과 동일함을 확인했다. +- 앱 글꼴/운영체제 시스템 영역/초기화 후 재시작 생명주기에 대한 적용 차이는 결과 README에 기록했다. diff --git a/pubspec.yaml b/pubspec.yaml index 2280c2b8..3730c517 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -110,6 +110,8 @@ flutter: # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true + assets: + - assets/design/ # To add assets to your application, add an assets section, like this: # assets: diff --git a/test/helpers/refresh_capture.dart b/test/helpers/refresh_capture.dart new file mode 100644 index 00000000..021e8315 --- /dev/null +++ b/test/helpers/refresh_capture.dart @@ -0,0 +1,31 @@ +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future loadRefreshFonts() async { + await (FontLoader( + 'Pretendard', + )..addFont(rootBundle.load('assets/fonts/Pretendard-Regular.ttf'))).load(); + await (FontLoader( + 'MaterialIcons', + )..addFont(rootBundle.load('fonts/MaterialIcons-Regular.otf'))).load(); +} + +Future captureRefresh(WidgetTester tester, String name) async { + if (!const bool.fromEnvironment('CAPTURE_RECURRING_SCREENSHOTS')) return; + await tester.runAsync(() async { + final view = tester.binding.renderViews.first; + final layer = view.debugLayer! as OffsetLayer; + final image = await layer.toImage(Offset.zero & view.size, pixelRatio: 1); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + final directory = Directory( + 'docs/design/redesign-20260923/flutter/screenshots', + )..createSync(recursive: true); + await File( + '${directory.path}/$name.png', + ).writeAsBytes(bytes!.buffer.asUint8List()); + image.dispose(); + }); +} diff --git a/test/presentation/my_page/my_page_screen_test.dart b/test/presentation/my_page/my_page_screen_test.dart index 9d9320b6..24157103 100644 --- a/test/presentation/my_page/my_page_screen_test.dart +++ b/test/presentation/my_page/my_page_screen_test.dart @@ -1,3 +1,5 @@ +import 'package:on_time_front/presentation/shared/components/bottom_nav_bar_scaffold.dart'; +import '../../helpers/refresh_capture.dart'; import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -22,6 +24,7 @@ import 'package:on_time_front/presentation/shared/theme/theme.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(loadRefreshFonts); late AppDatabase database; late _FakeAlarmRepository alarmRepository; @@ -69,11 +72,21 @@ void main() { await getIt.reset(); }); + testWidgets('Korean settings match the phone layout', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await _pumpMyPage(tester, locale: const Locale('ko')); + await captureRefresh(tester, 'mypage-entry'); + expect(tester.takeException(), isNull); + }); + testWidgets('shows only local data and device settings', (tester) async { await _pumpMyPage(tester); expect(find.text('My Page'), findsOneWidget); - expect(find.text('백업, 복원 및 로컬 데이터 초기화'), findsOneWidget); + expect(find.text('Back up my data'), findsOneWidget); expect(find.text('알림에 일정 이름 표시'), findsOneWidget); expect(find.text('Sign in'), findsNothing); expect(find.textContaining('email'), findsNothing); @@ -209,7 +222,8 @@ void main() { ) async { await _pumpMyPage(tester); - await tester.tap(find.text('백업, 복원 및 로컬 데이터 초기화')); + await tester.ensureVisible(find.text('Back up my data')); + await tester.tap(find.text('Back up my data')); await tester.pumpAndSettle(); expect(find.text('my data destination'), findsOneWidget); @@ -228,14 +242,18 @@ void main() { Future _pumpMyPage( WidgetTester tester, { NotificationService? notificationService, + Locale locale = const Locale('en'), }) async { final router = GoRouter( initialLocation: '/myPage', routes: [ GoRoute( path: '/myPage', - builder: (_, _) => - MyPageScreen(notificationService: notificationService), + builder: (_, _) => locale.languageCode == 'ko' + ? BottomNavBarScaffold( + child: MyPageScreen(notificationService: notificationService), + ) + : MyPageScreen(notificationService: notificationService), ), GoRoute( path: '/myData', @@ -256,7 +274,14 @@ Future _pumpMyPage( await tester.pumpWidget( MaterialApp.router( theme: themeData, - locale: const Locale('en'), + debugShowCheckedModeBanner: false, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(padding: const EdgeInsets.only(top: 44, bottom: 34)), + child: child!, + ), + locale: locale, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, routerConfig: router, diff --git a/test/presentation/recurring/recurring_form_integration_test.dart b/test/presentation/recurring/recurring_form_integration_test.dart index 8cf27693..8f7461f9 100644 --- a/test/presentation/recurring/recurring_form_integration_test.dart +++ b/test/presentation/recurring/recurring_form_integration_test.dart @@ -1,3 +1,4 @@ +import '../../helpers/refresh_capture.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:on_time_front/core/di/di_setup.dart'; @@ -41,6 +42,7 @@ import 'package:on_time_front/domain/use-cases/update_schedule_use_case.dart'; import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; void main() { + setUpAll(loadRefreshFonts); late AppDatabase db; late PreparationRepositoryImpl preparations; late ScheduleRepositoryImpl schedules; @@ -186,6 +188,7 @@ void main() { bool? saved; await tester.pumpWidget( MaterialApp( + debugShowCheckedModeBanner: false, theme: themeData, locale: const Locale('ko'), localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -231,19 +234,16 @@ void main() { isTrue, reason: 'Step $step: ${bloc.state}', ); - await tester.tap( - find.descendant( - of: find.byType(TopBar), - matching: find.byType(TextButton), - ), - ); + await tester.tap(find.text('다음')); await tester.pumpAndSettle(); + if (step == 0) await captureRefresh(tester, 'date-time'); + if (step == 2) await captureRefresh(tester, 'preparation'); } - expect(find.text('확인'), findsOneWidget); + expect(find.text('검토하기'), findsOneWidget); final reviewed = bloc.stream.firstWhere( (s) => s.submissionStatus == ScheduleFormSubmissionStatus.review, ); - await tester.tap(find.text('확인')); + await tester.tap(find.text('검토하기')); await tester.runAsync(() => reviewed); await tester.pumpAndSettle(); expect(find.text('반복 일정 확인'), findsOneWidget); @@ -255,7 +255,7 @@ void main() { final success = bloc.stream.firstWhere( (s) => s.submissionStatus == ScheduleFormSubmissionStatus.success, ); - await tester.tap(find.text('저장')); + await tester.tap(find.text('저장하기')); await tester.runAsync(() => success); await tester.pumpAndSettle(); expect(saved, isTrue); diff --git a/test/presentation/recurring/recurring_screens_test.dart b/test/presentation/recurring/recurring_screens_test.dart index 55ba117e..15540a6a 100644 --- a/test/presentation/recurring/recurring_screens_test.dart +++ b/test/presentation/recurring/recurring_screens_test.dart @@ -24,7 +24,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUpAll(() async { final loader = FontLoader('Pretendard'); - for (final weight in ['Regular', 'Medium', 'SemiBold']) { + for (final weight in ['Regular']) { loader.addFont(rootBundle.load('assets/fonts/Pretendard-$weight.ttf')); } await loader.load(); @@ -61,9 +61,13 @@ void main() { ); await tester.tap(find.widgetWithText(TextButton, '화')); await tester.enterText(find.byType(TextFormField).first, '2'); + await tester.tap(find.text('총 10회')); + await tester.pumpAndSettle(); await tester.enterText(find.byType(TextFormField).last, '7'); await tester.tap(find.text('적용')); await tester.pumpAndSettle(); + await tester.tap(find.text('적용')); + await tester.pumpAndSettle(); expect(result!.rule!.weekdays, {1, 2, 3, 5}); expect(result!.rule!.interval, 2); expect(result!.rule!.count, 7); @@ -85,7 +89,11 @@ void main() { (_) => fail('Invalid rule must not be applied'), ); for (final day in ['월', '수', '금']) { - await tester.tap(find.widgetWithText(TextButton, day)); + await tester.tap( + find.byKey( + ValueKey('recurrence-weekday-${{'월': 1, '수': 3, '금': 5}[day]}'), + ), + ); } await tester.tap(find.text('적용')); await tester.pumpAndSettle(); @@ -129,20 +137,20 @@ void main() { ); expect( tester - .widget(find.widgetWithText(TextButton, '저장')) + .widget(find.widgetWithText(TextButton, '저장하기 (3개)')) .onPressed, isNull, ); - await tester.ensureVisible(find.byType(CheckboxListTile)); - await tester.tap(find.byType(CheckboxListTile)); + await tester.ensureVisible(find.byType(CheckboxListTile).at(1)); + await tester.tap(find.byType(CheckboxListTile).at(1)); await tester.pumpAndSettle(); expect( tester - .widget(find.widgetWithText(TextButton, '저장')) + .widget(find.widgetWithText(TextButton, '저장하기 (2개)')) .onPressed, isNotNull, ); - await tester.tap(find.text('저장')); + await tester.tap(find.text('저장하기 (2개)')); await tester.pumpAndSettle(); expect(result, {slots[1].key}); expect(tester.takeException(), isNull); @@ -177,7 +185,11 @@ void main() { await tester.tap(find.text('출근 준비')); await tester.pumpAndSettle(); expect(find.text('가방 챙기기'), findsOneWidget); - await tester.ensureVisible(find.text('반복 종료')); + await tester.scrollUntilVisible( + find.text('반복 종료'), + 200, + scrollable: find.byType(Scrollable).last, + ); await tester.tap(find.text('반복 종료')); await tester.pumpAndSettle(); expect(useCase.deleted, isNull); @@ -288,9 +300,10 @@ Future _pump( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, builder: (context, child) => MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(textScaler: TextScaler.linear(textScale)), + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(textScale), + padding: const EdgeInsets.only(top: 44, bottom: 34), + ), child: child!, ), home: Scaffold(body: screen), @@ -331,7 +344,7 @@ Future _capture(WidgetTester tester, GlobalKey key, String name) async { .toImage(pixelRatio: 2); final bytes = await image.toByteData(format: ui.ImageByteFormat.png); final directory = Directory( - 'docs/design/recurring-schedules/implementation', + 'docs/design/redesign-20260923/flutter/screenshots', )..createSync(recursive: true); await File( '${directory.path}/$name.png', diff --git a/test/presentation/recurring/refreshed_screens_test.dart b/test/presentation/recurring/refreshed_screens_test.dart new file mode 100644 index 00000000..89a74dfc --- /dev/null +++ b/test/presentation/recurring/refreshed_screens_test.dart @@ -0,0 +1,353 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_engine.dart'; +import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; +import 'package:on_time_front/domain/use-cases/recurring_schedules_use_case.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_settings_sheet.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_review_sheet.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_scope_sheet.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_time_choice_sheet.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_occurrence_sheet.dart'; +import 'package:on_time_front/presentation/recurring/recurring_management_screen.dart'; +import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; +import 'package:on_time_front/presentation/startup/screens/local_data_recovery_screen.dart'; +import 'package:on_time_front/presentation/my_page/my_data_screen.dart'; +import 'package:on_time_front/presentation/my_page/privacy_policy_screen.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; +import '../../helpers/refresh_capture.dart'; + +void main() { + setUpAll(loadRefreshFonts); + final start = DateTime.utc(2030, 1, 2, 9); + final rule = RecurrenceRule( + frequency: RecurrenceFrequency.weekly, + start: start, + timeZoneId: 'UTC', + weekdays: {1, 3, 5}, + count: 10, + ); + final slots = const RecurrenceEngine() + .expand(rule, through: DateTime.utc(2031)) + .slots; + final form = ScheduleFormState( + scheduleName: '출근 준비', + scheduleTime: start, + recurrenceRule: rule, + preparation: _prep, + moveTime: const Duration(minutes: 20), + scheduleSpareTime: const Duration(minutes: 10), + ); + + testWidgets('monthly and end-date controls preserve a valid edited rule', ( + tester, + ) async { + await _pump( + tester, + RecurrenceSettingsSheet(start: start, timeZoneId: 'UTC', initial: rule), + ); + await tester.tap(find.widgetWithText(TextButton, '월').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('월 규칙')); + await tester.pumpAndSettle(); + await captureRefresh(tester, 'monthly'); + await tester.tap(find.text('특정 순번의 요일에 반복')); + await tester.pumpAndSettle(); + expect(find.byType(DropdownButtonFormField), findsNWidgets(2)); + await captureRefresh(tester, 'monthly-fields'); + await tester.tap(find.text('적용')); + await tester.pumpAndSettle(); + await tester.tap(find.text('총 10회')); + await tester.pumpAndSettle(); + await captureRefresh(tester, 'ending'); + await tester.tap(find.text('날짜 지정')); + await tester.pumpAndSettle(); + await captureRefresh(tester, 'end-date'); + expect(find.text('마지막 날짜'), findsOneWidget); + await tester.tap(find.text('적용')); + await tester.pumpAndSettle(); + expect(find.text('반복 예시'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('conflicts prohibit excluding every occurrence', (tester) async { + final review = RecurrenceReview( + slots: slots.take(3).toList(), + skipped: [], + conflicts: [ + RecurrenceConflict(slot: slots.first, other: _schedule('병원 예약', start)), + ], + ); + await _pump(tester, RecurrenceReviewSheet(review: review, form: form)); + await captureRefresh(tester, 'conflicts'); + for (var i = 0; i < 3; i++) { + await tester.tap(find.byType(CheckboxListTile).at(i)); + await tester.pump(); + } + expect( + tester + .widget(find.widgetWithText(TextButton, '저장하기 (0개)')) + .onPressed, + isNull, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('secondary recurrence states fit a small phone with large text', ( + tester, + ) async { + final screens = { + 'frequency': RecurrenceSettingsSheet(start: start, timeZoneId: 'UTC'), + 'persistent-conflict': RecurrenceReviewSheet( + review: RecurrenceReview( + slots: slots, + skipped: [], + persistentConflict: true, + conflicts: [ + RecurrenceConflict( + slot: slots.first, + other: _schedule('아침 운동', start), + ), + ], + ), + form: form, + ), + 'detached': RecurrenceReviewSheet( + review: RecurrenceReview( + slots: slots, + skipped: [], + detached: [_schedule('출근 준비', start)], + ), + form: form, + ), + 'time-exceptions': RecurrenceTimeChoiceSheet( + date: DateTime(2030, 11, 3, 1, 30), + timeZoneId: 'America/New_York', + ), + 'save-error': const RecurrenceSaveErrorSheet(message: '기기에 저장할 수 없어요'), + 'occurrence': RecurrenceOccurrenceSheet( + schedule: _schedule('출근 준비', DateTime(2025, 9, 23, 10)), + ), + 'empty': RecurringManagementScreen(useCase: _Management([])), + 'privacy': const PrivacyPolicyScreen(), + 'reset-complete': const LocalDataResetCompleteScreen(), + }; + for (final entry in screens.entries) { + await _pump(tester, entry.value); + await captureRefresh(tester, entry.key); + expect(tester.takeException(), isNull, reason: entry.key); + await _pump(tester, entry.value, width: 320, scale: 1.6); + expect(tester.takeException(), isNull, reason: '${entry.key} large text'); + } + }); + + testWidgets( + 'scope defaults to only this occurrence and supports following scope', + (tester) async { + RecurringEditScope? result; + await _pump( + tester, + Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await showRecurrenceScope(context); + }, + child: const Text('open'), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + await captureRefresh(tester, 'scope'); + await tester.tap(find.text('이번 및 이후 일정')); + await tester.pump(); + await tester.tap(find.text('선택한 범위로 수정')); + await tester.pumpAndSettle(); + expect(result, RecurringEditScope.following); + }, + ); + + testWidgets( + 'recovery confirms before deleting and prevents duplicate resets', + (tester) async { + final pending = Completer(); + var resets = 0; + var completed = false; + await _pump( + tester, + LocalDataRecoveryScreen( + onRetry: () {}, + reset: () { + resets++; + return pending.future; + }, + onResetComplete: () => completed = true, + ), + ); + await captureRefresh(tester, 'recovery'); + await tester.tap(find.text('모든 로컬 데이터 초기화')); + await tester.pumpAndSettle(); + expect(resets, 0); + await captureRefresh(tester, 'recovery-confirm'); + await tester.tap(find.text('모두 삭제')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(resets, 1); + expect(find.text('로컬 데이터 초기화 중'), findsOneWidget); + expect( + tester + .widget(find.widgetWithText(TextButton, '초기화 실행')) + .onPressed, + isNull, + ); + await captureRefresh(tester, 'recovery-busy'); + pending.complete(); + await tester.pump(); + expect(completed, isTrue); + }, + ); + + testWidgets('failed reset remains recoverable and never reports success', ( + tester, + ) async { + await _pump( + tester, + LocalDataRecoveryScreen( + onRetry: () {}, + reset: () async => throw StateError('reset failed'), + onResetComplete: () => fail('Must not report success'), + ), + ); + await tester.tap(find.text('모든 로컬 데이터 초기화')); + await tester.pumpAndSettle(); + await tester.tap(find.text('모두 삭제')); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('초기화하지 못했습니다. 다시 시도해 주세요.'), 200); + expect(find.text('초기화하지 못했습니다. 다시 시도해 주세요.'), findsOneWidget); + }); + + testWidgets('privacy keeps all six policy sections scrollable', ( + tester, + ) async { + await _pump(tester, const PrivacyPolicyScreen(), width: 320, scale: 1.6); + expect(PrivacyPolicyScreen.sections, hasLength(6)); + await tester.scrollUntilVisible(find.text('문의'), 300); + expect(find.text('문의'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'management detail and end confirmation keep independent preparation', + (tester) async { + final summary = RecurringScheduleSummary( + RecurringSegment( + id: 'segment', + seriesId: 'series', + rule: rule, + schedule: _schedule('출근 준비', start), + preparation: _prep, + preparationId: 'own', + fromSlot: start, + createdAt: DateTime.utc(2029), + ), + _schedule('출근 준비', start), + ); + await _pump( + tester, + RecurringManagementScreen(useCase: _Management([summary])), + ); + await tester.tap(find.text('출근 준비')); + await tester.pumpAndSettle(); + await captureRefresh(tester, 'detail'); + expect(find.text('씻기'), findsOneWidget); + await tester.scrollUntilVisible( + find.text('반복 종료'), + 200, + scrollable: find.byType(Scrollable).last, + ); + await tester.tap(find.text('반복 종료')); + await tester.pumpAndSettle(); + await captureRefresh(tester, 'end-confirm'); + expect(find.text('개별로 수정한 이후 회차도 삭제 대상에 포함됩니다.'), findsOneWidget); + await tester.tap(find.text('취소')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }, + ); +} + +Future _pump( + WidgetTester tester, + Widget child, { + double width = 390, + double scale = 1, +}) async { + tester.view.physicalSize = Size(width, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + key: UniqueKey(), + debugShowCheckedModeBanner: false, + theme: themeData, + locale: const Locale('ko'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + padding: const EdgeInsets.only(top: 44, bottom: 34), + textScaler: TextScaler.linear(scale), + ), + child: child!, + ), + home: Scaffold(body: child), + ), + ); + await tester.pumpAndSettle(); +} + +const _prep = PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'p', + preparationName: '씻기', + preparationTime: Duration(minutes: 15), + nextPreparationId: 'q', + ), + PreparationStepEntity( + id: 'q', + preparationName: '옷 입기', + preparationTime: Duration(minutes: 10), + ), + ], +); +ScheduleEntity _schedule(String name, DateTime time) => ScheduleEntity( + id: name, + place: const PlaceEntity(id: 'p', placeName: '회사'), + scheduleName: name, + scheduleTime: time, + timeZoneId: 'UTC', + occurrenceOffsetSeconds: 0, + moveTime: Duration.zero, + isChanged: true, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + recurringSegmentId: 'segment', +); + +class _Management implements RecurringSchedulesUseCase { + _Management(this.summaries); + final List summaries; + @override + Future> list() async => summaries; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart b/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart index 180141aa..17234f14 100644 --- a/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart +++ b/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart @@ -23,11 +23,11 @@ import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; import 'package:on_time_front/presentation/schedule_create/components/schedule_multi_page_form.dart'; -import 'package:on_time_front/presentation/schedule_create/components/top_bar.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_cubit.dart'; import 'package:on_time_front/presentation/schedule_create/screens/schedule_create_screen.dart'; import 'package:on_time_front/presentation/schedule_create/screens/schedule_edit_screen.dart'; -import 'package:on_time_front/presentation/shared/components/two_action_dialog.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_time_choice_sheet.dart'; const _unset = Object(); @@ -273,10 +273,12 @@ void main() { } Future goToFinalStepAndSubmit(WidgetTester tester) async { - Finder nextButton() => find.descendant( - of: find.byType(TopBar), - matching: find.byType(TextButton), - ); + Finder nextButton() => find + .descendant( + of: find.byType(ScreenActions), + matching: find.byType(TextButton), + ) + .last; await tester.tap(nextButton()); await tester.pumpAndSettle(); @@ -428,7 +430,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(ScheduleMultiPageForm), findsOneWidget); - expect(find.byType(TwoActionDialog), findsOneWidget); + expect(find.byType(RecurrenceSaveErrorSheet), findsOneWidget); }); testWidgets('edited name and place are submitted from form steps', ( @@ -445,10 +447,12 @@ void main() { await primeEditState(bloc); await pumpSheet(tester, bloc); - Finder nextButton() => find.descendant( - of: find.byType(TopBar), - matching: find.byType(TextButton), - ); + Finder nextButton() => find + .descendant( + of: find.byType(ScreenActions), + matching: find.byType(TextButton), + ) + .last; await tester.enterText(find.byType(TextFormField).first, 'Edited Meeting'); await tester.pump(); @@ -503,10 +507,12 @@ void main() { await primeEditState(bloc); await pumpSheet(tester, bloc); - Finder nextButton() => find.descendant( - of: find.byType(TopBar), - matching: find.byType(TextButton), - ); + Finder nextButton() => find + .descendant( + of: find.byType(ScreenActions), + matching: find.byType(TextButton), + ) + .last; await tester.tap(nextButton()); await tester.pumpAndSettle(); @@ -524,10 +530,12 @@ void main() { await primeCreateState(bloc); await pumpSheet(tester, bloc); - Finder nextButton() => find.descendant( - of: find.byType(TopBar), - matching: find.byType(TextButton), - ); + Finder nextButton() => find + .descendant( + of: find.byType(ScreenActions), + matching: find.byType(TextButton), + ) + .last; await tester.enterText(find.byType(TextFormField).first, 'Meeting'); await tester.pump();