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();