diff --git a/.github/workflows/android-play-closed.yml b/.github/workflows/android-play-closed.yml index 43f80592..aac87764 100644 --- a/.github/workflows/android-play-closed.yml +++ b/.github/workflows/android-play-closed.yml @@ -16,7 +16,11 @@ permissions: contents: read jobs: + quality: + uses: ./.github/workflows/flutter_test.yml + deploy_closed_testing: + needs: quality name: Build and upload closed testing release runs-on: ubuntu-latest environment: staging @@ -106,9 +110,6 @@ jobs: - name: Analyze run: flutter analyze - - name: Test - run: flutter test - - name: Derive Android build name run: | app_version="$(sed -n 's/^version: *//p' pubspec.yaml | head -n 1)" diff --git a/.github/workflows/android-play-internal.yml b/.github/workflows/android-play-internal.yml index bdd75b90..3231f785 100644 --- a/.github/workflows/android-play-internal.yml +++ b/.github/workflows/android-play-internal.yml @@ -16,7 +16,11 @@ permissions: contents: read jobs: + quality: + uses: ./.github/workflows/flutter_test.yml + deploy_internal: + needs: quality name: Build and upload internal testing draft runs-on: ubuntu-latest environment: staging @@ -102,9 +106,6 @@ jobs: - name: Analyze run: flutter analyze - - name: Test - run: flutter test - - name: Derive Android build name run: | app_version="$(sed -n 's/^version: *//p' pubspec.yaml | head -n 1)" diff --git a/.github/workflows/flutter_test.yml b/.github/workflows/flutter_test.yml index c1ccf182..5e7642d4 100644 --- a/.github/workflows/flutter_test.yml +++ b/.github/workflows/flutter_test.yml @@ -1,5 +1,6 @@ name: Flutter Testing on: + workflow_call: workflow_dispatch: pull_request: branches: @@ -13,7 +14,9 @@ on: - "hotfix/**" jobs: test: - runs-on: ubuntu-latest + # Real-font goldens are reviewed on macOS. Keep the rendering platform + # aligned with those baselines; cross-OS rasterization is not identical. + runs-on: macos-26 steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 @@ -22,7 +25,9 @@ jobs: channel: stable cache: true - name: install native test dependencies - run: sudo apt-get install sqlite3 libsqlite3-dev libsodium-dev + env: + HOMEBREW_NO_AUTO_UPDATE: "1" + run: brew install sqlite libsodium - name: Install packages run: flutter pub get - name: Check Flutter version @@ -35,6 +40,8 @@ jobs: run: dart run tool/check_local_only_boundary.dart - name: Analyze run: flutter analyze + - name: Check Figma UI inventory and reference images + run: dart run tool/report_figma_ui_coverage.dart --strict - name: Run test with coverage run: flutter test --coverage - name: Check coverage @@ -46,3 +53,10 @@ jobs: name: flutter-coverage-lcov path: coverage/lcov.info if-no-files-found: warn + - name: Upload golden failure images + if: failure() + uses: actions/upload-artifact@v4 + with: + name: flutter-golden-failures + path: test/**/failures/*.png + if-no-files-found: ignore diff --git a/assets/calendar_back.svg b/assets/calendar_back.svg new file mode 100644 index 00000000..f4a73928 --- /dev/null +++ b/assets/calendar_back.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/calendar_chevron_down.svg b/assets/calendar_chevron_down.svg new file mode 100644 index 00000000..627c372d --- /dev/null +++ b/assets/calendar_chevron_down.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/calendar_edit.svg b/assets/calendar_edit.svg new file mode 100644 index 00000000..53487c9c --- /dev/null +++ b/assets/calendar_edit.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/calendar_map_pin.svg b/assets/calendar_map_pin.svg new file mode 100644 index 00000000..c9d49ab6 --- /dev/null +++ b/assets/calendar_map_pin.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/calendar_month_arrow.svg b/assets/calendar_month_arrow.svg new file mode 100644 index 00000000..4ac18280 --- /dev/null +++ b/assets/calendar_month_arrow.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/calendar_trash.svg b/assets/calendar_trash.svg new file mode 100644 index 00000000..e1a9ae36 --- /dev/null +++ b/assets/calendar_trash.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/design/destructive_warning.svg b/assets/design/destructive_warning.svg new file mode 100644 index 00000000..b775ded9 --- /dev/null +++ b/assets/design/destructive_warning.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/design/information_filled.svg b/assets/design/information_filled.svg new file mode 100644 index 00000000..b4c8805e --- /dev/null +++ b/assets/design/information_filled.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/design/information_outline.svg b/assets/design/information_outline.svg new file mode 100644 index 00000000..10a3ba2d --- /dev/null +++ b/assets/design/information_outline.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/design/notification_bell.svg b/assets/design/notification_bell.svg new file mode 100644 index 00000000..e89ad86d --- /dev/null +++ b/assets/design/notification_bell.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/design/onboarding_drag.svg b/assets/design/onboarding_drag.svg new file mode 100644 index 00000000..f5cd9ef5 --- /dev/null +++ b/assets/design/onboarding_drag.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/design/onboarding_greeting.png b/assets/design/onboarding_greeting.png new file mode 100644 index 00000000..e4342e1f Binary files /dev/null and b/assets/design/onboarding_greeting.png differ diff --git a/assets/design/onboarding_progress_complete.svg b/assets/design/onboarding_progress_complete.svg new file mode 100644 index 00000000..6fe0ea38 --- /dev/null +++ b/assets/design/onboarding_progress_complete.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/design/onboarding_progress_current.svg b/assets/design/onboarding_progress_current.svg new file mode 100644 index 00000000..10f46094 --- /dev/null +++ b/assets/design/onboarding_progress_current.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/design/onboarding_progress_last_current.svg b/assets/design/onboarding_progress_last_current.svg new file mode 100644 index 00000000..2fb4feab --- /dev/null +++ b/assets/design/onboarding_progress_last_current.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/design/onboarding_progress_last_pending.svg b/assets/design/onboarding_progress_last_pending.svg new file mode 100644 index 00000000..01255607 --- /dev/null +++ b/assets/design/onboarding_progress_last_pending.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/design/onboarding_progress_pending.svg b/assets/design/onboarding_progress_pending.svg new file mode 100644 index 00000000..e2879d3f --- /dev/null +++ b/assets/design/onboarding_progress_pending.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/design/recovery_spinner.svg b/assets/design/recovery_spinner.svg new file mode 100644 index 00000000..a6994995 --- /dev/null +++ b/assets/design/recovery_spinner.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/feedback_spinner.svg b/assets/feedback_spinner.svg new file mode 100644 index 00000000..a555f4a6 --- /dev/null +++ b/assets/feedback_spinner.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/home_mascot.png b/assets/home_mascot.png new file mode 100644 index 00000000..6ba0490e Binary files /dev/null and b/assets/home_mascot.png differ diff --git a/assets/home_nav_add.svg b/assets/home_nav_add.svg new file mode 100644 index 00000000..c01e92b3 --- /dev/null +++ b/assets/home_nav_add.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/home_nav_home.svg b/assets/home_nav_home.svg new file mode 100644 index 00000000..7ed25060 --- /dev/null +++ b/assets/home_nav_home.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/home_nav_my.svg b/assets/home_nav_my.svg new file mode 100644 index 00000000..a273e406 --- /dev/null +++ b/assets/home_nav_my.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/my_page_bell.svg b/assets/my_page_bell.svg new file mode 100644 index 00000000..30994ec9 --- /dev/null +++ b/assets/my_page_bell.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/my_page_chevron.svg b/assets/my_page_chevron.svg new file mode 100644 index 00000000..b79a577d --- /dev/null +++ b/assets/my_page_chevron.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/my_page_clock.svg b/assets/my_page_clock.svg new file mode 100644 index 00000000..c67fce90 --- /dev/null +++ b/assets/my_page_clock.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/my_page_download.svg b/assets/my_page_download.svg new file mode 100644 index 00000000..c018fc82 --- /dev/null +++ b/assets/my_page_download.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/my_page_person.svg b/assets/my_page_person.svg new file mode 100644 index 00000000..b5d406ab --- /dev/null +++ b/assets/my_page_person.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/my_page_phone.svg b/assets/my_page_phone.svg new file mode 100644 index 00000000..2eba581f --- /dev/null +++ b/assets/my_page_phone.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/my_page_repeat.svg b/assets/my_page_repeat.svg new file mode 100644 index 00000000..7d19cad2 --- /dev/null +++ b/assets/my_page_repeat.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/my_page_shield.svg b/assets/my_page_shield.svg new file mode 100644 index 00000000..6f511af3 --- /dev/null +++ b/assets/my_page_shield.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/my_page_stopwatch.svg b/assets/my_page_stopwatch.svg new file mode 100644 index 00000000..36d0285d --- /dev/null +++ b/assets/my_page_stopwatch.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/my_page_toggle_on.svg b/assets/my_page_toggle_on.svg new file mode 100644 index 00000000..f5d0699f --- /dev/null +++ b/assets/my_page_toggle_on.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/my_page_trash.svg b/assets/my_page_trash.svg new file mode 100644 index 00000000..b1528567 --- /dev/null +++ b/assets/my_page_trash.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/my_page_upload.svg b/assets/my_page_upload.svg new file mode 100644 index 00000000..08b4fbb0 --- /dev/null +++ b/assets/my_page_upload.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/preparation_edit_drag.svg b/assets/preparation_edit_drag.svg new file mode 100644 index 00000000..fe122130 --- /dev/null +++ b/assets/preparation_edit_drag.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/recurrence_date_time_calendar.svg b/assets/recurrence_date_time_calendar.svg new file mode 100644 index 00000000..e63d3d63 --- /dev/null +++ b/assets/recurrence_date_time_calendar.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/recurrence_date_time_chevron.svg b/assets/recurrence_date_time_chevron.svg new file mode 100644 index 00000000..769a8360 --- /dev/null +++ b/assets/recurrence_date_time_chevron.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/recurrence_date_time_clock.svg b/assets/recurrence_date_time_clock.svg new file mode 100644 index 00000000..ebb50515 --- /dev/null +++ b/assets/recurrence_date_time_clock.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/recurrence_date_time_repeat.svg b/assets/recurrence_date_time_repeat.svg new file mode 100644 index 00000000..a6058682 --- /dev/null +++ b/assets/recurrence_date_time_repeat.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/recurrence_detached_timeicon.svg b/assets/recurrence_detached_timeicon.svg new file mode 100644 index 00000000..252ec95f --- /dev/null +++ b/assets/recurrence_detached_timeicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/recurrence_detached_vector.svg b/assets/recurrence_detached_vector.svg new file mode 100644 index 00000000..a8838ed8 --- /dev/null +++ b/assets/recurrence_detached_vector.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/recurrence_detail_frame.svg b/assets/recurrence_detail_frame.svg new file mode 100644 index 00000000..2007e861 --- /dev/null +++ b/assets/recurrence_detail_frame.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/recurrence_detail_frame1.svg b/assets/recurrence_detail_frame1.svg new file mode 100644 index 00000000..adb738ed --- /dev/null +++ b/assets/recurrence_detail_frame1.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/recurrence_detail_frame2.svg b/assets/recurrence_detail_frame2.svg new file mode 100644 index 00000000..9b9d6cce --- /dev/null +++ b/assets/recurrence_detail_frame2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/recurrence_detail_frame3.svg b/assets/recurrence_detail_frame3.svg new file mode 100644 index 00000000..6963a726 --- /dev/null +++ b/assets/recurrence_detail_frame3.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/recurrence_detail_frame4.svg b/assets/recurrence_detail_frame4.svg new file mode 100644 index 00000000..a503edd7 --- /dev/null +++ b/assets/recurrence_detail_frame4.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/recurrence_end_confirm_vector.svg b/assets/recurrence_end_confirm_vector.svg new file mode 100644 index 00000000..5178b4e8 --- /dev/null +++ b/assets/recurrence_end_confirm_vector.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/recurrence_ending_ellipse.svg b/assets/recurrence_ending_ellipse.svg new file mode 100644 index 00000000..bdc834ae --- /dev/null +++ b/assets/recurrence_ending_ellipse.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/recurrence_occurrence_frame.svg b/assets/recurrence_occurrence_frame.svg new file mode 100644 index 00000000..2007e861 --- /dev/null +++ b/assets/recurrence_occurrence_frame.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/recurrence_preparation_chevron.svg b/assets/recurrence_preparation_chevron.svg new file mode 100644 index 00000000..b79a577d --- /dev/null +++ b/assets/recurrence_preparation_chevron.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/recurrence_preparation_drag.svg b/assets/recurrence_preparation_drag.svg new file mode 100644 index 00000000..d0028e00 --- /dev/null +++ b/assets/recurrence_preparation_drag.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/recurrence_preparation_underline.svg b/assets/recurrence_preparation_underline.svg new file mode 100644 index 00000000..8a7b8913 --- /dev/null +++ b/assets/recurrence_preparation_underline.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/recurrence_review_clock.svg b/assets/recurrence_review_clock.svg new file mode 100644 index 00000000..a74c781d --- /dev/null +++ b/assets/recurrence_review_clock.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/recurrence_review_ellipse.svg b/assets/recurrence_review_ellipse.svg new file mode 100644 index 00000000..e3c420f9 --- /dev/null +++ b/assets/recurrence_review_ellipse.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/recurrence_review_play.svg b/assets/recurrence_review_play.svg new file mode 100644 index 00000000..edd7a09e --- /dev/null +++ b/assets/recurrence_review_play.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/recurrence_save_error_vector1.svg b/assets/recurrence_save_error_vector1.svg new file mode 100644 index 00000000..67646c3b --- /dev/null +++ b/assets/recurrence_save_error_vector1.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/recurrence_scope_stateselected.svg b/assets/recurrence_scope_stateselected.svg new file mode 100644 index 00000000..4a00d64f --- /dev/null +++ b/assets/recurrence_scope_stateselected.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/recurrence_scope_stateunselected.svg b/assets/recurrence_scope_stateunselected.svg new file mode 100644 index 00000000..4f306f5d --- /dev/null +++ b/assets/recurrence_scope_stateunselected.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/recurrence_scope_tonemuted.svg b/assets/recurrence_scope_tonemuted.svg new file mode 100644 index 00000000..0d4a1de8 --- /dev/null +++ b/assets/recurrence_scope_tonemuted.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/recurrence_scope_vector.svg b/assets/recurrence_scope_vector.svg new file mode 100644 index 00000000..ea97202b --- /dev/null +++ b/assets/recurrence_scope_vector.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/recurrence_time_exceptions_info.svg b/assets/recurrence_time_exceptions_info.svg new file mode 100644 index 00000000..40202690 --- /dev/null +++ b/assets/recurrence_time_exceptions_info.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/recurring_calendar_outline.svg b/assets/recurring_calendar_outline.svg new file mode 100644 index 00000000..97c38570 --- /dev/null +++ b/assets/recurring_calendar_outline.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/recurring_card_calendar.svg b/assets/recurring_card_calendar.svg new file mode 100644 index 00000000..ca8d9dc2 --- /dev/null +++ b/assets/recurring_card_calendar.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/recurring_card_chevron.svg b/assets/recurring_card_chevron.svg new file mode 100644 index 00000000..2007e861 --- /dev/null +++ b/assets/recurring_card_chevron.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/recurring_card_clock.svg b/assets/recurring_card_clock.svg new file mode 100644 index 00000000..08201ce5 --- /dev/null +++ b/assets/recurring_card_clock.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/runtime_step_check.svg b/assets/runtime_step_check.svg new file mode 100644 index 00000000..3882dd50 --- /dev/null +++ b/assets/runtime_step_check.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/design/figma_code_map.yaml b/docs/design/figma_code_map.yaml new file mode 100644 index 00000000..f84dd1fc --- /dev/null +++ b/docs/design/figma_code_map.yaml @@ -0,0 +1,768 @@ +# Reviewed means source and Flutter captures compared, with documented exceptions. +# Native verification and unmapped designs are reported separately. +--- +schema_version: 1 +figma_file_key: FIdR6bUMHScn9FWbwqrBsA +figma_page: '1:25' +figma_board: '285:5' +reference: + platform: ios + locale: ko + viewport: 390x844 + text_scale: 1.0 + flutter_commit: c7184b2a +screens: +- route: "/recovery" + figma_node: 1957:11762 + flutter: lib/presentation/startup/screens/local_data_recovery_screen.dart + state: default + status: reviewed + review_note: Compared with fresh source; lifecycle, accessible tap targets, bundled + font, native chrome, and source-copy exceptions are recorded in docs/design/review/recovery-parity-20260925.md. + figma_image: docs/design/review/figma/recovery-v3.png + figma_sha256: '0458b76579f000c3ed4b40059836b04ed35e38797caddca79477b47a75c73ea4' + flutter_golden: test/goldens/goldens/recovery_default_390x844.png + variants: + - state: reset_confirmation + figma_node: 2003:12059 + status: reviewed + review_note: Compared with fresh source; lifecycle, accessible tap targets, bundled + font, native chrome, and source-copy exceptions are recorded in docs/design/review/recovery-parity-20260925.md. + figma_image: docs/design/review/figma/recovery-confirm-v3.png + figma_sha256: ce978101bf343b568497e4c381a9e461abfbc767a50cda7268af28a7231db784 + flutter_golden: test/goldens/goldens/recovery_reset_confirmation_390x844.png + - state: busy + figma_node: 2005:12036 + status: reviewed + review_note: Compared with fresh source; lifecycle, accessible tap targets, bundled + font, native chrome, and source-copy exceptions are recorded in docs/design/review/recovery-parity-20260925.md. + figma_image: docs/design/review/figma/recovery-busy.png + figma_sha256: 93b54f8e6116d4d793d98106eb180ba920109a8f09046804a0fe2bdc2e7083d3 + flutter_golden: test/goldens/goldens/recovery_busy_390x844.png +- route: "/startup" + figma_node: + flutter: lib/presentation/startup/screens/startup_screen.dart + status: excluded + reason: No corresponding current full-screen reference after the Screen Shells and + Permission Onboarding/Alarm Timer page audit. Native permission dialogs are platform-owned. + App route behavior remains tested. +- route: "/allowNotification" + figma_node: 246:230 + flutter: lib/presentation/notification_allow/screens/notification_allow_screen.dart + status: reviewed + figma_image: docs/design/review/figma/onboarding-10-20260925.png + figma_sha256: ceba9ea9bb5429e33ac8bd38332f90c93ca65878c60ae3d707e8a12a3e2e1b5e + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/onboarding-parity-20260925.md. + flutter_golden: test/goldens/goldens/notification_permission_390x844.png + state: request + variants: + - figma_node: 246:238 + status: excluded + figma_image: docs/design/review/figma/onboarding-11-20260925.png + figma_sha256: 12bd9bd4f0c706eb1ac9de5202595e20aa746ace1ee4bc84bf98743fa1565d2d + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/onboarding-parity-20260925.md. + state: system_prompt + reason: OS-owned notification permission alert. Actual iOS request and denial + were exercised; app rationale is reviewed separately. +- route: "/allowAlarm" + figma_node: + flutter: lib/presentation/alarm_allow/screens/alarm_allow_screen.dart + status: excluded + reason: No corresponding current full-screen reference after the Screen Shells and + Permission Onboarding/Alarm Timer page audit. Native permission dialogs are platform-owned. + App route behavior remains tested. +- route: "/onboarding" + figma_node: '246:53' + flutter: lib/presentation/onboarding/screens/onboarding_screen.dart + status: reviewed + figma_image: docs/design/review/figma/onboarding-1-20260925.png + figma_sha256: a7cbab4c426baf609e2ce0b919a415cece70d0387199a1260e82ce1c8352c6d4 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/onboarding-parity-20260925.md. + flutter_golden: test/goldens/goldens/onboarding_select_390x844.png + state: select + variants: + - figma_node: 246:90 + status: reviewed + figma_image: docs/design/review/figma/onboarding-2-20260925.png + figma_sha256: 51f0d329717347fd0df91b6b6756fa40112c2809da65511fb1fe688d4522d1c1 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/onboarding-parity-20260925.md. + flutter_golden: test/goldens/goldens/onboarding_add_390x844.png + state: add + - figma_node: 246:113 + status: reviewed + figma_image: docs/design/review/figma/onboarding-3-20260925.png + figma_sha256: d89d8e494229a0e62ed43054028b7f1a46ac23017cc646dac83088456e18cdca + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/onboarding-parity-20260925.md. + flutter_golden: test/goldens/goldens/onboarding_selected_390x844.png + state: selected + - figma_node: 246:150 + status: reviewed + figma_image: docs/design/review/figma/onboarding-4-20260925.png + figma_sha256: 79514dd77bc8bcf081cf8dfd3e813baf92e256c00715fdba94c85d2a26055f81 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/onboarding-parity-20260925.md. + flutter_golden: test/goldens/goldens/onboarding_order_390x844.png + state: order + - figma_node: 246:180 + status: reviewed + figma_image: docs/design/review/figma/onboarding-5-20260925.png + figma_sha256: 681613261674d17d72260f67bc17f63574bebe11ac3bf9b8747d190b84fbc489 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/onboarding-parity-20260925.md. + flutter_golden: test/goldens/goldens/onboarding_time_390x844.png + state: time + - figma_node: 246:212 + status: reviewed + figma_image: docs/design/review/figma/onboarding-6-20260925.png + figma_sha256: 0b148e6e8b357f3b41e298e508148176b183a7956b3acfc7b7135a242d81ed87 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/onboarding-parity-20260925.md. + flutter_golden: test/goldens/goldens/onboarding_buffer_390x844.png + state: buffer + - figma_node: 278:100 + status: reviewed + figma_image: docs/design/review/figma/onboarding-7-20260925.png + figma_sha256: 389a73aa5b3fc1a3efec3077fd94695009a2f5ce5d00ec14469614a377bed3a8 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/onboarding-parity-20260925.md. + flutter_golden: test/goldens/goldens/onboarding_time_focused_390x844.png + state: time_focused + - figma_node: 278:187 + status: reviewed + figma_image: docs/design/review/figma/onboarding-8-20260925.png + figma_sha256: 9a9ec8a31e93b79045623e66c7a01622bde52dcd0ddc89b15b2dd9ce3df7390b + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/onboarding-parity-20260925.md. + flutter_golden: test/goldens/goldens/onboarding_time_short_390x844.png + state: time_short + - figma_node: 278:274 + status: reviewed + figma_image: docs/design/review/figma/onboarding-9-20260925.png + figma_sha256: bdceb130364cf9a585bc47426462460e408f24319290c131dfbce0b5637d9222 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/onboarding-parity-20260925.md. + flutter_golden: test/goldens/goldens/onboarding_time_long_390x844.png + state: time_long +- route: "/onboarding/start" + figma_node: '246:42' + flutter: lib/presentation/onboarding/screens/onboarding_start_screen.dart + status: reviewed + figma_image: docs/design/review/figma/onboarding-0-20260925.png + figma_sha256: b4e365b2ebe04c028c77f6b47af4d71cd1eaf3601e67ef427652e6614f167a2d + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/onboarding-parity-20260925.md. + flutter_golden: test/goldens/goldens/onboarding_welcome_390x844.png + state: welcome +- route: "/home" + figma_node: 600:34301 + flutter: lib/presentation/home/screens/home_screen_tmp.dart + state: default + status: reviewed + review_note: Compared with fresh source; keeps mathematically correct calendar dates + and actual model values. Exact geometry, interactions, font/native exceptions + and source defects are recorded in docs/design/review/calendar-parity-20260925.md. + figma_image: docs/design/review/figma/home-default.png + figma_sha256: 87d9c1e3ed1a5a556f2fbb8081abaacdbeb41fbbf19f4d1cf998e015fac70d40 + flutter_golden: test/goldens/goldens/home_default_390x844.png + variants: + - state: loading + figma_node: '1824:2' + status: reviewed + review_note: Compared with fresh source; keeps mathematically correct calendar + dates and actual model values. Exact geometry, interactions, font/native exceptions + and source defects are recorded in docs/design/review/calendar-parity-20260925.md. + figma_image: docs/design/review/figma/home-loading.png + figma_sha256: 52362c7a344d8e4cf788b4ce9ac5bb89631e813a3a7f7e08ce9eb80cf041c6a7 + flutter_golden: test/goldens/goldens/home_loading_390x844.png + - state: error + figma_node: 1824:694 + status: reviewed + review_note: Compared with fresh source; keeps mathematically correct calendar + dates and actual model values. Exact geometry, interactions, font/native exceptions + and source defects are recorded in docs/design/review/calendar-parity-20260925.md. + figma_image: docs/design/review/figma/home-error.png + figma_sha256: 7aa8f80ce1ff05847209828f49045bc8e1d0a09803fd1625534860ff4d746281 + flutter_golden: test/goldens/goldens/home_error_390x844.png +- route: "/myPage" + figma_node: 2113:21761 + flutter: lib/presentation/my_page/my_page_screen.dart + state: default + status: reviewed + review_note: Current 390x844 recurring-entry source was compared with the Flutter + golden; section coordinates, Figma SVGs, data routes, selection switches, large + text, and accessibility checks pass. Native status bar and home indicator are + excluded. + figma_image: docs/design/review/figma/my-page-recurring-entry.png + figma_sha256: a591d7b496760b05155125578eacfdbcd753d024e3d73c5a1ebb4cc7dad42e4a + flutter_golden: test/goldens/goldens/my_page_default_390x844.png +- route: "/recurringSchedules" + figma_node: 2112:21676 + flutter: lib/presentation/recurring/recurring_management_screen.dart + state: management + status: reviewed + review_note: Matching two-active and one-ended 390x844 fixture was compared to Figma; + section positions, counts, 99px active cards, 71px ended card, and detail/end + behavior pass. Native chrome is excluded. + figma_image: docs/design/review/figma/recurring-management.png + figma_sha256: c0b41a0cc37879d73207caf9d369df7d05449d1997974179b12f5dadb56b1879 + flutter_golden: test/goldens/goldens/recurring_management_390x844.png + variants: + - state: detail + figma_node: 2112:21715 + status: reviewed + figma_image: docs/design/review/figma/recurring-detail-20260925.png + figma_sha256: 2d2dce2fd2043e9d091cab354aa346604a8d52db71eb131e075814a15d6de87e + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/recurring-parity-20260925.md. + flutter_golden: test/goldens/goldens/recurring_detail_390x844.png + - state: empty + figma_node: 2112:21756 + status: reviewed + review_note: 390x844 info, empty, and action cards match Figma coordinates; large-text + and narrow-width layout tests pass. Native chrome is excluded. + figma_image: docs/design/review/figma/recurring-empty.png + figma_sha256: 168133393b3fbc76a76bf1ea2e36467ea4d92d098f94b1bfd8426cab1f64dfbc + flutter_golden: test/goldens/goldens/recurring_empty_390x844.png + - state: occurrence + figma_node: 2112:21768 + status: reviewed + figma_image: docs/design/review/figma/recurring-occurrence-20260925.png + figma_sha256: 31f7f534b0b8ccf5cde3c58b5027b85d40175bdce29d6939b0cfbd4a3c9c6d26 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/recurring-parity-20260925.md. + flutter_golden: test/goldens/goldens/recurring_occurrence_390x844.png +- route: "/myData" + figma_node: 1948:12072 + flutter: lib/presentation/my_page/my_data_screen.dart + state: default + status: reviewed + review_note: App-owned content, 358x68 rows, and 18px row spacing checked against + node metadata. Native status bar and home indicator are excluded from Flutter + golden. + figma_image: docs/design/review/figma/my-data-default.png + figma_sha256: 5f2a48998edfd1b3b47d4bff0022f941626ea94fbe3cae36799260a23aa41ed7 + flutter_golden: test/goldens/goldens/my_data_default_390x844.png + variants: + - state: reset_confirmation + figma_node: 2003:12059 + status: reviewed + review_note: Compared with fresh source; lifecycle, accessible tap targets, bundled + font, native chrome, and source-copy exceptions are recorded in docs/design/review/recovery-parity-20260925.md. + figma_image: docs/design/review/figma/recovery-confirm-v3.png + figma_sha256: ce978101bf343b568497e4c381a9e461abfbc767a50cda7268af28a7231db784 + flutter_golden: test/goldens/goldens/my_data_reset_confirmation_390x844.png + - state: restore_preview_confirmation + figma_node: 1978:11791 + status: reviewed + review_note: Compared with fresh source; lifecycle, accessible tap targets, bundled + font, native chrome, and source-copy exceptions are recorded in docs/design/review/recovery-parity-20260925.md. + figma_image: docs/design/review/figma/my-data-restore-preview.png + figma_sha256: ffb62bd9764f8897e14d95f6f12334fd0be6f8ec0a30f31c615fd37ad68a5689 + flutter_golden: test/goldens/goldens/my_data_restore_preview_390x844.png + - state: backup_password_create + figma_node: 1979:11816 + status: reviewed + review_note: Compared with fresh source; lifecycle, accessible tap targets, bundled + font, native chrome, and source-copy exceptions are recorded in docs/design/review/recovery-parity-20260925.md. + figma_image: docs/design/review/figma/my-data-password-create.png + figma_sha256: 7e04e15b88d878162767d910c9d45f3703a2fbe6bf27d56522204cef66abb04b + flutter_golden: test/goldens/goldens/my_data_password_create_390x844.png + - state: backup_password_input + figma_node: 1980:11841 + status: reviewed + review_note: Compared with fresh source; lifecycle, accessible tap targets, bundled + font, native chrome, and source-copy exceptions are recorded in docs/design/review/recovery-parity-20260925.md. + figma_image: docs/design/review/figma/my-data-password-input.png + figma_sha256: 94dd76f87a6a7fe77bd9672bdb645f52aa91700e48b2eeab101fbf2f4507f0c1 + flutter_golden: test/goldens/goldens/my_data_password_input_390x844.png + - state: backup_password_invalid_length + figma_node: 1981:11866 + status: reviewed + review_note: Compared with fresh source; lifecycle, accessible tap targets, bundled + font, native chrome, and source-copy exceptions are recorded in docs/design/review/recovery-parity-20260925.md. + figma_image: docs/design/review/figma/my-data-password-invalid.png + figma_sha256: de016130f45b290c63baddad21479e1ed2b65de2701be604bd8432c55d67ec9f + flutter_golden: test/goldens/goldens/my_data_password_invalid_390x844.png + - state: backup_password_mismatch + figma_node: 1982:11891 + status: reviewed + review_note: Compared with fresh source; lifecycle, accessible tap targets, bundled + font, native chrome, and source-copy exceptions are recorded in docs/design/review/recovery-parity-20260925.md. + figma_image: docs/design/review/figma/my-data-password-mismatch.png + figma_sha256: 894f3c951dfcb852e4f42b96c40cd19586434ac3eecda4aad3f8b5211f7f4621 + flutter_golden: test/goldens/goldens/my_data_password_mismatch_390x844.png + - state: freshness_checking + figma_node: 1985:11908 + status: reviewed + review_note: Pending asynchronous freshness lookup, 68px status card, and action + spacing compared. Native chrome is excluded. + figma_image: docs/design/review/figma/my-data-freshness-checking.png + figma_sha256: cd98356ebc2cfed2ec369acf4acbb1ae81d16bbd761ee1fba5228e0aad9689ea + flutter_golden: test/goldens/goldens/my_data_freshness_checking_390x844.png + - state: freshness_no_changes + figma_node: 1987:11925 + status: reviewed + review_note: No-changes text and status card compared. Native chrome is excluded. + figma_image: docs/design/review/figma/my-data-freshness-no-changes.png + figma_sha256: 42972adfb56946596e963d3bfa5311f6cdf5eace2fb6f914db203c4e8c1bea3d + flutter_golden: test/goldens/goldens/my_data_freshness_no_changes_390x844.png + - state: freshness_unexported_changes + figma_node: 1989:11942 + status: reviewed + review_note: Unexported-changes text and status card compared. Native chrome is + excluded. + figma_image: docs/design/review/figma/my-data-freshness-unexported.png + figma_sha256: bd2307f8dcf8fa3aa3b846d8bac6a4eb586f1fb5464fffb05c930d0a1631c91a + flutter_golden: test/goldens/goldens/my_data_freshness_unexported_390x844.png + - state: freshness_reminder_due + figma_node: 1991:11959 + status: reviewed + review_note: Two-line 30-day reminder grows the card without hiding actions; color + and 18px row spacing compared. Native chrome is excluded. + figma_image: docs/design/review/figma/my-data-freshness-reminder.png + figma_sha256: e36b489f777f60d72099d5a888b8f07108314eb344796c7b5ab2d335208f076c + flutter_golden: test/goldens/goldens/my_data_freshness_reminder_390x844.png + - state: busy + figma_node: 1996:11977 + status: reviewed + review_note: Disabled row opacity, exported 40px spinner at y466, and no concurrent + restore action compared. Native chrome is excluded. + figma_image: docs/design/review/figma/my-data-busy.png + figma_sha256: d70f19ee78f45d98b1e228997ac4e2baf68e1efd177821402bbd80fa2c544b33 + flutter_golden: test/goldens/goldens/my_data_busy_390x844.png + - state: export_success_snackbar + figma_node: 1999:11994 + status: reviewed + review_note: 358x44 result bar at y752 after successful export compared. Native + chrome is excluded. + figma_image: docs/design/review/figma/my-data-export-success.png + figma_sha256: edab25e7b2f4c5541cd01e1a2242e0aba01c4d1a341d481e86fddac5057a5aac + flutter_golden: test/goldens/goldens/my_data_export_success_390x844.png + - state: restore_success_snackbar + figma_node: 2000:12010 + status: reviewed + review_note: Result-bar visual compared through the shared UI helper; end-to-end + native restore remains pending. + figma_image: docs/design/review/figma/my-data-restore-success.png + figma_sha256: 86c4162179792464efd9da6cf615b2aa9f45100a342f83a59aa28a904b437d29 + flutter_golden: test/goldens/goldens/my_data_restore_success_390x844.png + - state: operation_failure_snackbar + figma_node: 2001:12027 + status: reviewed + review_note: Result-bar visual compared; failed export clears the busy state and + displays its error. Native chrome is excluded. + figma_image: docs/design/review/figma/my-data-operation-failure.png + figma_sha256: '0390e8213191266798c61e773409b6b0f3861027fbc1f94e28b1be6e0cc32e00' + flutter_golden: test/goldens/goldens/my_data_operation_failure_390x844.png +- route: "/privacyPolicy" + figma_node: 1950:11759 + flutter: lib/presentation/my_page/privacy_policy_screen.dart + state: default + status: reviewed + review_note: Compared with fresh source; lifecycle, accessible tap targets, bundled + font, native chrome, and source-copy exceptions are recorded in docs/design/review/recovery-parity-20260925.md. + figma_image: docs/design/review/figma/privacy-v3.png + figma_sha256: 88f84355c58e153300ccd4cce29c235c6fa3d05309fde7ba1eed99c04a6e94a5 + flutter_golden: test/goldens/goldens/privacy_policy_390x844.png +- route: "/resetComplete" + figma_node: 1963:11762 + flutter: lib/presentation/my_page/my_data_screen.dart + state: default + status: reviewed + review_note: Compared with fresh source; lifecycle, accessible tap targets, bundled + font, native chrome, and source-copy exceptions are recorded in docs/design/review/recovery-parity-20260925.md. + figma_image: docs/design/review/figma/reset-complete-v3.png + figma_sha256: f98c8ab46909ad90ea9a602c8b768a7bd78fd819bb18f1bdc4f70a086230fd01 + flutter_golden: test/goldens/goldens/reset_complete_390x844.png +- route: "/defaultPreparationSpareTimeEdit" + figma_node: 600:35201 + flutter: lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart + state: six_step_fixture + status: reviewed + review_note: Korean 390x844 header, stepper, summary, 358x62 list rows, duration + inputs, and add action compared with Figma. Six linked steps are a reference fixture, + not a product maximum; a seventh can be added. Pretendard substitutes for Figma + Noto Sans KR and IBM Plex Sans KR; native chrome is excluded. + figma_image: docs/design/review/figma/preparation-spare-time-edit-default.png + figma_sha256: bcd3b05ab6da3335d054e26910d8fe60183a2ef65ae2b0908c89e1fd84672569 + flutter_golden: test/goldens/goldens/preparation_spare_time_edit_390x844.png +- route: "/calendar" + figma_node: 868:146599 + state: empty + status: reviewed + review_note: Compared with fresh source; keeps mathematically correct calendar dates + and actual model values. Exact geometry, interactions, font/native exceptions + and source defects are recorded in docs/design/review/calendar-parity-20260925.md. + figma_image: docs/design/review/figma/calendar-empty.png + figma_sha256: 0a1bd7f2eb3afcc6c6809c8d9f7d05a904fcfcde6f13570188a97cc9bf242e8b + flutter_golden: test/goldens/goldens/calendar_empty_390x852.png + variants: + - state: expanded + figma_node: 868:146600 + status: reviewed + review_note: Compared with fresh source; keeps mathematically correct calendar + dates and actual model values. Exact geometry, interactions, font/native exceptions + and source defects are recorded in docs/design/review/calendar-parity-20260925.md. + figma_image: docs/design/review/figma/calendar-expanded.png + figma_sha256: 1583f6db767d15b1a7aea64dac8f51dba795d9c8d64339baeb42d6f747bae69d + flutter_golden: test/goldens/goldens/calendar_expanded_390x852.png + - state: list + figma_node: 882:426235 + status: reviewed + review_note: Compared with fresh source; keeps mathematically correct calendar + dates and actual model values. Exact geometry, interactions, font/native exceptions + and source defects are recorded in docs/design/review/calendar-parity-20260925.md. + figma_image: docs/design/review/figma/calendar-list.png + figma_sha256: 422d779410182dec6a1c5050d3b9c76c4a4de0116c16c946d0bfd400a091e3da + flutter_golden: test/goldens/goldens/calendar_list_390x852.png + - state: swipe_actions + figma_node: 882:464741 + status: reviewed + review_note: Compared with fresh source; keeps mathematically correct calendar + dates and actual model values. Exact geometry, interactions, font/native exceptions + and source defects are recorded in docs/design/review/calendar-parity-20260925.md. + figma_image: docs/design/review/figma/calendar-swipe-actions.png + figma_sha256: f9687add5e58cad41b146a3a448fe67e402a471d3b8d83277440be05e6983fee + flutter_golden: test/goldens/goldens/calendar_swipe_actions_390x852.png + - state: expanded_actions + figma_node: 882:493347 + status: reviewed + review_note: Compared with fresh source; keeps mathematically correct calendar + dates and actual model values. Exact geometry, interactions, font/native exceptions + and source defects are recorded in docs/design/review/calendar-parity-20260925.md. + figma_image: docs/design/review/figma/calendar-expanded-actions.png + figma_sha256: b475c56020d23f555bb07c44bf7dc3e201ffa170ef9473eccdf27ba370ec6aec + flutter_golden: test/goldens/goldens/calendar_expanded_actions_390x852.png + - state: delete_confirm + figma_node: 882:512625 + status: reviewed + review_note: Compared with fresh source; keeps mathematically correct calendar + dates and actual model values. Exact geometry, interactions, font/native exceptions + and source defects are recorded in docs/design/review/calendar-parity-20260925.md. + figma_image: docs/design/review/figma/calendar-delete-confirm.png + figma_sha256: a00d9ca5a8ad5a693f59da83cdfbcf72e8b34095fc9c101cc9aba9c4f77091c0 + flutter_golden: test/goldens/goldens/calendar_delete_confirm_390x852.png + - state: loading + figma_node: 1816:6728 + status: reviewed + review_note: Compared with fresh source; keeps mathematically correct calendar + dates and actual model values. Exact geometry, interactions, font/native exceptions + and source defects are recorded in docs/design/review/calendar-parity-20260925.md. + figma_image: docs/design/review/figma/calendar-loading.png + figma_sha256: d96b113898d510c8e11a391d6dc2f05a8671680a6986ef989464f297556a2be7 + flutter_golden: test/goldens/goldens/calendar_loading_390x852.png + - state: error + figma_node: 1816:6984 + status: reviewed + review_note: Compared with fresh source; keeps mathematically correct calendar + dates and actual model values. Exact geometry, interactions, font/native exceptions + and source defects are recorded in docs/design/review/calendar-parity-20260925.md. + figma_image: docs/design/review/figma/calendar-error.png + figma_sha256: a7d69eddcc876d99622541c561c0c81b55f24a3344af7bd72a505b34bd12f540 + flutter_golden: test/goldens/goldens/calendar_error_390x852.png +- route: "/scheduleCreate" + figma_node: 2112:2394 + state: date_time + status: reviewed + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/recurring-parity-20260925.md. + variants: + - state: weekly + figma_node: 2112:21323 + status: reviewed + figma_image: docs/design/review/figma/recurring-weekly-20260925.png + figma_sha256: 343688b5181b27e682b857de98fe8a907e6900521acaceb97f2e9c6e5891c82f + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/recurring-parity-20260925.md. + flutter_golden: test/goldens/goldens/recurring_weekly_390x844.png + - state: monthly + figma_node: 2112:21352 + status: reviewed + figma_image: docs/design/review/figma/recurring-monthly-20260925.png + figma_sha256: b69b5931f22f8893e31338cfac15eb2c15b37be6c06b2864d787113e480e974b + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/recurring-parity-20260925.md. + flutter_golden: test/goldens/goldens/recurring_monthly_390x844.png + - state: ending + figma_node: 2112:21395 + status: reviewed + figma_image: docs/design/review/figma/recurring-ending-20260925.png + figma_sha256: 22c1608681bd101b922d4deb4a1c1a0469cd15465a98d4d76d552a68be9b3688 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/recurring-parity-20260925.md. + flutter_golden: test/goldens/goldens/recurring_ending_390x844.png + - state: preparation + figma_node: 2112:21456 + status: reviewed + figma_image: docs/design/review/figma/recurring-preparation-20260925.png + figma_sha256: ecb0df74ebe0e54067514f16be2d2a25a1dd1ccdec5bdc4b0073fd0107b59fe3 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/recurring-parity-20260925.md. + flutter_golden: test/goldens/goldens/recurring_preparation_390x844.png + - state: review + figma_node: 2112:21500 + status: reviewed + figma_image: docs/design/review/figma/recurring-review-20260925.png + figma_sha256: 29a84cd7e21f2b9369967d8f26b9fcf1c25f20329ce584242b3db911cbe004fe + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/recurring-parity-20260925.md. + flutter_golden: test/goldens/goldens/recurring_review_390x844.png + - state: conflicts + figma_node: 2112:21531 + status: reviewed + figma_image: docs/design/review/figma/recurring-conflicts-20260925.png + figma_sha256: c4d4be1b26f8f51628b55660a7eaa3f2cb9847d4b23b7f39658e9a52bb72f7fe + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/recurring-parity-20260925.md. + flutter_golden: test/goldens/goldens/recurring_conflicts_390x844.png + - state: persistent_conflict + figma_node: 2112:21557 + status: reviewed + figma_image: docs/design/review/figma/recurring-persistent-conflict-20260925.png + figma_sha256: eed0e8720e4242851e2f518eb43ed98f44d0a991e15287aecd0ae6dd60474a00 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/recurring-parity-20260925.md. + flutter_golden: test/goldens/goldens/recurring_persistent_conflict_390x844.png + - state: scope + figma_node: 2112:21588 + status: reviewed + figma_image: docs/design/review/figma/recurring-scope-20260925.png + figma_sha256: 6361ec89504c0d9deea00e69b22bdde1dd2ec6f7b10f816381e7ea8c55b245e5 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/recurring-parity-20260925.md. + flutter_golden: test/goldens/goldens/recurring_scope_390x844.png + - state: detached + figma_node: 2112:21613 + status: reviewed + figma_image: docs/design/review/figma/recurring-detached-20260925.png + figma_sha256: d74e3d7294dbc3553fea3d445c7b1082a38a65eb1aab22f1a3d3cea136c1e5f1 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/recurring-parity-20260925.md. + flutter_golden: test/goldens/goldens/recurring_detached_390x844.png + - state: time_exceptions + figma_node: 2112:21635 + status: reviewed + figma_image: docs/design/review/figma/recurring-time-exceptions-20260925.png + figma_sha256: 44c32c26792c5a4838587e221c1287eca2f3aaa1ecfd5f7b41462f198b13dabe + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/recurring-parity-20260925.md. + flutter_golden: test/goldens/goldens/recurring_time_exceptions_390x844.png + - state: save_error + figma_node: 2112:21659 + status: reviewed + figma_image: docs/design/review/figma/recurring-save-error-20260925.png + figma_sha256: 19ceb72970e68136567e80473a5439485cab149f89d8f7d81b4043eafd1bd067 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/recurring-parity-20260925.md. + flutter_golden: test/goldens/goldens/recurring_save_error_390x844.png + - state: frequency + figma_node: 2113:21627 + status: reviewed + figma_image: docs/design/review/figma/recurring-frequency-20260925.png + figma_sha256: b9e75a77c132f8b8e7995878160f1c329e655e40262617c9ef91b4096cac1357 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/recurring-parity-20260925.md. + flutter_golden: test/goldens/goldens/recurring_frequency_390x844.png + - state: monthly_fields + figma_node: 2113:21670 + status: reviewed + figma_image: docs/design/review/figma/recurring-monthly-fields-20260925.png + figma_sha256: 657b3d1316485837fa641d87754d571060845e240c353a3ee9cf37ae4a2d405b + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/recurring-parity-20260925.md. + flutter_golden: test/goldens/goldens/recurring_monthly_fields_390x844.png + - state: end_date + figma_node: 2113:21708 + status: reviewed + figma_image: docs/design/review/figma/recurring-end-date-20260925.png + figma_sha256: 5ec407e1334d1e0b012647fb172fbf30e249b606f8600c1c486c8ad857af9ae7 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/recurring-parity-20260925.md. + flutter_golden: test/goldens/goldens/recurring_end_date_390x844.png + - state: end_confirm + figma_node: 2113:21745 + status: reviewed + figma_image: docs/design/review/figma/recurring-end-confirm-20260925.png + figma_sha256: 17fe56d97a952bcec836cd2fb6c9451eebdf10d2961819c1f729366979cec970 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/recurring-parity-20260925.md. + flutter_golden: test/goldens/goldens/recurring_end_confirm_390x844.png + - figma_node: '251:42' + status: reviewed + figma_image: docs/design/review/figma/form-name.png + figma_sha256: 912f174f618dfd88e6bdec1ad0f1aca2ae403377e51fcbb9ddb234c8f6642965 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/original-form-parity-20260925.md. + flutter_golden: test/goldens/goldens/form_name_390x844.png + state: name + - figma_node: 251:75 + status: reviewed + figma_image: docs/design/review/figma/form-date.png + figma_sha256: 147cc7d396469cb4e11af53b1dca3c25c1d7da57cf75c2e5a315143eb6bdb616 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/original-form-parity-20260925.md. + flutter_golden: test/goldens/goldens/form_date_picker_390x844.png + state: date_picker + - figma_node: 251:119 + status: reviewed + figma_image: docs/design/review/figma/form-time.png + figma_sha256: 50aacc58ce78d7ed1c2e1ccb027260cf1cca8e7e17bd1e8e2f17feb638b64995 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/original-form-parity-20260925.md. + flutter_golden: test/goldens/goldens/form_time_picker_390x844.png + state: time_picker + - figma_node: 251:162 + status: reviewed + figma_image: docs/design/review/figma/form-place.png + figma_sha256: 2bb089c948342412305999ccc5303fecf43c751b3ccd0dcd7caa88acfd40996e + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/original-form-parity-20260925.md. + flutter_golden: test/goldens/goldens/form_place_390x844.png + state: place_travel + figma_image: docs/design/review/figma/recurring-date-time-20260925.png + figma_sha256: e81a92bc149ec372cf5440f1a15992a70af63509f97c01b6f009df2bb2c7bbdd + flutter_golden: test/goldens/goldens/recurring_date_time_390x844.png +- route: "/scheduleEdit/:scheduleId" + figma_node: '251:42' + status: reviewed + figma_image: docs/design/review/figma/form-name.png + figma_sha256: 912f174f618dfd88e6bdec1ad0f1aca2ae403377e51fcbb9ddb234c8f6642965 + review_note: Edit directly reuses the reviewed ScheduleMultiPageForm and KeyboardBackedBottomSheet; + this image covers that shared form body, not a separate edit-only Figma design. + Original values, following-edit scope and individual overrides are verified by + database integration tests. See original-form-parity-20260925.md and recurring-parity-20260925.md. + flutter_golden: test/goldens/goldens/form_name_390x844.png + state: shared_form + flutter: lib/presentation/schedule_create/screens/schedule_edit_screen.dart +- route: "/preparationEdit" + figma_node: 1334:2023 + status: reviewed + figma_image: docs/design/review/figma/form-preparation.png + figma_sha256: 0ea7a3def11759115f924c88cc93b9f4176c52a09a40876c4d2bd4ded1f30684 + review_note: Source and Flutter compared, with documented product, source-data, + fonts and native exceptions. See docs/design/review/original-form-parity-20260925.md. + flutter_golden: test/goldens/goldens/preparation_edit_390x844.png + state: default + flutter: lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/screens/preparation_edit_form.dart +- route: "/scheduleStart" + figma_node: + status: excluded + reason: App runtime route has no corresponding full-screen reference in the audited + current Screen Shells, Form Flows, Preparation, Alarm Timer and Permission Onboarding + pages. Behavior is covered by preparation flow widget tests; source absence is + not visual approval. +- route: "/alarmScreen" + figma_node: '118:2' + status: reviewed + flutter: lib/presentation/alarm/screens/alarm_screen.dart + state: on_time + figma_image: docs/design/review/figma/runtime-on-time.png + figma_sha256: c3505d61f25faefd8e715fdc37a42c41e079b65ad4ca25c4e7120d14844304ff + flutter_golden: test/goldens/goldens/runtime_on_time_390x844.png + review_note: Fresh source and live-state fixture compared. Keeps consistent model + values, real progress and finish-result navigation; accessibility, fonts, source-data + and native exceptions are documented in docs/design/review/runtime-parity-20260925.md. + variants: + - figma_node: '118:58' + state: late + status: reviewed + figma_image: docs/design/review/figma/runtime-late.png + figma_sha256: 62f98401c1dee4fff78c0e2ea38e8d6ed020ec3ca0df1723e6fdd075ab1d3b8d + flutter_golden: test/goldens/goldens/runtime_late_390x844.png + review_note: Fresh source and live-state fixture compared. Keeps consistent model + values, real progress and finish-result navigation; accessibility, fonts, source-data + and native exceptions are documented in docs/design/review/runtime-parity-20260925.md. + - figma_node: 118:114 + state: finish_confirm + status: reviewed + figma_image: docs/design/review/figma/runtime-finish-confirm.png + figma_sha256: d9eb91b1ea9a9e1e7a6cc124435f057ca4fd7cba5e07749d9f8733630680edcb + flutter_golden: test/goldens/goldens/runtime_finish_confirm_390x844.png + review_note: Fresh source and live-state fixture compared. Keeps consistent model + values, real progress and finish-result navigation; accessibility, fonts, source-data + and native exceptions are documented in docs/design/review/runtime-parity-20260925.md. +- route: "/earlyLate" + figma_node: + status: excluded + reason: App runtime route has no corresponding full-screen reference in the audited + current Screen Shells, Form Flows, Preparation, Alarm Timer and Permission Onboarding + pages. Behavior is covered by preparation flow widget tests; source absence is + not visual approval. +- route: "/moving" + figma_node: + status: excluded + reason: App runtime route has no corresponding full-screen reference in the audited + current Screen Shells, Form Flows, Preparation, Alarm Timer and Permission Onboarding + pages. Behavior is covered by preparation flow widget tests; source absence is + not visual approval. +hidden_historical_nodes: +- 466:6517 +- 600:36265 +- 600:36353 +source_pages: + '1:25': Screen Shells + '1:26': Form Flows + '1:21': Preparation + '1:23': Permission Onboarding +reviewed_at: '2026-09-25' +design_only_nodes: +- figma_node: 1756:8930 + state: ScheduleDetail/Upcoming + status: unmapped + reason: Standalone Schedule Detail design has no app route/implementation. Calendar + expandable cards are a different component. See docs/design/review/calendar-parity-20260925.md. +- figma_node: 1775:9192 + state: ScheduleDetail/PreparationDue + status: unmapped + reason: Standalone Schedule Detail design has no app route/implementation. Calendar + expandable cards are a different component. See docs/design/review/calendar-parity-20260925.md. +- figma_node: 1775:9197 + state: ScheduleDetail/Preparing + status: unmapped + reason: Standalone Schedule Detail design has no app route/implementation. Calendar + expandable cards are a different component. See docs/design/review/calendar-parity-20260925.md. +- figma_node: 1775:9202 + state: ScheduleDetail/CompletedOnTime + status: unmapped + reason: Standalone Schedule Detail design has no app route/implementation. Calendar + expandable cards are a different component. See docs/design/review/calendar-parity-20260925.md. +- figma_node: 1775:9207 + state: ScheduleDetail/CompletedLate + status: unmapped + reason: Standalone Schedule Detail design has no app route/implementation. Calendar + expandable cards are a different component. See docs/design/review/calendar-parity-20260925.md. +- figma_node: 1775:9212 + state: ScheduleDetail/CompletedUnknown + status: unmapped + reason: Standalone Schedule Detail design has no app route/implementation. Calendar + expandable cards are a different component. See docs/design/review/calendar-parity-20260925.md. +- figma_node: 1775:9217 + state: ScheduleDetail/PastUnrecorded + status: unmapped + reason: Standalone Schedule Detail design has no app route/implementation. Calendar + expandable cards are a different component. See docs/design/review/calendar-parity-20260925.md. +- figma_node: 1913:11757 + state: ScheduleDetail/Loading + status: unmapped + reason: Standalone Schedule Detail design has no app route/implementation. Calendar + expandable cards are a different component. See docs/design/review/calendar-parity-20260925.md. +- figma_node: 1913:12081 + state: ScheduleDetail/Refreshing + status: unmapped + reason: Standalone Schedule Detail design has no app route/implementation. Calendar + expandable cards are a different component. See docs/design/review/calendar-parity-20260925.md. +- figma_node: 1913:11857 + state: ScheduleDetail/NotFound + status: unmapped + reason: Standalone Schedule Detail design has no app route/implementation. Calendar + expandable cards are a different component. See docs/design/review/calendar-parity-20260925.md. +- figma_node: 1913:11969 + state: ScheduleDetail/Failure + status: unmapped + reason: Standalone Schedule Detail design has no app route/implementation. Calendar + expandable cards are a different component. See docs/design/review/calendar-parity-20260925.md. diff --git a/docs/design/redesign-20260923/flutter/README.md b/docs/design/redesign-20260923/flutter/README.md index 978c8e62..640a8415 100644 --- a/docs/design/redesign-20260923/flutter/README.md +++ b/docs/design/redesign-20260923/flutter/README.md @@ -15,10 +15,10 @@ ## 검증과 시각 자료 - `flutter analyze --no-pub`: 오류 없음. -- 전체 테스트 **603개 통과**. 최종 명령과 결과는 [validation.txt](validation.txt)에 기록했다. +- 이 디렉터리의 [validation.txt](validation.txt)는 2026-09-23 첫 반영 시점의 기록이다. 메인 화면 갱신을 병합한 뒤 최신 테스트와 비교 범위는 [QA handoff](../../../../handoff/ios-ui-quality-figma-parity.md)와 [화면 매핑](../../figma_code_map.yaml)을 확인한다. - 390×844 Flutter 위젯 렌더 26개를 아래에 저장했다. 실제 iOS/Android 기기 캡처가 아니다. 테스트의 날짜·준비 단계·설정값은 fixture이며 제품 기본값을 바꾸지 않는다. - 320px 너비와 글자 1.6배에서 반복·복구 관련 상태를 검사하고, 긴 하단 액션은 필요 시 세로 배치한다. -- `comparison-*.png`: 왼쪽 Figma 원본, 오른쪽 Flutter 렌더. `contact-*.png`: Flutter 렌더 모음. +- `comparison-*.png`와 `contact-*.png`는 첫 반영 시점의 비교/모음 이미지다. 갱신된 반복 관리·빈 상태 단일 캡처는 `screenshots/management.png`, `screenshots/empty.png`이며, 최신 상태별 참조/골든은 [화면 매핑](../../figma_code_map.yaml)을 기준으로 한다. ## 적용 시 유지한 차이 diff --git a/docs/design/redesign-20260923/flutter/screenshots/empty.png b/docs/design/redesign-20260923/flutter/screenshots/empty.png index 5c3af73e..c98c9e0c 100644 Binary files a/docs/design/redesign-20260923/flutter/screenshots/empty.png and b/docs/design/redesign-20260923/flutter/screenshots/empty.png differ diff --git a/docs/design/redesign-20260923/flutter/screenshots/management.png b/docs/design/redesign-20260923/flutter/screenshots/management.png index a3c57e90..5e4da279 100644 Binary files a/docs/design/redesign-20260923/flutter/screenshots/management.png and b/docs/design/redesign-20260923/flutter/screenshots/management.png differ diff --git a/docs/design/review/calendar-parity-20260925.md b/docs/design/review/calendar-parity-20260925.md new file mode 100644 index 00000000..6e025731 --- /dev/null +++ b/docs/design/review/calendar-parity-20260925.md @@ -0,0 +1,48 @@ +# Home · Calendar Figma comparison — 2026-09-25 + +Canonical file: `FIdR6bUMHScn9FWbwqrBsA`. Fresh `get_design_context` and screenshots were read for Home Default/Loading/Error and Calendar Expanded/List/SwipeActions/ExpandedActions/DeleteConfirm. Calendar's shared month/card components were inspected through these full-fidelity contexts. Existing Empty/Loading/Error source PNGs are retained. + +## Compared states + +| State | Figma node | Source image | Flutter golden | Source SHA-256 | +|---|---|---|---|---| +| Home default | `600:34301` | `figma/home-default.png` | `home_default_390x844.png` | `87d9c1e3ed1a5a556f2fbb8081abaacdbeb41fbbf19f4d1cf998e015fac70d40` | +| Home loading | `1824:2` | `figma/home-loading.png` | `home_loading_390x844.png` | `52362c7a344d8e4cf788b4ce9ac5bb89631e813a3a7f7e08ce9eb80cf041c6a7` | +| Home error | `1824:694` | `figma/home-error.png` | `home_error_390x844.png` | `7aa8f80ce1ff05847209828f49045bc8e1d0a09803fd1625534860ff4d746281` | +| Calendar empty | `868:146599` | `figma/calendar-empty.png` | `calendar_empty_390x852.png` | `0a1bd7f2eb3afcc6c6809c8d9f7d05a904fcfcde6f13570188a97cc9bf242e8b` | +| Calendar expanded | `868:146600` | `figma/calendar-expanded.png` | `calendar_expanded_390x852.png` | `1583f6db767d15b1a7aea64dac8f51dba795d9c8d64339baeb42d6f747bae69d` | +| Calendar list | `882:426235` | `figma/calendar-list.png` | `calendar_list_390x852.png` | `422d779410182dec6a1c5050d3b9c76c4a4de0116c16c946d0bfd400a091e3da` | +| Calendar swipe actions | `882:464741` | `figma/calendar-swipe-actions.png` | `calendar_swipe_actions_390x852.png` | `f9687add5e58cad41b146a3a448fe67e402a471d3b8d83277440be05e6983fee` | +| Calendar expanded actions | `882:493347` | `figma/calendar-expanded-actions.png` | `calendar_expanded_actions_390x852.png` | `b475c56020d23f555bb07c44bf7dc3e201ffa170ef9473eccdf27ba370ec6aec` | +| Calendar delete confirmation | `882:512625` | `figma/calendar-delete-confirm.png` | `calendar_delete_confirm_390x852.png` | `a00d9ca5a8ad5a693f59da83cdfbcf72e8b34095fc9c101cc9aba9c4f77091c0` | +| Calendar loading | `1816:6728` | `figma/calendar-loading.png` | `calendar_loading_390x852.png` | `d96b113898d510c8e11a391d6dc2f05a8671680a6986ef989464f297556a2be7` | +| Calendar error | `1816:6984` | `figma/calendar-error.png` | `calendar_error_390x852.png` | `a7d69eddcc876d99622541c561c0c81b55f24a3344af7bd72a505b34bd12f540` | + +Source paths are relative to this directory. Goldens are under `test/goldens/goldens/`. + +## Changes and visual assessment + +- Home preserves the 230px hero, 360×137 today card at (15,177), 360×391 month card, and 76px add action. Bottom navigation matches the 81px source height and icon centers (80,310). Home's today-card shadow is painted above the following month surface and golden capture includes normal Flutter shadows. +- Compact Calendar month cells use 51px rows instead of 44px plus a large bottom gutter. Selected date uses a 40px circle and numeric day (no Korean `일` suffix). The source month card is (18,110,354,390); the selected-date heading/card stack is aligned with source y532/y592. +- Schedule cards use 82px collapsed and 192px expanded height, a 70px time column/divider, 21px content inset, and 16px row gaps. Dynamic appointment times use 12-hour notation because a separate AM/PM label is visible. +- Swipe actions use 82px source buttons, 8px collapsed/10px expanded gaps, exact source `#545454` edit / `#bf2e22` delete colors, and rounded viewport clipping. Source SVGs are local `assets/calendar_chevron_down.svg` (24×25), `calendar_map_pin.svg` (18×19), `calendar_edit.svg` (24×24), `calendar_trash.svg` (24×24). Header/month arrows and Home navigation also use local exact source SVGs (`calendar_back.svg`/`calendar_month_arrow.svg` 24×24; `home_nav_home.svg`/`home_nav_my.svg` 24×24; `home_nav_add.svg` 50×50). Each is nonempty and rendered at its root geometry; the map pin has an explicit 18×19 slot. +- Delete confirmation retains the revealed schedule/actions behind a 42% scrim, uses the existing 277px dialog with 16px padding and 12px gaps, and centers across the full viewport. Cancelling does not delete data. The configurable shared-dialog additions preserve default behavior for other call sites. +- Card content grows at enlarged text sizes rather than enforcing the source's fixed heights. Existing edit restrictions for past/preparing/early-started/completed schedules and recurring occurrence controls remain intact. + +## Explicit source exceptions + +These states can be recorded as reviewed with the following exceptions; they are not an assertion of pixel identity: + +1. **Calendar truth:** Figma labels the month December 2024 but its grid is November 2024 (day 1 on Friday; day 21 on Thursday). Actual December 2024 begins Sunday and day 21 is Saturday. Both Home and Calendar deliberately render real dates; weekday positions and marker positions therefore differ. This is a documented source inconsistency, not an unresolved request to hard-code an incorrect calendar. +2. **Fixture data:** Figma appointment cards contain placeholders (`시간`, `-시간--분`, `--분`). The deterministic Flutter fixture contains real 6:00/7:00 PM appointments, 80-minute travel, loaded 0-minute preparation, and 10-minute buffer. Data text is not replaced by source placeholder strings. +3. **Typography/system UI:** The app bundles Pretendard, while these source nodes use Noto Sans KR/IBM Plex Sans KR. Glyph metrics and small text offsets differ. Native status bar and home indicator are platform-owned and excluded. Source loading/error exports also clip the selected-date heading; Flutter retains the complete heading. + +## Unmapped full Schedule Detail design audit + +The newly discovered `ScreenShell/ScheduleDetail` nodes are a separate full-screen design, not `lib/presentation/calendar/component/schedule_detail.dart`. Fresh context/screenshot for Upcoming (`1756:8930`) shows a standalone header, event facts, four-stage timing timeline, preparation plan, and start action. Repository route/code search finds no matching schedule-detail route or full-screen implementation; the similarly named Flutter component is only a Calendar expandable card. + +Unmapped full-screen states: Upcoming `1756:8930`; PreparationDue `1775:9192`; Preparing `1775:9197`; CompletedOnTime `1775:9202`; CompletedLate `1775:9207`; CompletedUnknown `1775:9212`; PastUnrecorded `1775:9217`; Loading `1913:11757`; Refreshing `1913:12081`; NotFound `1913:11857`; Failure `1913:11969`. These must remain explicitly unmapped, not count as passed Calendar variants. Adding that product flow requires separate route/state implementation. + +## Verification + +Final source/Flutter screenshots were visually inspected after rendering. Focused Home/Calendar/shared-dialog/calendar-theme run: 105 tests, 103 passed initially; a standalone 2x test fixture was corrected to use the real scrollable card context and the marker-color assertion was updated to the Figma token. Both affected files were rerun: all 13 tests passed. The root full suite remains the final combined gate. Existing coverage includes real date selection/month arrows, loading/error retry, create refresh, delete failure, back navigation, edit/delete lifecycle guards, recurring controls, and compact/enlarged-text layout. Five new source-state goldens cover list/expanded/swipe/expanded-actions/delete confirmation; the delete capture asserts no deletion occurs before confirmation and cancellation leaves data intact. diff --git a/docs/design/review/completion-20260925.md b/docs/design/review/completion-20260925.md new file mode 100644 index 00000000..dbebefde --- /dev/null +++ b/docs/design/review/completion-20260925.md @@ -0,0 +1,87 @@ +# iOS UI quality and Figma parity completion — 2026-09-25 + +The implemented app's Figma mapping and visual review are complete with documented source/runtime adaptations. Strict coverage has no pending or in-progress app entry. This is distinct from physical-device acceptance and from implementing every design present in Figma. Delivery remains [Draft PR #596](https://github.com/DevKor-github/OnTime-front/pull/596). + +The implementation follows refreshed `main` `c7184b2a`, merged into the isolated QA branch at `64bc111d`. The canonical file is [OnTime Design System](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA/OnTime-Design-System), not the earlier `GiDCK...` link. The audit includes Screen Shells, Form Flows, Preparation, and Permission Onboarding; it is not limited to board `285:5`. + +## Coverage and completion meaning + +[The source-to-code manifest](../figma_code_map.yaml) passes strict validation with the following totals: + +| App inventory | Total | Reviewed | Excluded | Pending / in progress | +| --- | ---: | ---: | ---: | ---: | +| Route defaults | 21 | 16 | 5 | 0 | +| Variants | 58 | 57 | 1 | 0 | +| Combined app entries | 79 | **73** | 6 | **0** | + +The five excluded routes (`/startup`, `/allowAlarm`, `/scheduleStart`, `/earlyLate`, `/moving`) have no corresponding current full-screen reference after the source audit; their behavior remains tested. The excluded variant `246:238` is the native permission alert, whose app-owned rationale is reviewed separately. Excluded means a stated source/platform boundary, not visual approval. + +Separately, `design_only_nodes` contains **11 unmapped standalone Schedule Detail designs**: Upcoming, PreparationDue, Preparing, CompletedOnTime, CompletedLate, CompletedUnknown, PastUnrecorded, Loading, Refreshing, NotFound, and Failure. Repository audit found no matching full-screen route/implementation. They are not the expandable Calendar card and are not included among the 73 reviewed app entries. Their exact nodes are recorded in [the Schedule Detail audit](calendar-parity-20260925.md#unmapped-full-schedule-detail-design-audit); implementing that new flow is outside this completed parity pass. + +Reviewed entries connect a verified canonical source, an original export/hash, real Flutter components/fixtures, and a reviewed golden or documented component reuse. Goldens are regression evidence. They do not prove literal pixel equality, native alarm delivery, or physical-device performance. + +## Applied result + +Source assets, panel geometry, actions, selection controls, dialogs and preparation progress were aligned across the current app. The implementation retains the actual four-step appointment flow, date arithmetic, data-derived times/totals, explicit conflict exclusion, series-owned preparation, scope-aware edits, live timer state, and confirmation before destructive changes. Source inconsistencies and platform boundaries are explicitly documented instead of copied into product behavior. + +Independent final review also caught a timer/dialog race: automatic completion could open over a manual finish prompt. A shared dialog guard and current-schedule recheck prevent overlapping prompts, with two real-timer regressions covering completion before and after the user chooses to continue. + +The main adaptations are consistent across the reports: bundled Pretendard instead of the source's mixed fonts; native status bars/keyboards/pickers/permissions instead of drawn substitutes; accessible 44px targets and scrolling at large text; correct calendar dates and actual duration sums instead of inconsistent sample artwork; and restart guidance after closed-storage reset. `/scheduleEdit/:scheduleId` shares the creation components and has database-backed edit/override coverage; no separate edit-only source is claimed. + +## Evidence index + +| Evidence | Scope and interpretation | +| --- | --- | +| [Home and Calendar](calendar-parity-20260925.md) | Default/loading/error, calendar list/expanded/swipe/actions/delete; correct-date exception; 11 unmapped standalone detail designs. | +| [Original forms and preparation edit](original-form-parity-20260925.md) | Name/place inputs, date/time Cupertino pickers, preparation edit, actual totals, save/cancel and enlarged text. | +| [Recurring flows](recurring-parity-20260925.md) | 18 source states, creation/review/conflicts/scope/DST/failure/detail/end, source SHA-to-golden table, actual four-step and edit reuse boundaries. | +| [Onboarding and notification permission](onboarding-parity-20260925.md) | Eleven app-owned states, source artwork, selection/order/numeric entry/validation, focus and OS permission boundary. | +| [Recovery, local data and privacy](recovery-parity-20260925.md) | Recovery/busy/reset, password validation and restore preview, privacy, secure field behavior and closed-storage lifecycle. | +| [Preparation runtime](runtime-parity-20260925.md) | Live on-time/late preparation, restored ring progress, manual finish confirmation, actual state values and narrow/enlarged layouts. | +| [iOS native observations](native-20260925/README.md) | Real simulator setup and permission denial/defer; Files export/import; durable restore/reset/relaunch; device availability and execution limits. | + +These reports contain the focused test results and source/Flutter image evidence. Their results support the listed behaviors; the final combined regression result is recorded only below. + +## Native data lifecycle demonstrated + +The isolated **OnTime Parity Backup QA 20260925** simulator (iPhone17, iOS26.5; UUID `74CFB493-7B8B-4DFD-9DD6-F0E97E682FA7`) completed real numeric three-minute preparation input and saved a local profile with a 30-minute buffer. Actual notification and AlarmKit prompts were denied/deferred through the app's normal controls. + +The native Files export saved an **811-byte encrypted backup**. After the buffer was changed to **35 minutes**, selecting the same file decrypted it and produced a restore preview. Confirming restore reloaded the **30-minute buffer** and three-minute preparation. Reset completed, and a full process relaunch returned to Welcome. The external backup survived reset with unchanged SHA-256 `339fe8a981377bdc9ef538e3c08cbb970e73862aff1f2768d2a660eb5814989e`. Screenshots and the exact scope are linked in [the native record](native-20260925/README.md); neither backup bytes nor the QA passphrase are committed. + +That run exposed two actual integration defects and verified their repairs: iOS could not use the previous save-location API/type filter, and tapping the native duration row did not reliably focus the intended field. Export now uses the encrypted-file document picker bridge and import supplies a UTI; the row focuses/selects the actual numeric field. Native export cancellation is covered by automated tests, not claimed as an executed cancellation scenario. Successful alarm delivery and a fresh native appointment CRUD pass are not claimed by this data-lifecycle run. + +## Final combined validation + + +All commands below completed successfully on the final source state, including the two timer/dialog race regressions. + +| Validation | Result | +| --- | --- | +| `dart run build_runner build --delete-conflicting-outputs` | Exit 0; ignored generated artifacts regenerated, no generated Dart outputs committed. | +| `flutter analyze --no-pub` | Exit 0; no issues. | +| `flutter test --no-pub --coverage --concurrency=1 --reporter expanded` | Exit 0; **693 passed, 0 failed, 0 skipped**, 4m 29s. This was a comparison run without `--update-goldens`. | +| `dart run tool/check_coverage.dart --min=80` | Exit 0; **87.45% app-owned line coverage (11,969 / 13,686)**, above the 80% threshold. | +| `dart run tool/check_generated_dart_policy.dart` | Exit 0; generated outputs ignored and untracked. | +| `dart run tool/check_local_only_boundary.dart` | Exit 0; local-only boundary verified. | +| `dart run tool/report_figma_ui_coverage.dart --strict` | Exit 0; 73 app entries reviewed, zero pending/in progress, six exclusions; 11 design-only states explicitly separate. | +| `git diff --check` | Exit 0. | +| `flutter build ios --simulator --debug --no-pub` | Exit 0; Xcode build 76.9s, `build/ios/iphonesimulator/Runner.app`. | +| Final simulator install and launch | Exit 0; installed on the isolated QA simulator, launched successfully, and native accessibility read-back showed Welcome, explanatory text and Start. | +| Fresh upstream read | `git fetch origin main` succeeded; `git rev-list --count HEAD..origin/main` returned 0 before the delivery commit. | + +The coverage percentage uses the repository's app-owned LCOV filter, excluding generated/localization/schema files; it is not a claim that every source line or device behavior was executed. Remote CI is a separate receipt on Draft PR #596 and must be read against its current head commit. + +### Reproducible golden environment + +The first remote run on Ubuntu, [36079080226](https://github.com/DevKor-github/OnTime-front/actions/runs/36079080226), passed 632 tests and failed 61 golden comparisons. Every reported failure was a pixel mismatch; there were no non-golden failures. The reviewed images were generated on macOS 26.5.1 with Flutter 3.44.4. Cross-platform font rendering is not guaranteed to match, as described in [Flutter's comparator documentation](https://api.flutter.dev/flutter/flutter_test/GoldenFileComparator-class.html). + +The full CI job now runs on the supported [macOS 26 arm64 runner](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), with Flutter 3.44.4 retained and Homebrew SQLite/libsodium dependencies. It still executes every test and the unchanged exact-pixel comparator, without skipping tests, increasing tolerances or updating goldens in CI. Failure PNGs are uploaded for review. Generate and compare these iOS-first goldens on this macOS/Flutter environment; an OS/SDK rendering change requires explicit image review. The current PR check is the authoritative remote result. + +The same quality workflow is reusable through `workflow_call`. Both Android internal and closed-testing deployment jobs require its successful completion before their existing Ubuntu build/upload jobs can run. Their duplicate cross-platform golden invocation is removed; the entire suite and coverage gate remain mandatory on the canonical renderer. Existing branch checks, staging environment, signing secrets and upload commands are unchanged. Neither Android deployment was dispatched as part of this QA task. + + +## Physical-device release gate + +`xcrun devicectl list devices` reported the paired iPhones/iPad unavailable. There is no physical-device success claim. Before device acceptance, verify font/layout on an attached iPhone, VoiceOver operation, background and locked-screen alarm delivery, and scrolling/frame-time performance. Simulator debugging and widget semantics checks cannot substitute for those observations. + +Draft PR #596 remains the review artifact. Source review, automated regression, the demonstrated native data flow, physical-device acceptance, PR review, merge and release are separate evidence boundaries. [The plan](../../../plans/ios-ui-quality-figma-parity.md) and [handoff](../../../handoff/ios-ui-quality-figma-parity.md) point to this report as the sole final combined-validation receipt. diff --git a/docs/design/review/figma/calendar-delete-confirm.png b/docs/design/review/figma/calendar-delete-confirm.png new file mode 100644 index 00000000..cbf85f84 Binary files /dev/null and b/docs/design/review/figma/calendar-delete-confirm.png differ diff --git a/docs/design/review/figma/calendar-empty.png b/docs/design/review/figma/calendar-empty.png new file mode 100644 index 00000000..e960a4d3 Binary files /dev/null and b/docs/design/review/figma/calendar-empty.png differ diff --git a/docs/design/review/figma/calendar-error.png b/docs/design/review/figma/calendar-error.png new file mode 100644 index 00000000..e3dc53e9 Binary files /dev/null and b/docs/design/review/figma/calendar-error.png differ diff --git a/docs/design/review/figma/calendar-expanded-actions.png b/docs/design/review/figma/calendar-expanded-actions.png new file mode 100644 index 00000000..e46e8358 Binary files /dev/null and b/docs/design/review/figma/calendar-expanded-actions.png differ diff --git a/docs/design/review/figma/calendar-expanded.png b/docs/design/review/figma/calendar-expanded.png new file mode 100644 index 00000000..0e5b2556 Binary files /dev/null and b/docs/design/review/figma/calendar-expanded.png differ diff --git a/docs/design/review/figma/calendar-list.png b/docs/design/review/figma/calendar-list.png new file mode 100644 index 00000000..eada73e8 Binary files /dev/null and b/docs/design/review/figma/calendar-list.png differ diff --git a/docs/design/review/figma/calendar-loading.png b/docs/design/review/figma/calendar-loading.png new file mode 100644 index 00000000..9ebbc8ef Binary files /dev/null and b/docs/design/review/figma/calendar-loading.png differ diff --git a/docs/design/review/figma/calendar-swipe-actions.png b/docs/design/review/figma/calendar-swipe-actions.png new file mode 100644 index 00000000..faa86f4b Binary files /dev/null and b/docs/design/review/figma/calendar-swipe-actions.png differ diff --git a/docs/design/review/figma/form-date.png b/docs/design/review/figma/form-date.png new file mode 100644 index 00000000..e8021c7f Binary files /dev/null and b/docs/design/review/figma/form-date.png differ diff --git a/docs/design/review/figma/form-name.png b/docs/design/review/figma/form-name.png new file mode 100644 index 00000000..8afc5dd9 Binary files /dev/null and b/docs/design/review/figma/form-name.png differ diff --git a/docs/design/review/figma/form-place.png b/docs/design/review/figma/form-place.png new file mode 100644 index 00000000..0da52bef Binary files /dev/null and b/docs/design/review/figma/form-place.png differ diff --git a/docs/design/review/figma/form-preparation.png b/docs/design/review/figma/form-preparation.png new file mode 100644 index 00000000..a50b62e1 Binary files /dev/null and b/docs/design/review/figma/form-preparation.png differ diff --git a/docs/design/review/figma/form-time.png b/docs/design/review/figma/form-time.png new file mode 100644 index 00000000..33cc0f6f Binary files /dev/null and b/docs/design/review/figma/form-time.png differ diff --git a/docs/design/review/figma/home-default.png b/docs/design/review/figma/home-default.png new file mode 100644 index 00000000..ad26e44b Binary files /dev/null and b/docs/design/review/figma/home-default.png differ diff --git a/docs/design/review/figma/home-error.png b/docs/design/review/figma/home-error.png new file mode 100644 index 00000000..57968aa5 Binary files /dev/null and b/docs/design/review/figma/home-error.png differ diff --git a/docs/design/review/figma/home-loading.png b/docs/design/review/figma/home-loading.png new file mode 100644 index 00000000..b09246f4 Binary files /dev/null and b/docs/design/review/figma/home-loading.png differ diff --git a/docs/design/review/figma/my-data-busy.png b/docs/design/review/figma/my-data-busy.png new file mode 100644 index 00000000..beb9696a Binary files /dev/null and b/docs/design/review/figma/my-data-busy.png differ diff --git a/docs/design/review/figma/my-data-default.png b/docs/design/review/figma/my-data-default.png new file mode 100644 index 00000000..6a6fe414 Binary files /dev/null and b/docs/design/review/figma/my-data-default.png differ diff --git a/docs/design/review/figma/my-data-export-success.png b/docs/design/review/figma/my-data-export-success.png new file mode 100644 index 00000000..9eaa18f5 Binary files /dev/null and b/docs/design/review/figma/my-data-export-success.png differ diff --git a/docs/design/review/figma/my-data-freshness-checking.png b/docs/design/review/figma/my-data-freshness-checking.png new file mode 100644 index 00000000..9dea446f Binary files /dev/null and b/docs/design/review/figma/my-data-freshness-checking.png differ diff --git a/docs/design/review/figma/my-data-freshness-no-changes.png b/docs/design/review/figma/my-data-freshness-no-changes.png new file mode 100644 index 00000000..8d9fc09f Binary files /dev/null and b/docs/design/review/figma/my-data-freshness-no-changes.png differ diff --git a/docs/design/review/figma/my-data-freshness-reminder.png b/docs/design/review/figma/my-data-freshness-reminder.png new file mode 100644 index 00000000..507f55b9 Binary files /dev/null and b/docs/design/review/figma/my-data-freshness-reminder.png differ diff --git a/docs/design/review/figma/my-data-freshness-unexported.png b/docs/design/review/figma/my-data-freshness-unexported.png new file mode 100644 index 00000000..2a712903 Binary files /dev/null and b/docs/design/review/figma/my-data-freshness-unexported.png differ diff --git a/docs/design/review/figma/my-data-operation-failure.png b/docs/design/review/figma/my-data-operation-failure.png new file mode 100644 index 00000000..1d0f74c6 Binary files /dev/null and b/docs/design/review/figma/my-data-operation-failure.png differ diff --git a/docs/design/review/figma/my-data-password-create.png b/docs/design/review/figma/my-data-password-create.png new file mode 100644 index 00000000..75b4feac Binary files /dev/null and b/docs/design/review/figma/my-data-password-create.png differ diff --git a/docs/design/review/figma/my-data-password-input.png b/docs/design/review/figma/my-data-password-input.png new file mode 100644 index 00000000..f0f1488c Binary files /dev/null and b/docs/design/review/figma/my-data-password-input.png differ diff --git a/docs/design/review/figma/my-data-password-invalid.png b/docs/design/review/figma/my-data-password-invalid.png new file mode 100644 index 00000000..594894cd Binary files /dev/null and b/docs/design/review/figma/my-data-password-invalid.png differ diff --git a/docs/design/review/figma/my-data-password-mismatch.png b/docs/design/review/figma/my-data-password-mismatch.png new file mode 100644 index 00000000..ca9814f0 Binary files /dev/null and b/docs/design/review/figma/my-data-password-mismatch.png differ diff --git a/docs/design/review/figma/my-data-reset-confirmation.png b/docs/design/review/figma/my-data-reset-confirmation.png new file mode 100644 index 00000000..0fbf9ce2 Binary files /dev/null and b/docs/design/review/figma/my-data-reset-confirmation.png differ diff --git a/docs/design/review/figma/my-data-restore-preview.png b/docs/design/review/figma/my-data-restore-preview.png new file mode 100644 index 00000000..f9ea0d8e Binary files /dev/null and b/docs/design/review/figma/my-data-restore-preview.png differ diff --git a/docs/design/review/figma/my-data-restore-success.png b/docs/design/review/figma/my-data-restore-success.png new file mode 100644 index 00000000..ef5cc5a3 Binary files /dev/null and b/docs/design/review/figma/my-data-restore-success.png differ diff --git a/docs/design/review/figma/my-page-default.png b/docs/design/review/figma/my-page-default.png new file mode 100644 index 00000000..ac2f6f06 Binary files /dev/null and b/docs/design/review/figma/my-page-default.png differ diff --git a/docs/design/review/figma/my-page-recurring-entry.png b/docs/design/review/figma/my-page-recurring-entry.png new file mode 100644 index 00000000..f3c2eff4 Binary files /dev/null and b/docs/design/review/figma/my-page-recurring-entry.png differ diff --git a/docs/design/review/figma/onboarding-0-20260925.png b/docs/design/review/figma/onboarding-0-20260925.png new file mode 100644 index 00000000..3089ea74 Binary files /dev/null and b/docs/design/review/figma/onboarding-0-20260925.png differ diff --git a/docs/design/review/figma/onboarding-1-20260925.png b/docs/design/review/figma/onboarding-1-20260925.png new file mode 100644 index 00000000..45e85e3d Binary files /dev/null and b/docs/design/review/figma/onboarding-1-20260925.png differ diff --git a/docs/design/review/figma/onboarding-10-20260925.png b/docs/design/review/figma/onboarding-10-20260925.png new file mode 100644 index 00000000..7ce755ce Binary files /dev/null and b/docs/design/review/figma/onboarding-10-20260925.png differ diff --git a/docs/design/review/figma/onboarding-11-20260925.png b/docs/design/review/figma/onboarding-11-20260925.png new file mode 100644 index 00000000..e0a90836 Binary files /dev/null and b/docs/design/review/figma/onboarding-11-20260925.png differ diff --git a/docs/design/review/figma/onboarding-2-20260925.png b/docs/design/review/figma/onboarding-2-20260925.png new file mode 100644 index 00000000..47a29b4d Binary files /dev/null and b/docs/design/review/figma/onboarding-2-20260925.png differ diff --git a/docs/design/review/figma/onboarding-3-20260925.png b/docs/design/review/figma/onboarding-3-20260925.png new file mode 100644 index 00000000..c82fd22f Binary files /dev/null and b/docs/design/review/figma/onboarding-3-20260925.png differ diff --git a/docs/design/review/figma/onboarding-4-20260925.png b/docs/design/review/figma/onboarding-4-20260925.png new file mode 100644 index 00000000..c20fd768 Binary files /dev/null and b/docs/design/review/figma/onboarding-4-20260925.png differ diff --git a/docs/design/review/figma/onboarding-5-20260925.png b/docs/design/review/figma/onboarding-5-20260925.png new file mode 100644 index 00000000..3a179607 Binary files /dev/null and b/docs/design/review/figma/onboarding-5-20260925.png differ diff --git a/docs/design/review/figma/onboarding-6-20260925.png b/docs/design/review/figma/onboarding-6-20260925.png new file mode 100644 index 00000000..a4180fc5 Binary files /dev/null and b/docs/design/review/figma/onboarding-6-20260925.png differ diff --git a/docs/design/review/figma/onboarding-7-20260925.png b/docs/design/review/figma/onboarding-7-20260925.png new file mode 100644 index 00000000..a27022ad Binary files /dev/null and b/docs/design/review/figma/onboarding-7-20260925.png differ diff --git a/docs/design/review/figma/onboarding-8-20260925.png b/docs/design/review/figma/onboarding-8-20260925.png new file mode 100644 index 00000000..1f0001d3 Binary files /dev/null and b/docs/design/review/figma/onboarding-8-20260925.png differ diff --git a/docs/design/review/figma/onboarding-9-20260925.png b/docs/design/review/figma/onboarding-9-20260925.png new file mode 100644 index 00000000..f3fb02a3 Binary files /dev/null and b/docs/design/review/figma/onboarding-9-20260925.png differ diff --git a/docs/design/review/figma/preparation-spare-time-edit-default.png b/docs/design/review/figma/preparation-spare-time-edit-default.png new file mode 100644 index 00000000..e7ab89d5 Binary files /dev/null and b/docs/design/review/figma/preparation-spare-time-edit-default.png differ diff --git a/docs/design/review/figma/privacy-policy-default.png b/docs/design/review/figma/privacy-policy-default.png new file mode 100644 index 00000000..5a8dfde0 Binary files /dev/null and b/docs/design/review/figma/privacy-policy-default.png differ diff --git a/docs/design/review/figma/privacy-v3.png b/docs/design/review/figma/privacy-v3.png new file mode 100644 index 00000000..5c0b97a3 Binary files /dev/null and b/docs/design/review/figma/privacy-v3.png differ diff --git a/docs/design/review/figma/recovery-busy.png b/docs/design/review/figma/recovery-busy.png new file mode 100644 index 00000000..286994fd Binary files /dev/null and b/docs/design/review/figma/recovery-busy.png differ diff --git a/docs/design/review/figma/recovery-confirm-v3.png b/docs/design/review/figma/recovery-confirm-v3.png new file mode 100644 index 00000000..0e7379ff Binary files /dev/null and b/docs/design/review/figma/recovery-confirm-v3.png differ diff --git a/docs/design/review/figma/recovery-default.png b/docs/design/review/figma/recovery-default.png new file mode 100644 index 00000000..0099a063 Binary files /dev/null and b/docs/design/review/figma/recovery-default.png differ diff --git a/docs/design/review/figma/recovery-reset-confirmation.png b/docs/design/review/figma/recovery-reset-confirmation.png new file mode 100644 index 00000000..d5fb5a26 Binary files /dev/null and b/docs/design/review/figma/recovery-reset-confirmation.png differ diff --git a/docs/design/review/figma/recovery-v3.png b/docs/design/review/figma/recovery-v3.png new file mode 100644 index 00000000..97cb878c Binary files /dev/null and b/docs/design/review/figma/recovery-v3.png differ diff --git a/docs/design/review/figma/recurring-conflicts-20260925.png b/docs/design/review/figma/recurring-conflicts-20260925.png new file mode 100644 index 00000000..6bb1cd14 Binary files /dev/null and b/docs/design/review/figma/recurring-conflicts-20260925.png differ diff --git a/docs/design/review/figma/recurring-date-time-20260925.png b/docs/design/review/figma/recurring-date-time-20260925.png new file mode 100644 index 00000000..49901955 Binary files /dev/null and b/docs/design/review/figma/recurring-date-time-20260925.png differ diff --git a/docs/design/review/figma/recurring-detached-20260925.png b/docs/design/review/figma/recurring-detached-20260925.png new file mode 100644 index 00000000..c0ee1f0d Binary files /dev/null and b/docs/design/review/figma/recurring-detached-20260925.png differ diff --git a/docs/design/review/figma/recurring-detail-20260925.png b/docs/design/review/figma/recurring-detail-20260925.png new file mode 100644 index 00000000..6f65a884 Binary files /dev/null and b/docs/design/review/figma/recurring-detail-20260925.png differ diff --git a/docs/design/review/figma/recurring-empty.png b/docs/design/review/figma/recurring-empty.png new file mode 100644 index 00000000..4d7db68a Binary files /dev/null and b/docs/design/review/figma/recurring-empty.png differ diff --git a/docs/design/review/figma/recurring-end-confirm-20260925.png b/docs/design/review/figma/recurring-end-confirm-20260925.png new file mode 100644 index 00000000..5c649db8 Binary files /dev/null and b/docs/design/review/figma/recurring-end-confirm-20260925.png differ diff --git a/docs/design/review/figma/recurring-end-date-20260925.png b/docs/design/review/figma/recurring-end-date-20260925.png new file mode 100644 index 00000000..5af94e98 Binary files /dev/null and b/docs/design/review/figma/recurring-end-date-20260925.png differ diff --git a/docs/design/review/figma/recurring-ending-20260925.png b/docs/design/review/figma/recurring-ending-20260925.png new file mode 100644 index 00000000..9638ccb3 Binary files /dev/null and b/docs/design/review/figma/recurring-ending-20260925.png differ diff --git a/docs/design/review/figma/recurring-frequency-20260925.png b/docs/design/review/figma/recurring-frequency-20260925.png new file mode 100644 index 00000000..75116d73 Binary files /dev/null and b/docs/design/review/figma/recurring-frequency-20260925.png differ diff --git a/docs/design/review/figma/recurring-management.png b/docs/design/review/figma/recurring-management.png new file mode 100644 index 00000000..8f0483db Binary files /dev/null and b/docs/design/review/figma/recurring-management.png differ diff --git a/docs/design/review/figma/recurring-monthly-20260925.png b/docs/design/review/figma/recurring-monthly-20260925.png new file mode 100644 index 00000000..cdabe60b Binary files /dev/null and b/docs/design/review/figma/recurring-monthly-20260925.png differ diff --git a/docs/design/review/figma/recurring-monthly-fields-20260925.png b/docs/design/review/figma/recurring-monthly-fields-20260925.png new file mode 100644 index 00000000..6776ed3b Binary files /dev/null and b/docs/design/review/figma/recurring-monthly-fields-20260925.png differ diff --git a/docs/design/review/figma/recurring-occurrence-20260925.png b/docs/design/review/figma/recurring-occurrence-20260925.png new file mode 100644 index 00000000..32a59845 Binary files /dev/null and b/docs/design/review/figma/recurring-occurrence-20260925.png differ diff --git a/docs/design/review/figma/recurring-persistent-conflict-20260925.png b/docs/design/review/figma/recurring-persistent-conflict-20260925.png new file mode 100644 index 00000000..ec54ad5e Binary files /dev/null and b/docs/design/review/figma/recurring-persistent-conflict-20260925.png differ diff --git a/docs/design/review/figma/recurring-preparation-20260925.png b/docs/design/review/figma/recurring-preparation-20260925.png new file mode 100644 index 00000000..605dd02a Binary files /dev/null and b/docs/design/review/figma/recurring-preparation-20260925.png differ diff --git a/docs/design/review/figma/recurring-review-20260925.png b/docs/design/review/figma/recurring-review-20260925.png new file mode 100644 index 00000000..96119899 Binary files /dev/null and b/docs/design/review/figma/recurring-review-20260925.png differ diff --git a/docs/design/review/figma/recurring-save-error-20260925.png b/docs/design/review/figma/recurring-save-error-20260925.png new file mode 100644 index 00000000..740688f8 Binary files /dev/null and b/docs/design/review/figma/recurring-save-error-20260925.png differ diff --git a/docs/design/review/figma/recurring-scope-20260925.png b/docs/design/review/figma/recurring-scope-20260925.png new file mode 100644 index 00000000..d8338429 Binary files /dev/null and b/docs/design/review/figma/recurring-scope-20260925.png differ diff --git a/docs/design/review/figma/recurring-time-exceptions-20260925.png b/docs/design/review/figma/recurring-time-exceptions-20260925.png new file mode 100644 index 00000000..8673737e Binary files /dev/null and b/docs/design/review/figma/recurring-time-exceptions-20260925.png differ diff --git a/docs/design/review/figma/recurring-weekly-20260925.png b/docs/design/review/figma/recurring-weekly-20260925.png new file mode 100644 index 00000000..e3c9ef77 Binary files /dev/null and b/docs/design/review/figma/recurring-weekly-20260925.png differ diff --git a/docs/design/review/figma/reset-complete-v3.png b/docs/design/review/figma/reset-complete-v3.png new file mode 100644 index 00000000..c1f75131 Binary files /dev/null and b/docs/design/review/figma/reset-complete-v3.png differ diff --git a/docs/design/review/figma/reset-complete.png b/docs/design/review/figma/reset-complete.png new file mode 100644 index 00000000..e4acbc43 Binary files /dev/null and b/docs/design/review/figma/reset-complete.png differ diff --git a/docs/design/review/figma/runtime-finish-confirm.png b/docs/design/review/figma/runtime-finish-confirm.png new file mode 100644 index 00000000..20529185 Binary files /dev/null and b/docs/design/review/figma/runtime-finish-confirm.png differ diff --git a/docs/design/review/figma/runtime-late.png b/docs/design/review/figma/runtime-late.png new file mode 100644 index 00000000..9c36723f Binary files /dev/null and b/docs/design/review/figma/runtime-late.png differ diff --git a/docs/design/review/figma/runtime-on-time.png b/docs/design/review/figma/runtime-on-time.png new file mode 100644 index 00000000..e268a929 Binary files /dev/null and b/docs/design/review/figma/runtime-on-time.png differ diff --git a/docs/design/review/figma/today-status-scheduled.png b/docs/design/review/figma/today-status-scheduled.png new file mode 100644 index 00000000..5e7b258b Binary files /dev/null and b/docs/design/review/figma/today-status-scheduled.png differ diff --git a/docs/design/review/native-20260925/README.md b/docs/design/review/native-20260925/README.md new file mode 100644 index 00000000..52508e6c --- /dev/null +++ b/docs/design/review/native-20260925/README.md @@ -0,0 +1,33 @@ +# iOS native verification — 2026-09-25 + +The isolated **OnTime Parity Backup QA 20260925** simulator is iPhone 17 / iOS 26.5, UUID `74CFB493-7B8B-4DFD-9DD6-F0E97E682FA7`, bundle `club.devkor.ontime.ios`, debug build of this worktree. No existing device profile was reset. Its new local profile had one 3-minute preparation step, 30-minute buffer, and no appointments. System language was English; existing My Data screens and user-editable seed names remain Korean. + +## Executed, observed results + +| Scenario | Observation | +| --- | --- | +| New installation | Welcome → selection → order → numeric 3-minute entry → buffer → local profile saved. Native testing found that the time field's accessibility bounds merged with its row; the row now focuses/selects the real field and the native input succeeded after rebuilding. | +| Notifications | App rationale opened the actual iOS notification prompt. Denying permission continued to the alarm gate. | +| AlarmKit | The actual iOS alarm/timer permission prompt appeared. Denial retained the rationale; defer continued to Home. My Page showed notifications Off. No permission bypass or settings override was used. | +| Export | Entered a QA-only passphrase; the actual iOS Files document export picker opened. Saved `OnTime-20260925.ontimebackup` under On My iPhone. My Data changed to no changes since backup. See [saved state](backup-saved.png). | +| Durable setting change | Changed the default buffer 30→35 minutes and saved. My Page read 35 minutes. See [before restore](buffer-changed-35.png). | +| Import and validation | The native file-open picker showed the same 811-byte file; selecting it successfully decrypted and produced a preview for iOS, 1.1.0+56, zero appointments and one default preparation. See [preview](restore-preview.png). | +| Restore application | Confirmed restore; the app reported success. My Page reloaded the persisted 3-minute preparation and **30-minute** buffer. See [restored values](restored-buffer-30.png). | +| Reset lifecycle | Reset confirmation → busy → complete succeeded. See [completion](reset-complete.png). After full process termination/relaunch, Welcome appeared for a new local profile. See [relaunch](reset-relaunch-welcome.png). | +| External backup preservation | After reset/relaunch, the exported file still existed and its SHA-256 was unchanged. | + +File verification: 811 bytes, SHA-256 `339fe8a981377bdc9ef538e3c08cbb970e73862aff1f2768d2a660eb5814989e`. The encrypted bytes did not contain the sample preparation name in plaintext. Successful decryption/preview and the restored buffer value are the substantive restore evidence; filename/size alone would not be sufficient. No backup file or passphrase is committed. + +## Native defect repaired + +Installed `file_selector_ios` 0.5.3+6 implements open-file selection but does not implement the save-location API previously used by BackupService. Its type filtering also requires `uniformTypeIdentifiers`, which the old backup group omitted. Thus widget tests with a fake service could not establish that the production iOS operation worked. + +`BackupFilePicker` now routes iOS export through `on_time_front/backup_files`. Swift writes only the already-encrypted container to a temporary directory and presents [`UIDocumentPickerViewController(forExporting:asCopy:)`](https://developer.apple.com/documentation/uikit/uidocumentpickerviewcontroller/init%28forexporting%3Aascopy%3A%29). The delegate reports success after the user saves, false on cancellation/dismissal, rejects concurrent export, and cleans its temporary files. The password stays in Dart. Non-iOS save-location handling is retained. Import uses the iOS `public.data` type; authenticated format validation determines whether the chosen file is a valid backup. + +Tests separately verify encrypted bytes/name crossing the channel, cancellation, native errors, unchanged freshness on failed/cancelled export, completion-only snapshot marking, preview without applying, invalid files and unchanged data, replacement restore, legacy format compatibility and recurring relationships. A second agent reviewed the native diff and found no blocking issue. Unit tests do not substitute for the Files UI observations above. + +## Evidence limits + +- This run exercised native setup, settings persistence, permission denial/defer, export, import, restore, reset and relaunch. It did not claim successful alarm delivery or a native export-cancel run; those gateway/cancellation paths have automated tests. +- Appointment creation/edit/delete and recurring scope/conflict/ownership are covered by the current widget/database integration suite. Earlier native recurring creation/navigation evidence is dated separately in `docs/design/recurring-schedules/implementation/native-ios/README.md`; it is not re-labelled as this run. +- No physical device was available: `xcrun devicectl list devices` returned the paired iPhones/iPad as unavailable. Physical-device font/layout, background/locked-screen alarm delivery, VoiceOver operation and frame-time/scroll performance remain a device acceptance gate. Debug simulator rendering is not a performance benchmark. diff --git a/docs/design/review/native-20260925/alarm-denied-rationale.png b/docs/design/review/native-20260925/alarm-denied-rationale.png new file mode 100644 index 00000000..a3b9c957 Binary files /dev/null and b/docs/design/review/native-20260925/alarm-denied-rationale.png differ diff --git a/docs/design/review/native-20260925/backup-saved.png b/docs/design/review/native-20260925/backup-saved.png new file mode 100644 index 00000000..d57dde0b Binary files /dev/null and b/docs/design/review/native-20260925/backup-saved.png differ diff --git a/docs/design/review/native-20260925/buffer-changed-35.png b/docs/design/review/native-20260925/buffer-changed-35.png new file mode 100644 index 00000000..3b9b6423 Binary files /dev/null and b/docs/design/review/native-20260925/buffer-changed-35.png differ diff --git a/docs/design/review/native-20260925/reset-complete.png b/docs/design/review/native-20260925/reset-complete.png new file mode 100644 index 00000000..25baf3f3 Binary files /dev/null and b/docs/design/review/native-20260925/reset-complete.png differ diff --git a/docs/design/review/native-20260925/reset-relaunch-welcome.png b/docs/design/review/native-20260925/reset-relaunch-welcome.png new file mode 100644 index 00000000..d7c3543c Binary files /dev/null and b/docs/design/review/native-20260925/reset-relaunch-welcome.png differ diff --git a/docs/design/review/native-20260925/restore-preview.png b/docs/design/review/native-20260925/restore-preview.png new file mode 100644 index 00000000..bbc26c72 Binary files /dev/null and b/docs/design/review/native-20260925/restore-preview.png differ diff --git a/docs/design/review/native-20260925/restored-buffer-30.png b/docs/design/review/native-20260925/restored-buffer-30.png new file mode 100644 index 00000000..a4ac6f10 Binary files /dev/null and b/docs/design/review/native-20260925/restored-buffer-30.png differ diff --git a/docs/design/review/onboarding-parity-20260925.md b/docs/design/review/onboarding-parity-20260925.md new file mode 100644 index 00000000..562c470d --- /dev/null +++ b/docs/design/review/onboarding-parity-20260925.md @@ -0,0 +1,59 @@ +# Onboarding and notification permission review — 2026-09-25 + +The canonical source is Figma `FIdR6bUMHScn9FWbwqrBsA`, page `1:23`, Onboarding/FlowScreens and Permission/NotificationPermission families. These active app surfaces must not be excluded merely because they are absent from board `285:5`. Every node below was read with fresh design context and screenshot. + +## Sources and captures + +| Node | State | Source image | SHA-256 | Flutter golden | +| --- | --- | --- | --- | --- | +| `246:42` | welcome | `docs/design/review/figma/onboarding-0-20260925.png` | `b4e365b2ebe04c028c77f6b47af4d71cd1eaf3601e67ef427652e6614f167a2d` | `test/goldens/goldens/onboarding_welcome_390x844.png` | +| `246:53` | select | `docs/design/review/figma/onboarding-1-20260925.png` | `a7cbab4c426baf609e2ce0b919a415cece70d0387199a1260e82ce1c8352c6d4` | `test/goldens/goldens/onboarding_select_390x844.png` | +| `246:90` | add | `docs/design/review/figma/onboarding-2-20260925.png` | `51f0d329717347fd0df91b6b6756fa40112c2809da65511fb1fe688d4522d1c1` | `test/goldens/goldens/onboarding_add_390x844.png` | +| `246:113` | selected | `docs/design/review/figma/onboarding-3-20260925.png` | `d89d8e494229a0e62ed43054028b7f1a46ac23017cc646dac83088456e18cdca` | `test/goldens/goldens/onboarding_selected_390x844.png` | +| `246:150` | order | `docs/design/review/figma/onboarding-4-20260925.png` | `79514dd77bc8bcf081cf8dfd3e813baf92e256c00715fdba94c85d2a26055f81` | `test/goldens/goldens/onboarding_order_390x844.png` | +| `246:180` | time | `docs/design/review/figma/onboarding-5-20260925.png` | `681613261674d17d72260f67bc17f63574bebe11ac3bf9b8747d190b84fbc489` | `test/goldens/goldens/onboarding_time_390x844.png` | +| `246:212` | buffer | `docs/design/review/figma/onboarding-6-20260925.png` | `0b148e6e8b357f3b41e298e508148176b183a7956b3acfc7b7135a242d81ed87` | `test/goldens/goldens/onboarding_buffer_390x844.png` | +| `278:100` | time_focused | `docs/design/review/figma/onboarding-7-20260925.png` | `389a73aa5b3fc1a3efec3077fd94695009a2f5ce5d00ec14469614a377bed3a8` | `test/goldens/goldens/onboarding_time_focused_390x844.png` | +| `278:187` | time_short | `docs/design/review/figma/onboarding-8-20260925.png` | `9a9ec8a31e93b79045623e66c7a01622bde52dcd0ddc89b15b2dd9ce3df7390b` | `test/goldens/goldens/onboarding_time_short_390x844.png` | +| `278:274` | time_long | `docs/design/review/figma/onboarding-9-20260925.png` | `bdceb130364cf9a585bc47426462460e408f24319290c131dfbce0b5637d9222` | `test/goldens/goldens/onboarding_time_long_390x844.png` | +| `246:230` | notification_permission | `docs/design/review/figma/onboarding-10-20260925.png` | `ceba9ea9bb5429e33ac8bd38332f90c93ca65878c60ae3d707e8a12a3e2e1b5e` | `test/goldens/goldens/notification_permission_390x844.png` | +| `246:238` | system_prompt | `docs/design/review/figma/onboarding-11-20260925.png` | `12bd9bd4f0c706eb1ac9de5202595e20aa746ace1ee4bc84bf98743fa1565d2d` | `OS-owned system prompt; gateway behavior tested` | + +## Changes + +- The welcome screen uses the exact downloaded greeting image, 271×280 at the source position, and a 358×58 primary action at (16,752). The content scrolls at narrower widths/larger text. +- The existing four-step setup now uses the source Korean headings, single-line STEP labels, exact source progress segments, five editable preparation suggestions, 62px selection/reorder rows, and matching primary colors. The flow remains entirely local. +- Time entry now uses an actual numeric TextFormField and platform keyboard, matching the interaction in the Figma TimeInput family. The existing Formz domain validation still rejects zero/negative/over-1440-minute durations. Fields preserve controller selection while editing and update when the cubit changes externally. The total is derived from entered durations and inherits the real app font. Tapping anywhere on a duration row focuses the field and selects its value, including the row-wide accessibility bounds reported by iOS. The unit and accessibility label use the active localization. +- The notification request uses the exact 36×37 bell SVG inside a 70px badge, 358×58 primary action with an 8px radius, and a real TextButton for the defer action. Permission results, denial, deferral, settings resume and home navigation retain the existing gateway/cubit behavior. + +## Documented exceptions + +- Native status bars, home indicators, numeric/alphabetic keyboards, and the iOS permission alert are OS-owned. Widget goldens model keyboard insets and focus but do not draw a fake system keyboard or permission prompt. `246:238` is excluded only as a platform overlay; the app-owned request behind it is reviewed against `246:230`. +- The source time input screenshots show “총 시간: 30분” even when displayed values sum to 0, 60, 40, or 100030. Flutter calculates the real sum and prevents submitting 100000 minutes. The mockup’s oversized value is an invalid-input fixture, not permission to weaken the domain limit. +- Figma shows an enabled Next action with zero preparation selections. Flutter keeps it disabled until at least one valid preparation is selected. +- The default spare time remains the current product value 30 minutes. The source 10-minute state is reproduced with a valid 10-minute fixture. The existing minimum-decrease warning is visible at the minimum; the Figma buffer frame omits this current product warning. +- Notification rationale preserves the product’s precise schedule-preparation reminder copy (“약속 준비 리마인더를 보내…”), which explains actual local-notification use. It does not revert to the older generic promise in Figma. +- The current app uses bundled Pretendard; Figma uses Noto Sans KR/IBM Plex Sans KR. Text metrics vary. STEP labels remain compact while their semantic label announces the active step; form copy, controls and input text scale normally. +- App content can scroll and grows with text. An added preparation field scrolls into view based on the actual keyboard/focus geometry; the Figma AddPreparation frame freezes a particular scroll offset. The selection form retains the same vertical position when selections change, while the source SelectedPreparation variant shifts its first row down 22px. The app avoids moving all rows when the user selects a choice. +- Notification defer retains a 44px touch target, which shifts the footer content 4px relative to the smaller source text target. Numeric inputs retain a real caret, focus underline, and platform text editing. Long invalid input expands to fit Pretendard digits rather than clipping to the mockup width. + +## Assets + +All files below are nonempty original Figma downloads. SVG root sizes are used at callsites without modification. The greeting image is precached before visual capture so missing asynchronous raster decoding cannot be accepted as a blank golden. + +- `assets/design/onboarding_drag.svg` — SHA-256 `ad38a92436c5b8aad4e799b8436f1d2560cce14091039065a86e6753c4393508` +- `assets/design/onboarding_greeting.png` — SHA-256 `b37032dbcde924bf5f054a06c105efe884d8ccef787274e6c5648dd0e46d8c64` +- `assets/design/onboarding_progress_complete.svg` — SHA-256 `9771c7f3860d3f95d9fb95b2d359f071aab7da254cbdd860b2abdeca00dd9247` +- `assets/design/onboarding_progress_current.svg` — SHA-256 `7ff87b51206a3f1c5a9bb5aafd71a68b179aef5095e215b9a9c73ee47d93386d` +- `assets/design/onboarding_progress_last_current.svg` — SHA-256 `c5281905e24fc557df43aef4e3f55767597a875d3f9466c1ea92f4cf2ca18f45` +- `assets/design/onboarding_progress_last_pending.svg` — SHA-256 `329c31f798bace191fea4bcf6044c812a023ea9e8197625ec9a6084b15cd15dd` +- `assets/design/onboarding_progress_pending.svg` — SHA-256 `8c43a3af95906abc736c744e41de6708a20456f928ee6354b59c4cc24da4cf7c` +- `assets/design/notification_bell.svg` — SHA-256 `cda453a5be09d508d0287b623ad140dd1c298d7211fb1283b0efba3fe100ac2d` + +## Verification + +New tests cover the welcome image/action geometry; initial selection validation; selecting, adding and editing six preparations; reorder persistence; numeric focus/short/oversized entry; computed totals; invalid submission prevention; valid duration persistence; 320px/2× text; and notification request golden/iOS touch and label checks. Existing tests cover local profile submission/failure and every notification gateway result. The three-file focused run completed with **17 tests passed** (`/private/tmp/ontime-onboarding-final2-test.log`). The final five visual tests, including the added row-center tap → focus and full-selection regression, passed after the font and drag-size refinements (`/private/tmp/ontime-onboarding-final3-test.log`). A five-test non-update golden run also passed before the final invalid-input width adjustment; the parent task owns the final full-suite verification. + +The parent task independently rebuilt the iOS app and confirmed row-wide tap, entry of 3 minutes, and onboarding save on the simulator after this change. This is simulator evidence, not physical-device evidence. + +Final visual assessment: all 11 app-owned states have source-backed captures and are reviewed with the explicit behavior, typography, and OS exceptions above. The greeting raster, progress segments, row styling, drag icon, bell, actions, recovery-related states and totals render correctly. This is not a claim of pixel-identical Figma fonts or fake native keyboards. No unresolved app-owned icon or clipping defect is accepted as a passing golden. diff --git a/docs/design/review/original-form-parity-20260925.md b/docs/design/review/original-form-parity-20260925.md new file mode 100644 index 00000000..3324563c --- /dev/null +++ b/docs/design/review/original-form-parity-20260925.md @@ -0,0 +1,41 @@ +# Original appointment forms and preparation editing — 2026-09-25 + +Canonical Figma file: `FIdR6bUMHScn9FWbwqrBsA`, page `1:26`. Fresh design context and screenshots were read for all five nodes before changing the implementation. The source exports below are stored at 390×844. Flutter captures use the real production widgets and bundled Pretendard fonts. + +| State | Figma node | Source PNG | Flutter golden | Source SHA-256 | +|---|---|---|---|---| +| Name input | `251:42` | `figma/form-name.png` | `form_name_390x844.png` | `912f174f618dfd88e6bdec1ad0f1aca2ae403377e51fcbb9ddb234c8f6642965` | +| Date picker | `251:75` | `figma/form-date.png` | `form_date_picker_390x844.png` | `147cc7d396469cb4e11af53b1dca3c25c1d7da57cf75c2e5a315143eb6bdb616` | +| Time picker | `251:119` | `figma/form-time.png` | `form_time_picker_390x844.png` | `50aacc58ce78d7ed1c2e1ccb027260cf1cca8e7e17bd1e8e2f17feb638b64995` | +| Place and travel input | `251:162` | `figma/form-place.png` | `form_place_390x844.png` | `2bb089c948342412305999ccc5303fecf43c751b3ccd0dcd7caa88acfd40996e` | +| Preparation edit | `1334:2023` | `figma/form-preparation.png` | `preparation_edit_390x844.png` | `0ea7a3def11759115f924c88cc93b9f4176c52a09a40876c4d2bd4ded1f30684` | + +Source paths are relative to this report; Flutter goldens are under `test/goldens/goldens/`. + +## Implementation and comparison + +- Name and place captures open `KeyboardBackedBottomSheet` through the same `showModalBottomSheet` options used by production, then render/navigate the actual `ScheduleMultiPageForm`. They cover the empty name and empty place/zero travel state. The static bloc isolates rendering; existing form widget/domain tests cover validation and save behavior. +- The shared date/time/minute picker sheet now follows the source structure: 16px horizontal padding, 24px top/section gaps, 167px native wheel viewport, 16px top corners, 42% scrim, and paired source-color cancel/input buttons. Date/time/minute values still come from native Cupertino widgets. Save commits the current value; cancellation only dismisses. The minute scroll controller is disposed after the modal completes. +- Preparation editing now has a meaningful localized header and Done action, a computed total duration, 16px horizontal content insets, and list start at y167. Four rows retain the source 62px height and 8px separation at default text scale. The source drag icon is stored as `assets/preparation_edit_drag.svg` (18×16, SHA-256 `5755366e0fe2044409763c277fea35bd8fd2d8f55084bdac3b0ce4596885fc48`). Duration chips grow with text scale and show localized minute units. The existing draft/save/reorder interactions remain in place. +- The 10+10+5+5 minute fixture displays a real 30-minute total. Editing the first step and pressing Done updates `PreparationEditDraftCubit`; the same save remains reachable at 2× text scale. + +## Explicit source exceptions + +These five states are reviewed with exceptions, not pixel-identical renders: + +1. **Current shared form flow:** The original name/place/date/time Figma nodes contain an 80% sheet, `STEP 1–4` labels, and a top-right Next action. Current production and the newer recurring design use an 85% sheet, Korean descriptive step labels, and bottom Back/Next actions. That current shared flow is preserved; old source frames do not justify reverting the newer recurring implementation. Name/place content remains the actual editable fields, including useful name placeholder and the real `0시간 0분` value. Source empty placeholders are not application data. +2. **Native picker rendering:** Figma contains flat numeric wheel artwork, while Flutter retains accessible, localized Cupertino wheels, including Korean year/month/day and AM/PM labels, native perspective, and selection behavior. The Figma sheet starts at y532 but its declared 333px content extends 21px beyond the 844px frame. Flutter keeps the entire sheet and bottom controls visible, so its top is y513. The isolated date/time golden's host above the modal is only a test trigger; comparison is the picker sheet region, not that host. The app's actual date/time field integration is covered separately by `schedule_date_time_form_test.dart`. +3. **Typography:** Source uses Noto Sans KR/IBM Plex Sans KR; the app uses Pretendard. The golden harness explicitly substitutes Pretendard for unavailable private Cupertino SF fonts. Production keeps the native Cupertino font choice. Native system chrome is excluded. +4. **Preparation source content:** The Figma header says “캘린더” and shows four `00 분` placeholders beside a 30-minute total. Production uses “준비 과정 및 시간 수정” and the actual sum of actual step durations. This is a source text/arithmetic inconsistency, not an application mismatch to copy. The existing add control retains its 44px accessible tap area around a 32px circle; its center is 4px above the source artwork. + +## Verification + +`test/presentation/schedule_create/original_form_visual_test.dart` adds five PNG captures and six behavior/visual tests: name/place navigation, native date/time save, 2× picker cancellation without save, and preparation edit/draft save at 2×. The rendered PNGs were opened and visually compared with fresh Figma exports; this check caught and corrected the source SVG package-path issue. + +Focused original-form/picker/preparation/date/place run: 28 tests, initially 27 passed. The one failure was an existing Korean date-format assertion after the recurring design changed formatting to include weekday (`2026년 5월 15일 (금)`); the expectation was updated and all three date-time tests passed on rerun. The original-form and preparation-row rerun passed all 12 tests. After the SVG package-path correction, all six original-form tests passed again and the final preparation-edit image was inspected with the source drag icons visible. Coverage includes minute-wheel movement/save/disposal, cancel without save, timer/date initial selection save, field validation, focus identity changes, add-row picker lifecycle, reorder, and date/time cubit wiring. The root full-suite run remains the combined regression gate. + +No native-device picker or accessibility-screen-reader claim is made by these widget/golden checks. + +### Shared six-row editor regression review + +The root full-suite run exposed a 1.75% difference in the existing `preparation_spare_time_edit_390x844.png`. Comparing its test/master/diff images against `figma/preparation-spare-time-edit-default.png` (node `600:35201`) isolated the difference to the new source 18×16 drag SVG and the resulting slight name-position shift. Duration chips, row geometry, and actions were unchanged; the fresh icon and name position are closer to the source. The single affected golden was therefore updated with this evidence. All eight tests in `preparation_spare_time_edit_screen_test.dart` passed, including edit persistence during spare-time changes, save failure, asynchronous save completion, changed-duration submission, six-row geometry, and adding a seventh step. diff --git a/docs/design/review/recovery-parity-20260925.md b/docs/design/review/recovery-parity-20260925.md new file mode 100644 index 00000000..b0579e34 --- /dev/null +++ b/docs/design/review/recovery-parity-20260925.md @@ -0,0 +1,56 @@ +# Recovery, local data, and privacy visual review — 2026-09-25 + +Source file: `FIdR6bUMHScn9FWbwqrBsA`. All listed nodes were fetched with fresh high-fidelity design context and screenshots on 2026-09-25; source exports are separate from Flutter goldens. The comparison viewport is iOS 390×844, safe area top44/bottom21, Korean, text scale1. + +## Source and capture inventory + +| Figma node | Source export | SHA-256 | Flutter golden | +| --- | --- | --- | --- | +| `1957:11762` | `docs/design/review/figma/recovery-v3.png` | `0458b76579f000c3ed4b40059836b04ed35e38797caddca79477b47a75c73ea4` | `test/goldens/goldens/recovery_default_390x844.png` | +| `2003:12059` | `docs/design/review/figma/recovery-confirm-v3.png` | `ce978101bf343b568497e4c381a9e461abfbc767a50cda7268af28a7231db784` | `test/goldens/goldens/recovery_reset_confirmation_390x844.png` | +| `2005:12036` | `docs/design/review/figma/recovery-busy.png` | `93b54f8e6116d4d793d98106eb180ba920109a8f09046804a0fe2bdc2e7083d3` | `test/goldens/goldens/recovery_busy_390x844.png` | +| `1963:11762` | `docs/design/review/figma/reset-complete-v3.png` | `f98c8ab46909ad90ea9a602c8b768a7bd78fd819bb18f1bdc4f70a086230fd01` | `test/goldens/goldens/reset_complete_390x844.png` | +| `1950:11759` | `docs/design/review/figma/privacy-v3.png` | `88f84355c58e153300ccd4cce29c235c6fa3d05309fde7ba1eed99c04a6e94a5` | `test/goldens/goldens/privacy_policy_390x844.png` | +| `1978:11791` | `docs/design/review/figma/my-data-restore-preview.png` | `ffb62bd9764f8897e14d95f6f12334fd0be6f8ec0a30f31c615fd37ad68a5689` | `test/goldens/goldens/my_data_restore_preview_390x844.png` | +| `1979:11816` | `docs/design/review/figma/my-data-password-create.png` | `7e04e15b88d878162767d910c9d45f3703a2fbe6bf27d56522204cef66abb04b` | `test/goldens/goldens/my_data_password_create_390x844.png` | +| `1980:11841` | `docs/design/review/figma/my-data-password-input.png` | `94dd76f87a6a7fe77bd9672bdb645f52aa91700e48b2eeab101fbf2f4507f0c1` | `test/goldens/goldens/my_data_password_input_390x844.png` | +| `1981:11866` | `docs/design/review/figma/my-data-password-invalid.png` | `de016130f45b290c63baddad21479e1ed2b65de2701be604bd8432c55d67ec9f` | `test/goldens/goldens/my_data_password_invalid_390x844.png` | +| `1982:11891` | `docs/design/review/figma/my-data-password-mismatch.png` | `894f3c951dfcb852e4f42b96c40cd19586434ac3eecda4aad3f8b5211f7f4621` | `test/goldens/goldens/my_data_password_mismatch_390x844.png` | + +The same `2003:12059` confirmation is used by My Data; its route capture is `test/goldens/goldens/my_data_reset_confirmation_390x844.png`. + +## Implementation and comparison + +- Recovery restores the exact 68×68 warning SVG, 22×22 information SVG, 352px content width, notice at (19,360), retry at y457, and fallback panel at y578. Both actions have 44px touch targets. Retry dispatches the existing auth retry event; reset still requires explicit confirmation. +- Reset confirmation uses the exact 47×47 destructive-warning and 22×22 information SVGs. Consequences start at (19,254); external-backup notice starts at (19,386); cancel/delete actions sit at y742. Both My Data and recovery open this shared screen. +- Busy recovery uses the exact 40×40 spinner artwork inside a 47px slot, heading at y397, disabled 356×44 actions at y702/y756. The spinner animates in production; the golden fixes its animation phase. A failed reset clears busy state and presents an error. +- Reset completion restores the exact 69×69 success and 23×23 outlined-information artwork. The final action remains at y754 with 50px height. The app retains required restart guidance after storage has been closed. +- Privacy uses the source 358px card grid at x16, first card y169, 14px inner padding, 16px section-number column, 12px gutter, six complete selectable sections, and scrollable large text. +- Password creation, restore password, invalid-length, mismatch, and restore preview are compared with all five fresh source exports. The 277px card at y352 is preserved; the primary action now uses the source #4F69DF through the existing scoped RefreshTheme, without changing global button styling. Secure inputs, 15–128 character validation, mismatch protection, cancellation, preview metadata, keyboard and 2× text behavior remain tested. + +## Explicit runtime and source exceptions + +- Native status bar and home indicator are operating-system UI and are excluded from app goldens. +- Bundled Pretendard remains the app font; Figma uses Noto Sans KR/IBM Plex Sans KR on these screens. Glyph shape and line wrapping differ, notably one line of the privacy backup section and password bullets. Cards grow naturally at larger text sizes rather than clipping the fixed Figma frames. +- Recovery and reset-complete are terminal storage states: their Figma back chevron has no valid destination, so the app omits it. Confirmation and privacy have a real 44px back control. The privacy source has a 37px header; the app keeps a 44px touch target and compensates introductory spacing to retain the first card at y169. +- The Figma recovery buttons are 43px/42px; the app uses 44px accessibility targets and compensates the adjacent gaps. +- After destructive reset, the database, secure key, and services are closed. Figma says “시작하기” and claims immediate restart; app keeps “다시 시작 안내” and explains a full process restart. The action displays guidance instead of accessing closed storage or bypassing initialization. This is a deliberate lifecycle requirement, not unfinished layout work. +- The five Figma My Data overlays contain a Restore-row icon/text overlap and a Reset-row label copied from backup export. Flutter retains correct current row labels, icons, and nonoverlapping layout underneath the scrim. +- Password fields retain actual secure TextField behavior, focus, keyboard handling, and scalable text; the mockup’s literal bullet strings are not used as fake fields. + +## Asset verification + +All added SVGs are nonempty files downloaded from the specific Figma context asset URLs. Original root dimensions are preserved at the callsites. Existing recovery_warning.svg and reset_success.svg match the fresh downloaded source byte-for-byte. The busy spinner is stored separately because its fresh source hash differs from the previous generic spinner. + +- `assets/design/recovery_warning.svg` — SHA-256 `79f8587b1905dcf7a17eea7d1876d7222ea1dc5f08193464d08dbceed3b8144e` +- `assets/design/reset_success.svg` — SHA-256 `6523bd1c81ab2cf36aa8522260900bc226701babb6304d17c2f9d758f8b3f587` +- `assets/design/information_filled.svg` — SHA-256 `af35a5afb451a5cc45b1e560d2c6922897227b119e6d09ce04e74c80ff4b491a` +- `assets/design/information_outline.svg` — SHA-256 `d32455d3d0aa53f71266ea404872bfc892c258553f649d6cedd00c56d7bebcdb` +- `assets/design/destructive_warning.svg` — SHA-256 `5e7479a845a8961b1e76d60f52270fafed5f77657c266707b6d33e5bc6c017f7` +- `assets/design/recovery_spinner.svg` — SHA-256 `3435d4eaf4260f838229014a4297393e60f3c275b299191560fc14bb99516790` + +## Verification + +Focused command: `flutter test --no-pub --concurrency=1 --update-goldens test/presentation/startup/local_data_recovery_visual_test.dart test/presentation/startup/local_data_reset_complete_visual_test.dart test/presentation/my_page/privacy_policy_screen_visual_test.dart test/presentation/my_page/my_data_screen_visual_test.dart`. Focused run: **31 tests passed**. The recovery/My Data/privacy cases passed again in the combined focused run after the header chevron refinement. Fresh captures were visually inspected against the source images. Final assessment is source-reviewed with the explicit font, platform and storage-lifecycle exceptions above; it is not a claim of pixel-identical fonts. The parent task records the final full-suite and native backup verification. + +Additional coverage: exact notice positions, destructive-screen placement, cancel leaves data untouched, retry behavior, busy disabled actions/reset failure, restart guidance, six privacy sections/2× scrolling, password validation/cancel, restoration preview cancellation, keyboard/large text, and iOS tap/label checks. diff --git a/docs/design/review/recurring-parity-20260925.md b/docs/design/review/recurring-parity-20260925.md new file mode 100644 index 00000000..28f6d835 --- /dev/null +++ b/docs/design/review/recurring-parity-20260925.md @@ -0,0 +1,66 @@ +# Recurring UI review — 2026-09-25 + +Canonical Figma file: [OnTime Design System](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA/OnTime-Design-System). All 18 nodes below were read with fresh `get_design_context` and `get_screenshot` results before implementation, and compared with rendered Flutter fixtures. Original exports are preserved with SHA-256 hashes. These are reviewed implementations with the explicit adaptations below; this report does not assert zero-pixel difference from Figma. + +## Source-to-fixture inventory + +Each source export and golden is 390×844 at device pixel ratio 1. The standalone sheet fixtures reserve real safe-area space (44 top/34 bottom); they do not paint a fake system status bar. Creation fixtures exercise the actual four-step form and use future dates against an injected clock to keep the database assertions deterministic. `date_time` and `preparation` come from `recurring_form_integration_test.dart`; the other 16 come from `recurring_parity_visual_test.dart`. + +| State | Figma node | Source PNG | Source SHA-256 | Flutter golden | +|---|---|---|---|---| +| `date_time` | `2112:2394` | `docs/design/review/figma/recurring-date-time-20260925.png` | `e81a92bc149ec372cf5440f1a15992a70af63509f97c01b6f009df2bb2c7bbdd` | `test/goldens/goldens/recurring_date_time_390x844.png` | +| `weekly` | `2112:21323` | `docs/design/review/figma/recurring-weekly-20260925.png` | `343688b5181b27e682b857de98fe8a907e6900521acaceb97f2e9c6e5891c82f` | `test/goldens/goldens/recurring_weekly_390x844.png` | +| `monthly` | `2112:21352` | `docs/design/review/figma/recurring-monthly-20260925.png` | `b69b5931f22f8893e31338cfac15eb2c15b37be6c06b2864d787113e480e974b` | `test/goldens/goldens/recurring_monthly_390x844.png` | +| `ending` | `2112:21395` | `docs/design/review/figma/recurring-ending-20260925.png` | `22c1608681bd101b922d4deb4a1c1a0469cd15465a98d4d76d552a68be9b3688` | `test/goldens/goldens/recurring_ending_390x844.png` | +| `preparation` | `2112:21456` | `docs/design/review/figma/recurring-preparation-20260925.png` | `ecb0df74ebe0e54067514f16be2d2a25a1dd1ccdec5bdc4b0073fd0107b59fe3` | `test/goldens/goldens/recurring_preparation_390x844.png` | +| `review` | `2112:21500` | `docs/design/review/figma/recurring-review-20260925.png` | `29a84cd7e21f2b9369967d8f26b9fcf1c25f20329ce584242b3db911cbe004fe` | `test/goldens/goldens/recurring_review_390x844.png` | +| `conflicts` | `2112:21531` | `docs/design/review/figma/recurring-conflicts-20260925.png` | `c4d4be1b26f8f51628b55660a7eaa3f2cb9847d4b23b7f39658e9a52bb72f7fe` | `test/goldens/goldens/recurring_conflicts_390x844.png` | +| `persistent_conflict` | `2112:21557` | `docs/design/review/figma/recurring-persistent-conflict-20260925.png` | `eed0e8720e4242851e2f518eb43ed98f44d0a991e15287aecd0ae6dd60474a00` | `test/goldens/goldens/recurring_persistent_conflict_390x844.png` | +| `scope` | `2112:21588` | `docs/design/review/figma/recurring-scope-20260925.png` | `6361ec89504c0d9deea00e69b22bdde1dd2ec6f7b10f816381e7ea8c55b245e5` | `test/goldens/goldens/recurring_scope_390x844.png` | +| `detached` | `2112:21613` | `docs/design/review/figma/recurring-detached-20260925.png` | `d74e3d7294dbc3553fea3d445c7b1082a38a65eb1aab22f1a3d3cea136c1e5f1` | `test/goldens/goldens/recurring_detached_390x844.png` | +| `time_exceptions` | `2112:21635` | `docs/design/review/figma/recurring-time-exceptions-20260925.png` | `44c32c26792c5a4838587e221c1287eca2f3aaa1ecfd5f7b41462f198b13dabe` | `test/goldens/goldens/recurring_time_exceptions_390x844.png` | +| `save_error` | `2112:21659` | `docs/design/review/figma/recurring-save-error-20260925.png` | `19ceb72970e68136567e80473a5439485cab149f89d8f7d81b4043eafd1bd067` | `test/goldens/goldens/recurring_save_error_390x844.png` | +| `frequency` | `2113:21627` | `docs/design/review/figma/recurring-frequency-20260925.png` | `b9e75a77c132f8b8e7995878160f1c329e655e40262617c9ef91b4096cac1357` | `test/goldens/goldens/recurring_frequency_390x844.png` | +| `monthly_fields` | `2113:21670` | `docs/design/review/figma/recurring-monthly-fields-20260925.png` | `657b3d1316485837fa641d87754d571060845e240c353a3ee9cf37ae4a2d405b` | `test/goldens/goldens/recurring_monthly_fields_390x844.png` | +| `end_date` | `2113:21708` | `docs/design/review/figma/recurring-end-date-20260925.png` | `5ec407e1334d1e0b012647fb172fbf30e249b606f8600c1c486c8ad857af9ae7` | `test/goldens/goldens/recurring_end_date_390x844.png` | +| `end_confirm` | `2113:21745` | `docs/design/review/figma/recurring-end-confirm-20260925.png` | `17fe56d97a952bcec836cd2fb6c9451eebdf10d2961819c1f729366979cec970` | `test/goldens/goldens/recurring_end_confirm_390x844.png` | +| `detail` | `2112:21715` | `docs/design/review/figma/recurring-detail-20260925.png` | `2d2dce2fd2043e9d091cab354aa346604a8d52db71eb131e075814a15d6de87e` | `test/goldens/goldens/recurring_detail_390x844.png` | +| `occurrence` | `2112:21768` | `docs/design/review/figma/recurring-occurrence-20260925.png` | `31f7f534b0b8ccf5cde3c58b5027b85d40175bdce29d6939b0cfbd4a3c9c6d26` | `test/goldens/goldens/recurring_occurrence_390x844.png` | + +## Implemented changes + +- Used source SVG assets for the recurrence calendar/clock/repeat icons, selection radio controls, preparation handles/underlines/chevrons, review notices, occurrence controls, and detail actions. The source assets are under `assets/recurrence_*.svg`. +- Matched the weekly segmented selection, monthly interval and all four monthly modes, selection panels, compact end-count field, and underlined inclusive end-date field. Day-of-month and ordinal-weekday inputs retain validation and the ability to switch patterns. +- Updated review, conflicts, persistent conflicts, detached occurrences, edit scope, civil-time ambiguity, save failure, detail, and end confirmation compositions. Conflict selection remains explicit, changes the saved count, and blocks saving unresolved or empty selections. +- Kept the actual name → date/time → place/travel → preparation wizard. Date/time and preparation content use the new visual styles. Buffer time stays outside total preparation time; recurrence preparation continues to be a copy owned by the series. +- Kept the existing recurring management and empty-state implementation. Only its detail and end-confirmation children changed. The detail end action is visible above the footer at the target viewport, and remains scrollable with more preparation steps. +- Preserved nonzero minutes in the occurrence title and fixed the monthly dropdown to inherit the application font instead of leaving its selected label with an unspecified font. + +## Explicit runtime and visual adaptations + +1. **Typography and system chrome:** the Figma nodes mix Noto Sans KR / IBM Plex Sans KR and contain a drawn iOS status/home bar. Flutter keeps the bundled Pretendard app typography, native safe areas, and real modal presentation. The detail, occurrence scope, and end confirmation are bottom sheets and therefore expose the actual dimmed backdrop at the top. Font metrics, paragraph wrapping, text antialiasing, and some resulting vertical spacing differ. Goldens load Pretendard and MaterialIcons deterministically; they are regression baselines, not screenshots of the OS chrome. +2. **Creation shell and fixture data:** source date/time and preparation nodes depict different step labels and sheet positions. The existing four-step route, navigation, validation, keyboard handling, and travel step are preserved. Their integration goldens show that real form in a full-screen test host, with a daily three-occurrence rule in 2030 and one 25-minute default step plus five minutes of buffer. The source sample uses a 2026 weekly rule, two steps totalling 25 minutes, and ten minutes of buffer. These values are fixture inputs, not hard-coded production substitutions. The date/time summary defers the actual first valid occurrence until preparation is known, then review uses the recurrence engine result. +3. **Touch targets and scrolling:** weekday controls remain 44×44 even where the static source is smaller. Recurrence pages scroll above fixed actions; lengthy conflict lists may require scrolling to the final explanatory notice. The weekly screen retains an explicit frequency/disable link so a recurring draft can become a one-off schedule. Expanded text can increase panel heights. +4. **Correct recurrence dates:** review and conflict fixtures expand the weekly rule with the real engine and retain actual weekday/date correspondence, count rules, skipped dates, preparation cutoff, and inclusive end date. They do not copy dates that conflict with the selected rule. Finite rules also keep their end/count label in detail. +5. **Unavailable source-only values:** a detached `ScheduleEntity` does not carry its preparation duration, so the detached card renders its real date/time without inventing the Figma sample's 40-minute preparation. Likewise, `RecurrenceConflict.other` has no preparation-start timestamp, so persistent conflicts show the other appointment's actual time rather than claiming a complete reserved interval from incomplete data. The edited occurrence has an actual override notice; it cannot infer a full series rule from an isolated entity. The detail preparation introduction is generic to the series, while row names and durations are actual values. +6. **DST and failure states:** the repeated-time sheet displays offsets resolved for the supplied civil date and zone (fixture: America/New_York, 2026-11-01 01:30, UTC−04:00 and UTC−05:00). It does not display a fictitious form-progress rail. The Figma failure frame combines an empty-generation example and a storage-error example; runtime renders the single actual error it received and keeps retry behavior. The storage fixture therefore does not falsely claim there were no matching occurrences. + +The source and fixture pairs above were manually reviewed for content, state, controls, colors, spacing and wrapping. The adaptations are intentional boundaries of this review; `reviewed` must not be presented as an assertion that every rendered pixel equals Figma. + +## Verification + +- `dart analyze lib/presentation/recurring lib/presentation/schedule_create/schedule_date_time/screens/schedule_date_time_form.dart lib/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form.dart test/presentation/recurring/recurring_parity_visual_test.dart` — no issues. +- Focused run of `recurring_parity_visual_test.dart`, `recurring_screens_test.dart`, `refreshed_screens_test.dart`, and `recurring_form_integration_test.dart` with `--no-pub --concurrency=1 --update-goldens` — **32 tests passed**, 12 seconds. The initial run exposed a detail end-action hit-test problem; spacing was corrected and all 32 passed after correction. +- Final follow-up: `recurring_parity_visual_test.dart` — **13 passed with `--update-goldens`**, then **13 passed without updating goldens**, two seconds each. The monthly day label renders with Pretendard after its font inheritance fix; detail uses a generic series preparation introduction and monthly field focus is settled before capture. All revised images were re-opened for visual verification. +- Coverage includes weekday/count validation, explicit conflict exclusions, persistent-conflict prevention, local database writes only after review confirmation, owned preparation, alarm effects, cancelled review, retry after invalid preparation, and following edits preserving individual overrides. Existing settings/review checks exercise 1.6× text. +- This subtask did not independently claim simulator/physical-device execution. Native execution evidence belongs to the parent task's separate runtime report. + +## `/scheduleEdit/:scheduleId` mapping + +`ScheduleEditScreen` loads `ScheduleFormEditRequested(scheduleId, scope)` and reuses the exact `ScheduleMultiPageForm` used by `ScheduleCreateScreen`; it submits `ScheduleFormUpdated` instead of `ScheduleFormCreated`. Its visual component mapping is therefore shared with the creation nodes, including `2112:2394` date/time and `2112:21456` preparation, plus the scope/review nodes above. No separately verified Figma edit-only node is asserted here. + +The integration case `following edit loads the series base and preserves an individual name override` loads an actual saved series from the database, edits from the second occurrence, verifies the two-occurrence review and preserves the individual name override after confirmation. This is behavioral edit-mode coverage; it is not a separate edit-route screenshot. The original name/date-picker/time-picker/place-travel nodes and `1334:2023` preparation-edit are audited by the calendar/original-form subtask rather than duplicated here. + +## Native backup review boundary + +Read-only review covered `BackupFilePicker`, `BackupService.exportToUserSelectedFile`, `OnTimeBackupExporter` in `ios/Runner/AppDelegate.swift`, their tests, and the installed file-selector iOS delegate implementation. No blocking production defect was found in the reviewed diff. The review confirmed encrypted bytes only, filename/path validation, a concurrent-export guard, explicit cancel/dismiss handling, temporary-directory cleanup, and marking only the captured revision after a completed save. This code review is not additional evidence of an executed native save/restore flow. diff --git a/docs/design/review/runtime-parity-20260925.md b/docs/design/review/runtime-parity-20260925.md new file mode 100644 index 00000000..0bea73c3 --- /dev/null +++ b/docs/design/review/runtime-parity-20260925.md @@ -0,0 +1,26 @@ +# Preparation runtime comparison — 2026-09-25 + +Canonical file `FIdR6bUMHScn9FWbwqrBsA`, Preparation page `1:21`. Fresh design contexts and screenshots were compared to real-font Flutter captures at Korean 390×844, top safe area44, fixed time and preparation data. + +| Node | Source image | Flutter golden | +| --- | --- | --- | +| `118:2` | `docs/design/review/figma/runtime-on-time.png` | `test/goldens/goldens/runtime_on_time_390x844.png` | +| `118:58` | `docs/design/review/figma/runtime-late.png` | `test/goldens/goldens/runtime_late_390x844.png` | +| `118:114` | `docs/design/review/figma/runtime-finish-confirm.png` | `test/goldens/goldens/runtime_finish_confirm_390x844.png` | + +The live timer uses the source268px circle at y121, 14px stroke, 28px preparation name, 48px countdown, top426px/bottom418px layout, blue/red timing colors, 358px preparation cards, 34px numbered badges and source checkmark artwork. Active cards135px, inactive62px, 14px connectors, 53px skip and57px finish actions match the reference geometry. Actual duration values, progress, scrolling and text scaling remain dynamic. Restoring an in-progress preparation now draws its saved ring progress immediately instead of starting at zero. + +Manual finish now opens the explicit source confirmation. Cancel leaves the session running; only confirm dispatches the finish event. Lateness is recalculated at confirmation, and the existing persisted result flow is retained. Automatic all-steps-complete and continuing-until-departure behaviors are separately regression-tested. + +A final independent review found that the last timer tick could open an automatic prompt over an already-open manual prompt. Both paths now reserve the same dialog slot and recheck the active schedule when the dialog closes. Continuing after the last step acknowledges completion without immediately reopening the automatic prompt; continuing before completion still allows the later automatic prompt. Two regressions use the real ScheduleBloc and a five-second last-step timer to prove both transitions. The final focused runtime group passes all 32 tests without changing the goldens. + +## Explicit exceptions + +- Figma's ring names “메이크업” while the active card names “샤워하기”, duplicates some step numbers and displays unrelated ring progress/countdowns. Flutter derives all names, numbers, durations and progress from the same active preparation state. The late fixture keeps real active-step remaining time rather than replacing it with a static overdue duration. +- Source confirmation promises Home navigation; the actual app shows the preparation result. Confirmation copy accurately describes that behavior. A functional44px Home close control is retained. +- Native status/home indicators are outside widget captures; bundled Pretendard replaces the Figma font. The dialog uses the viewport center while its text grows naturally. +- At320×640 and2× text, card content scrolls so the skip action stays reachable and the finish action remains anchored. Geometry from the fixed source is not enforced when it would clip accessible text. + +## Verification + +Five new tests cover three compared goldens, exact ring bounds, live remaining time, manual cancellation/confirmation, narrow enlarged-text skip/finish and restored initial progress. Twenty-five existing preparation flow tests cover early start, navigation, late/on-time completion, continuing timers and stale/missing schedules. All pass after the source changes; final combined validation is recorded in the completion report. The core backup checks run alongside this focused group are reported separately. diff --git a/handoff/ios-ui-quality-figma-parity.md b/handoff/ios-ui-quality-figma-parity.md new file mode 100644 index 00000000..3eadad13 --- /dev/null +++ b/handoff/ios-ui-quality-figma-parity.md @@ -0,0 +1,23 @@ +# iOS UI quality / Figma parity handoff + +Updated 2026-09-25. Work is based on refreshed `main` `c7184b2a`, merged at `64bc111d`. The canonical source is [OnTime Design System](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA/OnTime-Design-System); the old `GiDCK...` link is not the source. [Draft PR #596](https://github.com/DevKor-github/OnTime-front/pull/596) remains open for review; this handoff does not authorize merge or assert release readiness. + +## Completed scope + +- [The manifest](../docs/design/figma_code_map.yaml) passes strict validation: 21 routes contain 16 reviewed/five excluded defaults, and 58 variants contain 57 reviewed/one excluded state. There are **73 reviewed app entries and zero pending/in-progress entries**. Each reviewed entry has source/golden evidence or a documented shared-component mapping. Five route exclusions mean no current full-screen Figma source; the one variant exclusion is the OS-owned permission alert. +- Home/Calendar, original and recurring appointment forms, preparation editing/runtime, onboarding/permissions, recovery/reset, My Data dialogs, and privacy have source-backed review and focused tests. The six detailed reports and their source hashes/golden links are indexed in [the completion report](../docs/design/review/completion-20260925.md#evidence-index). +- The calendar's December-title/November-grid contradiction is resolved as a documented source exception: runtime retains correct dates. Other explicit adaptations retain Pretendard, real durations/counts, the current four-step form, native controls/chrome, accessible touch targets, and reset restart guidance. None is a claim of pixel identity. +- `/scheduleEdit/:scheduleId` shares the creation form and its mapped components. Database integration verifies following edits preserve individual overrides. Original form and preparation-edit mappings were also audited; a separate edit-only Figma screen is not invented. +- [Native evidence](../docs/design/review/native-20260925/README.md) records a rebuilt iPhone17/iOS26.5 debug simulator: setup and actual numeric entry; notification/AlarmKit denial and defer; successful Files export of an 811-byte encrypted backup; changing buffer 30→35; importing/decrypting the same backup; restoring buffer to 30; reset; and full relaunch to Welcome. The external backup file's hash remained unchanged after reset. This also verifies the repaired iOS export bridge and import UTI boundary in production UI. +- The final full-suite and coverage receipt has one authoritative location: [final combined validation](../docs/design/review/completion-20260925.md#final-combined-validation). Do not reuse the old checkpoint's test/coverage totals or treat targeted passes as the final combined result. + +## Explicitly outside the completed app mapping + +The manifest's **11 unmapped `design_only_nodes`** are standalone Schedule Detail screens, with no matching app route/implementation. They are different from the existing expandable Calendar card: Upcoming, PreparationDue, Preparing, CompletedOnTime, CompletedLate, CompletedUnknown, PastUnrecorded, Loading, Refreshing, NotFound, and Failure. Their node IDs and audit evidence are in [the Calendar report](../docs/design/review/calendar-parity-20260925.md#unmapped-full-schedule-detail-design-audit). Strict app coverage does not certify or implement them; adding that product flow is separate work. + +## Remaining release acceptance + +1. Local final combined validation is complete and recorded in the completion report. Read the current PR head and its remote checks before merge; keep local execution and remote CI receipts distinct. +2. Attach an available physical iPhone and execute device font/layout inspection, VoiceOver interaction, background/locked-screen alarm delivery, and scrolling/frame-time measurements. All paired devices were unavailable during this task; debug simulator rendering is not a device performance result. +3. Keep native evidence scoped accurately. The 2026-09-25 run proves the setup/permission denial/data lifecycle above. Export cancellation has automated coverage, not an executed native cancellation receipt. Appointment creation/edit/delete and recurring scope/conflict/ownership use widget/database integration; earlier native recurring evidence retains its own date. Successful alarm delivery was not observed in this run. +4. Preserve Draft PR #596 until the required release gates and normal review are satisfied. No merge or release was performed by this documentation handoff. diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 9fb3807f..1cee735d 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -14,6 +14,7 @@ private let onTimeAlarmLaunchURLHost = "alarm" @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { private static weak var current: AppDelegate? private var nativeAlarmChannel: FlutterMethodChannel? + private let backupExporter = OnTimeBackupExporter() override func application( _ application: UIApplication, @@ -36,6 +37,21 @@ private let onTimeAlarmLaunchURLHost = "alarm" channel.setMethodCallHandler { call, result in self.handleNativeAlarmCall(call, result: result) } + let backupChannel = FlutterMethodChannel( + name: "on_time_front/backup_files", + binaryMessenger: engineBridge.applicationRegistrar.messenger() + ) + backupChannel.setMethodCallHandler { [weak self] call, result in + guard call.method == "exportBackup" else { + result(FlutterMethodNotImplemented) + return + } + guard let self = self else { + result(FlutterError(code: "unavailable", message: "Application unavailable", details: nil)) + return + } + self.backupExporter.export(call.arguments, result: result) + } } private func handleNativeAlarmCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { @@ -456,3 +472,80 @@ private func deterministicAlarmUUID(_ scheduleId: String) -> UUID { )) } #endif + +/// Exports only the encrypted portable container through the system Files UI. +private final class OnTimeBackupExporter: NSObject, UIDocumentPickerDelegate, + UIAdaptivePresentationControllerDelegate { + private var pendingResult: FlutterResult? + private var temporaryDirectory: URL? + + func export(_ arguments: Any?, result: @escaping FlutterResult) { + guard pendingResult == nil else { + result(FlutterError(code: "busy", message: "A backup export is already open", details: nil)) + return + } + guard let args = arguments as? [String: Any], + let bytes = args["bytes"] as? FlutterStandardTypedData, + !bytes.data.isEmpty, + let name = args["suggestedName"] as? String, + (name as NSString).lastPathComponent == name, + name.hasSuffix(".ontimebackup") else { + result(FlutterError(code: "invalidArguments", message: "Invalid backup export", details: nil)) + return + } + let window = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + guard var presenter = window?.rootViewController else { + result(FlutterError(code: "unavailable", message: "No active application window", details: nil)) + return + } + while let presented = presenter.presentedViewController { + presenter = presented + } + guard !presenter.isBeingDismissed else { + result(FlutterError(code: "busy", message: "Application is dismissing a dialog", details: nil)) + return + } + pendingResult = result + do { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("ontime-export-\(UUID().uuidString)", isDirectory: true) + temporaryDirectory = directory + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let file = directory.appendingPathComponent(name) + try bytes.data.write(to: file, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + let picker = UIDocumentPickerViewController(forExporting: [file], asCopy: true) + picker.delegate = self + picker.modalPresentationStyle = .formSheet + picker.presentationController?.delegate = self + presenter.present(picker, animated: true) + } catch { + finish(FlutterError(code: "exportFailed", message: "Could not prepare backup file", details: nil)) + } + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + finish(!urls.isEmpty) + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + finish(false) + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + finish(false) + } + + private func finish(_ value: Any?) { + let result = pendingResult + pendingResult = nil + if let directory = temporaryDirectory { + try? FileManager.default.removeItem(at: directory) + } + temporaryDirectory = nil + result?(value) + } +} diff --git a/lib/core/backup/backup_file_picker.dart b/lib/core/backup/backup_file_picker.dart new file mode 100644 index 00000000..94fba736 --- /dev/null +++ b/lib/core/backup/backup_file_picker.dart @@ -0,0 +1,47 @@ +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/services.dart'; +import 'package:on_time_front/core/services/device_info_service/shared.dart'; + +/// Keeps OS document UI separate from backup encryption and persistence. +class BackupFilePicker { + BackupFilePicker({bool? isIOS}) + : _isIOS = isIOS ?? DeviceInfoService.osType == OsType.ios; + + static const _channel = MethodChannel('on_time_front/backup_files'); + static const typeGroup = XTypeGroup( + label: 'OnTime Backup', + extensions: ['ontimebackup'], + mimeTypes: ['application/octet-stream'], + // iOS requires UTIs. Authentication, rather than the file extension, + // determines whether the selected file is a valid OnTime backup. + uniformTypeIdentifiers: ['public.data'], + ); + + final bool _isIOS; + + Future save(Uint8List encrypted, String suggestedName) async { + if (_isIOS) { + return await _channel.invokeMethod('exportBackup', { + 'bytes': encrypted, + 'suggestedName': suggestedName, + }) ?? + false; + } + final location = await getSaveLocation( + acceptedTypeGroups: const [typeGroup], + suggestedName: suggestedName, + ); + if (location == null) return false; + await XFile.fromData( + encrypted, + name: suggestedName, + mimeType: 'application/octet-stream', + ).saveTo(location.path); + return true; + } + + Future select() async { + final file = await openFile(acceptedTypeGroups: const [typeGroup]); + return file?.readAsBytes(); + } +} diff --git a/lib/core/backup/backup_service.dart b/lib/core/backup/backup_service.dart index 46112b99..0a5a7338 100644 --- a/lib/core/backup/backup_service.dart +++ b/lib/core/backup/backup_service.dart @@ -2,7 +2,7 @@ import 'package:on_time_front/core/backup/recurring_backup_data.dart'; import 'dart:convert'; import 'package:drift/drift.dart'; -import 'package:file_selector/file_selector.dart'; +import 'package:on_time_front/core/backup/backup_file_picker.dart'; import 'package:injectable/injectable.dart'; import 'package:on_time_front/core/backup/backup_crypto.dart'; import 'package:on_time_front/core/constants/local_profile.dart'; @@ -63,31 +63,23 @@ class BackupService { this._database, this._metadataProvider, { @ignoreParam BackupCrypto? crypto, - }) : _crypto = crypto ?? BackupCrypto(); - - static const _typeGroup = XTypeGroup( - label: 'OnTime Backup', - extensions: ['ontimebackup'], - mimeTypes: ['application/octet-stream'], - ); + @ignoreParam BackupFilePicker? filePicker, + }) : _crypto = crypto ?? BackupCrypto(), + _filePicker = filePicker ?? BackupFilePicker(); final AppDatabase _database; final AppMetadataProvider _metadataProvider; final BackupCrypto _crypto; + final BackupFilePicker _filePicker; Future exportToUserSelectedFile(String password) async { final snapshot = await _captureSnapshot(); final encrypted = await _encryptSnapshot(snapshot, password); - final location = await getSaveLocation( - acceptedTypeGroups: const [_typeGroup], - suggestedName: 'OnTime-${_fileDate(snapshot.cutoff)}.ontimebackup', - ); - if (location == null) return false; - await XFile.fromData( + final saved = await _filePicker.save( encrypted, - name: 'OnTime-${_fileDate(snapshot.cutoff)}.ontimebackup', - mimeType: 'application/octet-stream', - ).saveTo(location.path); + 'OnTime-${_fileDate(snapshot.cutoff)}.ontimebackup', + ); + if (!saved) return false; await _database.userDao.markExported( userId: localProfileId, revision: snapshot.dataRevision, @@ -99,9 +91,9 @@ class BackupService { Future selectAndPreviewRestore( String password, ) async { - final file = await openFile(acceptedTypeGroups: const [_typeGroup]); - if (file == null) return null; - return previewEncryptedBackup(await file.readAsBytes(), password); + final bytes = await _filePicker.select(); + if (bytes == null) return null; + return previewEncryptedBackup(bytes, password); } /// Creates the same portable container used by the OS file export flow. diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e6b4e476..5c65f255 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4,6 +4,14 @@ "description": "The title of the calendar screen" }, "error": "Error", + "retry": "Try again", + "@retry": { + "description": "Action to retry loading failed content" + }, + "preparationInProgress": "Preparing", + "@preparationInProgress": { + "description": "Status label for an active preparation on the home card" + }, "@error": { "description": "Generic error message" }, @@ -112,6 +120,8 @@ "description": "Button text to continue preparing" }, "finishPreparation": "Finish Preparation", + "finishPreparationConfirmTitle": "Have you finished preparing?", + "finishPreparationConfirmDescription": "Finish preparation to see your result.", "@finishPreparation": { "description": "Button text to finish preparing" }, @@ -364,6 +374,18 @@ "@editDefaultPreparation": { "description": "Setting tile for editing default preparation and spare time" }, + "editPreparationSpareTimeHeader": "Edit spare time and preparation", + "@editPreparationSpareTimeHeader": { + "description": "Title of the spare-time and preparation edit screen" + }, + "done": "Done", + "@done": { + "description": "Action that saves and closes an edit screen" + }, + "addPreparationStep": "Add preparation step", + "@addPreparationStep": { + "description": "Accessible label for the preparation list add button" + }, "allowAppNotifications": "Allow App Notifications", "@allowAppNotifications": { "description": "Setting tile for allowing app notifications" @@ -392,7 +414,7 @@ "@editSpareTime": { "description": "Section title for editing spare time" }, - "editPreparationTime": "Edit preparation time", + "editPreparationTime": "Edit preparation steps and times", "@editPreparationTime": { "description": "Section title for editing preparation time" }, diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index bd808785..b4bac895 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1,6 +1,8 @@ { "calendarTitle": "캘린더", "error": "오류", + "retry": "다시 시도", + "preparationInProgress": "준비 진행 중", "noSchedules": "약속이 없어요", "setSpareTimeTitle": "여유시간을 설정해주세요", "setSpareTimeDescription": "설정한 여유시간만큼 일찍 도착할 수 있어요.", @@ -28,6 +30,8 @@ "preparationCompletedDescription": "준비 단계를 모두 완료했어요.\n지금 종료하거나 계속 준비할 수 있어요.", "continuePreparing": "계속 준비", "finishPreparation": "준비 종료", + "finishPreparationConfirmTitle": "준비가 모두 끝나셨나요?", + "finishPreparationConfirmDescription": "준비 종료를 누르면\n준비를 마치고 결과를 확인할 수 있어요.", "preparationReadyToGo": "출발 준비 완료", "signInSlogan": "당신의 잃어버린 여유를 찾아드립니다.", "signInFailedTitle": "로그인에 실패했어요", @@ -35,10 +39,10 @@ "welcome": "반가워요!", "onboardingStartSubtitle": "Ontime과 함께 준비하기 위해서\n평소 본인의 준비 과정을 알려주세요", "start": "시작하기", - "preparationOrderTitle": "앞에서 고른 준비 과정의 순서를\n알려주세요", - "preparationNameTitle": "주로 하는 준비 과정을\n선택해주세요 ", + "preparationOrderTitle": "앞에서 고른 준비 과정의 순서를\n설정해주세요", + "preparationNameTitle": "약속에 나가기 위한 준비 과정을\n선택해주세요 ", "multipleSelection": "(복수 선택)", - "preparationTimeTitle": "준비 시간", + "preparationTimeTitle": "과정별로 소요되는 시간을\n알려주세요", "addAppointment": "약속 추가하기", "next": "다음", "appointmentName": "약속 이름", @@ -92,6 +96,9 @@ "appSettings": "앱 설정", "accountSettings": "계정 설정", "editDefaultPreparation": "기본 준비과정 / 여유시간 수정", + "editPreparationSpareTimeHeader": "여유시간/준비과정 수정", + "done": "완료", + "addPreparationStep": "준비 과정 추가", "allowAppNotifications": "앱 알림 허용", "preciseNotificationPermissionRequired": "정확한 알림 권한이 필요해요", "preciseNotificationPermissionDescription": "정확한 시간에 준비를 알려드리기 위해 이 권한이 필요해요.", @@ -107,7 +114,7 @@ "logOut": "로그아웃", "deleteAccount": "회원 탈퇴", "editSpareTime": "여유시간 수정", - "editPreparationTime": "준비시간 수정", + "editPreparationTime": "준비 과정 및 시간 수정", "totalTime": "총 시간: ", "scheduleOverlapWarning": "\"{scheduleName}\"과 겹치지 않으려면 {minutes}분 안에 준비해야해요", "@scheduleOverlapWarning": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 19fa9ca4..38228de8 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -110,6 +110,18 @@ abstract class AppLocalizations { /// **'Error'** String get error; + /// Action to retry loading failed content + /// + /// In en, this message translates to: + /// **'Try again'** + String get retry; + + /// Status label for an active preparation on the home card + /// + /// In en, this message translates to: + /// **'Preparing'** + String get preparationInProgress; + /// Text shown when there are no schedules on a selected date /// /// In en, this message translates to: @@ -272,6 +284,18 @@ abstract class AppLocalizations { /// **'Finish Preparation'** String get finishPreparation; + /// No description provided for @finishPreparationConfirmTitle. + /// + /// In en, this message translates to: + /// **'Have you finished preparing?'** + String get finishPreparationConfirmTitle; + + /// No description provided for @finishPreparationConfirmDescription. + /// + /// In en, this message translates to: + /// **'Finish preparation to see your result.'** + String get finishPreparationConfirmDescription; + /// Center timer label shown after all preparation steps are complete and there is still time before leaving /// /// In en, this message translates to: @@ -608,6 +632,24 @@ abstract class AppLocalizations { /// **'Edit Default Preparation / Spare Time'** String get editDefaultPreparation; + /// Title of the spare-time and preparation edit screen + /// + /// In en, this message translates to: + /// **'Edit spare time and preparation'** + String get editPreparationSpareTimeHeader; + + /// Action that saves and closes an edit screen + /// + /// In en, this message translates to: + /// **'Done'** + String get done; + + /// Accessible label for the preparation list add button + /// + /// In en, this message translates to: + /// **'Add preparation step'** + String get addPreparationStep; + /// Setting tile for allowing app notifications /// /// In en, this message translates to: @@ -653,7 +695,7 @@ abstract class AppLocalizations { /// Section title for editing preparation time /// /// In en, this message translates to: - /// **'Edit preparation time'** + /// **'Edit preparation steps and times'** String get editPreparationTime; /// Label for total preparation time diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f21680be..cc33a963 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -14,6 +14,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get error => 'Error'; + @override + String get retry => 'Try again'; + + @override + String get preparationInProgress => 'Preparing'; + @override String get noSchedules => 'No schedules'; @@ -101,6 +107,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get finishPreparation => 'Finish Preparation'; + @override + String get finishPreparationConfirmTitle => 'Have you finished preparing?'; + + @override + String get finishPreparationConfirmDescription => + 'Finish preparation to see your result.'; + @override String get preparationReadyToGo => 'Ready to go'; @@ -298,6 +311,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String get editDefaultPreparation => 'Edit Default Preparation / Spare Time'; + @override + String get editPreparationSpareTimeHeader => + 'Edit spare time and preparation'; + + @override + String get done => 'Done'; + + @override + String get addPreparationStep => 'Add preparation step'; + @override String get allowAppNotifications => 'Allow App Notifications'; @@ -321,7 +344,7 @@ class AppLocalizationsEn extends AppLocalizations { String get editSpareTime => 'Edit spare time'; @override - String get editPreparationTime => 'Edit preparation time'; + String get editPreparationTime => 'Edit preparation steps and times'; @override String get totalTime => 'Total time: '; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index e646908c..5f26f487 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -14,6 +14,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get error => '오류'; + @override + String get retry => '다시 시도'; + + @override + String get preparationInProgress => '준비 진행 중'; + @override String get noSchedules => '약속이 없어요'; @@ -99,6 +105,13 @@ class AppLocalizationsKo extends AppLocalizations { @override String get finishPreparation => '준비 종료'; + @override + String get finishPreparationConfirmTitle => '준비가 모두 끝나셨나요?'; + + @override + String get finishPreparationConfirmDescription => + '준비 종료를 누르면\n준비를 마치고 결과를 확인할 수 있어요.'; + @override String get preparationReadyToGo => '출발 준비 완료'; @@ -122,16 +135,16 @@ class AppLocalizationsKo extends AppLocalizations { String get start => '시작하기'; @override - String get preparationOrderTitle => '앞에서 고른 준비 과정의 순서를\n알려주세요'; + String get preparationOrderTitle => '앞에서 고른 준비 과정의 순서를\n설정해주세요'; @override - String get preparationNameTitle => '주로 하는 준비 과정을\n선택해주세요 '; + String get preparationNameTitle => '약속에 나가기 위한 준비 과정을\n선택해주세요 '; @override String get multipleSelection => '(복수 선택)'; @override - String get preparationTimeTitle => '준비 시간'; + String get preparationTimeTitle => '과정별로 소요되는 시간을\n알려주세요'; @override String get addAppointment => '약속 추가하기'; @@ -278,6 +291,15 @@ class AppLocalizationsKo extends AppLocalizations { @override String get editDefaultPreparation => '기본 준비과정 / 여유시간 수정'; + @override + String get editPreparationSpareTimeHeader => '여유시간/준비과정 수정'; + + @override + String get done => '완료'; + + @override + String get addPreparationStep => '준비 과정 추가'; + @override String get allowAppNotifications => '앱 알림 허용'; @@ -300,7 +322,7 @@ class AppLocalizationsKo extends AppLocalizations { String get editSpareTime => '여유시간 수정'; @override - String get editPreparationTime => '준비시간 수정'; + String get editPreparationTime => '준비 과정 및 시간 수정'; @override String get totalTime => '총 시간: '; diff --git a/lib/presentation/alarm/components/alarm_graph_animator.dart b/lib/presentation/alarm/components/alarm_graph_animator.dart index e0b2f57c..ffa48b27 100644 --- a/lib/presentation/alarm/components/alarm_graph_animator.dart +++ b/lib/presentation/alarm/components/alarm_graph_animator.dart @@ -31,10 +31,8 @@ class _AlarmGraphAnimatorState extends State duration: const Duration(milliseconds: 300), ); - _progressAnimation = Tween( - begin: 0.0, - end: 0.0, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + previousProgress = widget.progress.clamp(0.0, 1.0); + _progressAnimation = AlwaysStoppedAnimation(previousProgress); } @override @@ -53,8 +51,8 @@ class _AlarmGraphAnimatorState extends State void _animateToNewProgress(double newProgress) { _progressAnimation = Tween( - begin: previousProgress, - end: newProgress, + begin: _progressAnimation.value, + end: newProgress.clamp(0.0, 1.0), ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); _controller.forward(from: 0); @@ -67,7 +65,7 @@ class _AlarmGraphAnimatorState extends State animation: _progressAnimation, builder: (context, child) { return CustomPaint( - size: const Size(230, 115), + size: const Size.square(268), painter: AlarmGraphComponent( progress: _progressAnimation.value, backgroundColor: widget.backgroundColor, diff --git a/lib/presentation/alarm/components/alarm_graph_component.dart b/lib/presentation/alarm/components/alarm_graph_component.dart index 988cbed7..f37845ec 100644 --- a/lib/presentation/alarm/components/alarm_graph_component.dart +++ b/lib/presentation/alarm/components/alarm_graph_component.dart @@ -18,22 +18,22 @@ class AlarmGraphComponent extends CustomPainter { final double sweepAngle = math.pi * 2; final rect = Rect.fromCenter( - center: Offset(size.width / 2, size.height), - width: size.width, - height: size.height * 2, + center: size.center(Offset.zero), + width: size.shortestSide - 14, + height: size.shortestSide - 14, ); // 채워지기 전 색 final Paint backgroundPaint = Paint() ..color = backgroundColor - ..strokeWidth = 12 + ..strokeWidth = 14 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round; // 채워진 후 색 final Paint progressPaint = Paint() ..color = progressColor - ..strokeWidth = 12 + ..strokeWidth = 14 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round; @@ -47,7 +47,9 @@ class AlarmGraphComponent extends CustomPainter { } @override - bool shouldRepaint(covariant CustomPainter oldDelegate) { - return true; + bool shouldRepaint(covariant AlarmGraphComponent oldDelegate) { + return progress != oldDelegate.progress || + backgroundColor != oldDelegate.backgroundColor || + progressColor != oldDelegate.progressColor; } } diff --git a/lib/presentation/alarm/components/alarm_screen_bottom_section.dart b/lib/presentation/alarm/components/alarm_screen_bottom_section.dart index 48f06ce5..6ed0644d 100644 --- a/lib/presentation/alarm/components/alarm_screen_bottom_section.dart +++ b/lib/presentation/alarm/components/alarm_screen_bottom_section.dart @@ -1,16 +1,14 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; import 'package:on_time_front/presentation/alarm/components/preparation_step_list_widget.dart'; import 'package:on_time_front/presentation/alarm/utils/preparation_step_state_mapper.dart'; -import 'package:on_time_front/presentation/shared/constants/constants.dart'; +import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; class AlarmScreenBottomSection extends StatelessWidget { - final PreparationWithTimeEntity preparation; - final VoidCallback onSkip; - final VoidCallback onEndPreparation; - const AlarmScreenBottomSection({ super.key, required this.preparation, @@ -18,105 +16,70 @@ class AlarmScreenBottomSection extends StatelessWidget { required this.onEndPreparation, }); - @override - Widget build(BuildContext context) { - final steps = List.from( - preparation.preparationStepList, - ); - - return Column( - children: [ - Expanded( - child: _PreparationStepListSection( - preparationSteps: steps, - currentStepIndex: preparation.resolvedCurrentStepIndex, - stepElapsedTimes: preparation.stepElapsedTimesInSeconds, - preparationStepStates: preparation.preparationStepStates, - onSkip: onSkip, - ), - ), - _EndPreparationButtonSection(onEndPreparation: onEndPreparation), - ], - ); - } -} - -class _PreparationStepListSection extends StatelessWidget { - final List preparationSteps; - final int currentStepIndex; - final List stepElapsedTimes; - final List preparationStepStates; + final PreparationWithTimeEntity preparation; final VoidCallback onSkip; - - const _PreparationStepListSection({ - required this.preparationSteps, - required this.currentStepIndex, - required this.stepElapsedTimes, - required this.preparationStepStates, - required this.onSkip, - }); - @override - Widget build(BuildContext context) { - return Stack( - children: [ - Container( - decoration: const BoxDecoration( - color: Color(0xffF6F6F6), - borderRadius: BorderRadius.only( - topLeft: Radius.circular(18), - topRight: Radius.circular(18), - ), - ), - ), - Positioned( - top: 15, - bottom: 0, - left: MediaQuery.of(context).size.width * 0.06, - right: MediaQuery.of(context).size.width * 0.06, - child: PreparationStepListWidget( - preparationSteps: preparationSteps, - currentStepIndex: currentStepIndex, - stepElapsedTimes: stepElapsedTimes, - preparationStepStates: preparationStepStates, - onSkip: onSkip, - ), - ), - ], - ); - } -} - -class _EndPreparationButtonSection extends StatelessWidget { final VoidCallback onEndPreparation; - const _EndPreparationButtonSection({required this.onEndPreparation}); @override Widget build(BuildContext context) { - final theme = Theme.of(context); final colorScheme = Theme.of(context).colorScheme; - final baseButtonStyle = theme.elevatedButtonTheme.style; - return Container( - color: Colors.white, - padding: const EdgeInsets.only(top: 16, bottom: 20), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: onEndPreparation, - style: baseButtonStyle?.copyWith( - backgroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) { - return colorScheme.surfaceDim; - } - return colorScheme.primary; - }), - foregroundColor: WidgetStatePropertyAll( - colorScheme.onPrimary, + return ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: ColoredBox( + color: AppColors.white, + child: Column( + children: [ + Expanded( + child: ColoredBox( + color: AppColors.grey.shade50, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 13, 12, 0), + child: PreparationStepListWidget( + preparationSteps: List.from( + preparation.preparationStepList, + ), + currentStepIndex: preparation.resolvedCurrentStepIndex, + stepElapsedTimes: preparation.stepElapsedTimesInSeconds, + preparationStepStates: preparation.preparationStepStates, + onSkip: onSkip, + ), + ), + ), + ), + Padding( + padding: EdgeInsets.fromLTRB( + 16, + 5, + 16, + math.max(40, MediaQuery.paddingOf(context).bottom + 12), + ), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: onEndPreparation, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(57), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + elevation: 0, + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + textStyle: Theme.of(context).textTheme.labelLarge!.copyWith( + fontSize: 18, + height: 1.4, + fontWeight: FontWeight.w500, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text(AppLocalizations.of(context)!.finishPreparation), + ), ), ), - child: Text(AppLocalizations.of(context)!.finishPreparation), - ), + ], ), ), ); diff --git a/lib/presentation/alarm/components/alarm_screen_top_section.dart b/lib/presentation/alarm/components/alarm_screen_top_section.dart index e539c433..a134a1dd 100644 --- a/lib/presentation/alarm/components/alarm_screen_top_section.dart +++ b/lib/presentation/alarm/components/alarm_screen_top_section.dart @@ -1,16 +1,11 @@ -import 'package:flutter/material.dart'; +import 'dart:math' as math; +import 'package:flutter/material.dart'; import 'package:on_time_front/presentation/alarm/components/alarm_graph_animator.dart'; +import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; import 'package:on_time_front/presentation/shared/utils/time_format.dart'; class AlarmScreenTopSection extends StatelessWidget { - final bool isLate; - final int beforeOutTime; - final String preparationName; - final bool showPreparationName; - final int preparationRemainingTime; - final double progress; - const AlarmScreenTopSection({ super.key, required this.isLate, @@ -21,113 +16,119 @@ class AlarmScreenTopSection extends StatelessWidget { required this.progress, }); - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Column( - children: [ - _BeforeOutTimeText(isLate: isLate, beforeOutTime: beforeOutTime), - _AlarmGraphSection( - preparationName: preparationName, - showPreparationName: showPreparationName, - preparationRemainingTime: preparationRemainingTime, - progress: progress, - highlightColor: colorScheme.primaryContainer, - graphBackgroundColor: isLate - ? colorScheme.primaryContainer - : colorScheme.onPrimaryContainer.withValues(alpha: 0.35), - graphProgressColor: colorScheme.primaryContainer, - ), - ], - ); - } -} - -class _BeforeOutTimeText extends StatelessWidget { final bool isLate; final int beforeOutTime; - - const _BeforeOutTimeText({required this.isLate, required this.beforeOutTime}); - - @override - Widget build(BuildContext context) { - final overdueText = formatTime(beforeOutTime.abs()); - return Padding( - padding: const EdgeInsets.only(top: 75), - child: Text( - isLate - ? '준비시간을 $overdueText 초과했어요' - : '${formatTime(beforeOutTime)} 뒤에 나가야 해요', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ); - } -} - -class _AlarmGraphSection extends StatelessWidget { final String preparationName; final bool showPreparationName; final int preparationRemainingTime; final double progress; - final Color highlightColor; - final Color graphBackgroundColor; - final Color graphProgressColor; - - const _AlarmGraphSection({ - required this.preparationName, - required this.showPreparationName, - required this.preparationRemainingTime, - required this.progress, - required this.highlightColor, - required this.graphBackgroundColor, - required this.graphProgressColor, - }); @override Widget build(BuildContext context) { - return SizedBox( - height: 190, - child: Stack( - alignment: Alignment.center, - children: [ - AlarmGraphAnimator( - progress: progress, - backgroundColor: graphBackgroundColor, - progressColor: graphProgressColor, - ), - Padding( - padding: const EdgeInsets.only(top: 100), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (showPreparationName) ...[ - Text( - preparationName, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: highlightColor, + final foreground = isLate ? AppColors.red.shade900 : AppColors.white; + final titleColor = isLate + ? AppColors.grey.shade700 + : AppColors.blue.shade200; + final textScale = MediaQuery.textScalerOf(context).scale(1); + final topPadding = math.max( + 24.0, + math.min(67.0, MediaQuery.paddingOf(context).top + 23), + ); + return LayoutBuilder( + builder: (context, constraints) => Padding( + padding: EdgeInsets.fromLTRB(16, topPadding, 16, 36), + child: Column( + children: [ + Text( + isLate + ? '준비 시간을 ${formatTime(beforeOutTime.abs())} 초과했어요' + : '${formatTime(beforeOutTime)} 뒤에 나가야 돼요', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: textScale > 1.3 ? 20 : 24, + height: 1.4, + fontWeight: FontWeight.w500, + color: foreground, + ), + ), + const SizedBox(height: 20), + Expanded( + child: LayoutBuilder( + builder: (context, ringConstraints) { + final diameter = math.min( + 268.0, + math.min( + ringConstraints.maxWidth, + ringConstraints.maxHeight, ), - ), - const SizedBox(height: 8), - ], - Text( - formatTimeTimer(preparationRemainingTime), - style: TextStyle( - fontSize: 35, - color: highlightColor, - fontWeight: FontWeight.bold, - ), - ), - ], + ); + return Center( + child: SizedBox.square( + dimension: diameter, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: AlarmGraphAnimator( + progress: progress, + backgroundColor: isLate + ? AppColors.red.shade100 + : AppColors.blue.shade700, + progressColor: foreground, + ), + ), + Padding( + padding: const EdgeInsets.all(32), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (showPreparationName) ...[ + Text( + preparationName, + style: TextStyle( + fontSize: 28, + height: 1.4, + fontWeight: FontWeight.w700, + color: titleColor, + ), + ), + const SizedBox(height: 8), + ], + Text( + _countdown(preparationRemainingTime), + style: TextStyle( + fontSize: 48, + height: 1.4, + fontWeight: FontWeight.w500, + color: foreground, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + }, + ), ), - ), - ], + ], + ), ), ); } + + String _countdown(int seconds) { + final value = seconds.abs(); + final minutes = value ~/ 60; + final remainder = (value % 60).toString().padLeft(2, '0'); + if (minutes >= 60) { + return '${minutes ~/ 60}:${(minutes % 60).toString().padLeft(2, '0')}:$remainder'; + } + return '$minutes:$remainder'; + } } diff --git a/lib/presentation/alarm/components/preparation_completion_dialog.dart b/lib/presentation/alarm/components/preparation_completion_dialog.dart index 31f31df1..2dc587db 100644 --- a/lib/presentation/alarm/components/preparation_completion_dialog.dart +++ b/lib/presentation/alarm/components/preparation_completion_dialog.dart @@ -8,16 +8,28 @@ Future showPreparationCompletionDialog({ required bool isLate, required VoidCallback onFinish, VoidCallback? onContinue, + bool manual = false, }) async { final l10n = AppLocalizations.of(context)!; final result = await showTwoActionDialog( context, config: TwoActionDialogConfig( - title: isLate ? l10n.areYouRunningLate : l10n.preparationCompletedTitle, - description: isLate + title: manual + ? l10n.finishPreparationConfirmTitle + : isLate + ? l10n.areYouRunningLate + : l10n.preparationCompletedTitle, + description: manual + ? l10n.finishPreparationConfirmDescription + : isLate ? l10n.runningLateDescription : l10n.preparationCompletedDescription, barrierDismissible: false, + useSafeArea: false, + barrierColor: Colors.black.withValues(alpha: 0.6), + innerPadding: const EdgeInsets.all(16), + titleContentSpacing: 12, + contentActionsSpacing: 12, secondaryAction: DialogActionConfig( label: l10n.continuePreparing, variant: ModalWideButtonVariant.neutral, diff --git a/lib/presentation/alarm/components/preparation_step_list_widget.dart b/lib/presentation/alarm/components/preparation_step_list_widget.dart index db94d7d5..6378af9d 100644 --- a/lib/presentation/alarm/components/preparation_step_list_widget.dart +++ b/lib/presentation/alarm/components/preparation_step_list_widget.dart @@ -35,31 +35,28 @@ class _PreparationStepListWidgetState extends State { for (int i = 0; i < widget.preparationSteps.length; i++) { _tileKeys[i] = GlobalKey(); } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _scrollToCurrentStep(widget.currentStepIndex); + }); } @override void didUpdateWidget(covariant PreparationStepListWidget oldWidget) { super.didUpdateWidget(oldWidget); + for (int i = 0; i < widget.preparationSteps.length; i++) { + _tileKeys.putIfAbsent(i, GlobalKey.new); + } if (oldWidget.currentStepIndex != widget.currentStepIndex) { _scrollToCurrentStep(widget.currentStepIndex); } } Future _scrollToCurrentStep(int currentStepIndex) async { - if (currentStepIndex > 1) { - final key = _tileKeys[currentStepIndex - 1]; + if (currentStepIndex > 0) { + final key = _tileKeys[currentStepIndex]; if (key?.currentContext != null) { - final RenderBox box = - key!.currentContext!.findRenderObject() as RenderBox; - final double targetOffset = - box.localToGlobal(Offset.zero).dy + - _scrollController.offset - - (MediaQuery.of(context).size.height / 2) + - (box.size.height / 2) - - 50; - - await _scrollController.animateTo( - targetOffset, + await Scrollable.ensureVisible( + key!.currentContext!, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); @@ -67,13 +64,21 @@ class _PreparationStepListWidgetState extends State { } } + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return Center( + return Align( + alignment: Alignment.topLeft, child: SizedBox( - width: 329, + width: 358, child: ListView.builder( controller: _scrollController, + padding: EdgeInsets.zero, itemCount: widget.preparationSteps.length, itemBuilder: (context, index) { final preparation = widget.preparationSteps[index]; @@ -87,6 +92,10 @@ class _PreparationStepListWidgetState extends State { ), isLastItem: index == widget.preparationSteps.length - 1, stepElapsedTime: widget.stepElapsedTimes[index], + stepRemainingTime: + (preparation.preparationTime.inSeconds - + widget.stepElapsedTimes[index]) + .clamp(0, preparation.preparationTime.inSeconds), preparationStepState: widget.preparationStepStates[index], onSkip: () => widget.onSkip(), ); diff --git a/lib/presentation/alarm/components/preparation_step_tile.dart b/lib/presentation/alarm/components/preparation_step_tile.dart index 41c824bc..e25abd6a 100644 --- a/lib/presentation/alarm/components/preparation_step_tile.dart +++ b/lib/presentation/alarm/components/preparation_step_tile.dart @@ -1,17 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:dotted_line/dotted_line.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; import 'package:on_time_front/presentation/shared/constants/constants.dart'; import 'package:on_time_front/presentation/shared/utils/time_format.dart'; class PreparationStepTile extends StatelessWidget { - final int stepIndex; - final String preparationName; - final String preparationTime; - final bool isLastItem; - final VoidCallback? onSkip; - final int stepElapsedTime; - final PreparationStateEnum preparationStepState; - const PreparationStepTile({ super.key, required this.stepIndex, @@ -20,59 +13,70 @@ class PreparationStepTile extends StatelessWidget { required this.isLastItem, this.onSkip, required this.stepElapsedTime, + this.stepRemainingTime, required this.preparationStepState, }); + final int stepIndex; + final String preparationName; + final String preparationTime; + final bool isLastItem; + final VoidCallback? onSkip; + final int stepElapsedTime; + final int? stepRemainingTime; + final PreparationStateEnum preparationStepState; + @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - String displayTime; - if (preparationStepState == PreparationStateEnum.yet) { - displayTime = preparationTime; - } else { - displayTime = formatElapsedTime(stepElapsedTime); - } - - Widget circleContent = (preparationStepState == PreparationStateEnum.done) - ? Icon(Icons.check, color: colorScheme.primary) - : Text( - '$stepIndex', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: colorScheme.onPrimaryContainer, - ), + final colors = Theme.of(context).colorScheme; + final active = preparationStepState == PreparationStateEnum.now; + final done = preparationStepState == PreparationStateEnum.done; + final largeText = MediaQuery.textScalerOf(context).scale(1) > 1.3; + final displayTime = preparationStepState == PreparationStateEnum.yet + ? preparationTime + : formatElapsedTime( + active ? stepRemainingTime ?? stepElapsedTime : stepElapsedTime, ); - - Widget? skipButton; - if (preparationStepState == PreparationStateEnum.now && onSkip != null) { - skipButton = Align( - alignment: Alignment.centerRight, - child: SizedBox( - width: 326, - height: 53, - child: TextButton( - style: TextButton.styleFrom( - backgroundColor: colorScheme.primaryContainer, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - onPressed: onSkip, - child: Text( - '이 단계 건너 뛰기', - style: TextStyle( - color: colorScheme.onPrimaryContainer, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), + final time = Text( + displayTime, + style: TextStyle( + fontSize: 20, + height: 1.4, + fontWeight: FontWeight.w500, + color: colors.primary, + ), + ); + final badge = SizedBox.square( + dimension: 34, + child: Center( + child: Container( + width: done ? 30 : 34, + height: done ? 30 : 34, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: done ? AppColors.green : colors.primaryContainer, ), + child: done + ? SvgPicture.asset( + 'runtime_step_check.svg', + package: 'assets', + semanticsLabel: '완료', + width: 24, + height: 24, + ) + : Text( + '$stepIndex', + style: TextStyle( + fontSize: 20, + height: 1.4, + fontWeight: FontWeight.w500, + color: colors.onPrimaryContainer, + ), + ), ), - ); - } - + ), + ); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -80,80 +84,82 @@ class PreparationStepTile extends StatelessWidget { duration: const Duration(milliseconds: 200), curve: Curves.ease, child: Container( - width: 358, - height: - (preparationStepState == PreparationStateEnum.now && - skipButton != null) - ? 135 - : 62, + width: double.infinity, + constraints: BoxConstraints( + minHeight: active && onSkip != null ? 135 : 62, + ), decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(10)), - border: (preparationStepState == PreparationStateEnum.now) - ? Border.all(color: colorScheme.primary, width: 2) + color: AppColors.white, + borderRadius: BorderRadius.circular(8), + border: active + ? Border.all(color: colors.primary, width: 2) : null, - color: Colors.white, ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - children: [ - Row( - children: [ - Stack( - alignment: Alignment.center, - children: [ - Container( - width: 34, - height: 34, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: colorScheme.primaryContainer, - ), - ), - circleContent, - ], - ), - const SizedBox(width: 20), - Expanded( - child: Text( - preparationName, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - overflow: TextOverflow.ellipsis, + padding: EdgeInsets.symmetric( + horizontal: active ? 14 : 16, + vertical: active ? 12 : 14, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + badge, + const SizedBox(width: 18), + Expanded( + child: Text( + preparationName, + style: const TextStyle( + fontSize: 20, + height: 1.4, + fontWeight: FontWeight.w500, ), ), - Text( - displayTime, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: colorScheme.primary, + ), + if (!largeText) ...[const SizedBox(width: 8), time], + ], + ), + if (largeText) + Align(alignment: Alignment.centerRight, child: time), + if (active && onSkip != null) ...[ + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: onSkip, + style: TextButton.styleFrom( + minimumSize: const Size.fromHeight(53), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + backgroundColor: colors.primaryContainer, + foregroundColor: colors.onPrimaryContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), + textStyle: Theme.of(context).textTheme.labelLarge! + .copyWith( + fontSize: 18, + height: 1.4, + fontWeight: FontWeight.w500, + ), + ), + child: const Text( + '이 단계 건너 뛰기', + textAlign: TextAlign.center, ), - ], + ), ), - if (skipButton != null) ...[ - const SizedBox(height: 20), - skipButton, - ], ], - ), + ], ), ), ), if (!isLastItem) Padding( - padding: const EdgeInsets.only(left: 30), - child: DottedLine( - direction: Axis.vertical, - lineLength: 23, - lineThickness: 3, - dashColor: colorScheme.primary, - dashLength: 4, - dashGapLength: 5, - ), + padding: const EdgeInsets.only(left: 33), + child: Container(width: 2, height: 14, color: colors.primary), ), ], ); diff --git a/lib/presentation/alarm/screens/alarm_screen.dart b/lib/presentation/alarm/screens/alarm_screen.dart index da9b987b..1d4ed453 100644 --- a/lib/presentation/alarm/screens/alarm_screen.dart +++ b/lib/presentation/alarm/screens/alarm_screen.dart @@ -11,6 +11,7 @@ import 'package:on_time_front/presentation/alarm/components/alarm_screen_bottom_ import 'package:on_time_front/presentation/alarm/components/alarm_screen_top_section.dart'; import 'package:on_time_front/presentation/alarm/components/preparation_completion_dialog.dart'; import 'package:on_time_front/presentation/shared/router/route_arguments.dart'; +import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; import 'package:on_time_front/presentation/shared/utils/time_format.dart'; class AlarmScreen extends StatefulWidget { @@ -23,9 +24,10 @@ class AlarmScreen extends StatefulWidget { } class _AlarmScreenState extends State { - static const _lateContinuePrimary = Color(0xFFFF6953); + static const _lateContinuePrimary = Color(0xFFBF2E22); static const _lateContinuePrimaryContainer = Color(0xFFFFEAE7); bool _hasShownCompletionDialog = false; + bool _isCompletionDialogOpen = false; bool _isContinuingAfterCompletion = false; bool _navigateAfterFinish = false; bool _didNavigateForNotExistsTransition = false; @@ -60,16 +62,81 @@ class _AlarmScreenState extends State { return schedule.isLateAt(widget.nowProvider()); } - ThemeData _buildAlarmTheme(BuildContext context, bool isLateContinueMode) { - if (!isLateContinueMode) { - return Theme.of(context); + ScheduleWithPreparationEntity? _activeDialogSchedule(String scheduleId) { + if (!mounted) return null; + final state = context.read().state; + if ((state.status != ScheduleStatus.ongoing && + state.status != ScheduleStatus.started) || + state.schedule?.id != scheduleId) { + return null; } + return state.schedule; + } + + void _requestCompletionDialog( + BuildContext context, + ScheduleWithPreparationEntity schedule, { + bool manual = false, + }) { + if (_isCompletionDialogOpen || !mounted) return; + // Reserve the slot before scheduling the automatic dialog after this frame. + _isCompletionDialogOpen = true; + if (!manual) _hasShownCompletionDialog = true; + + if (manual) { + unawaited(_presentCompletionDialog(context, schedule, manual: true)); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(_presentCompletionDialog(context, schedule)); + }); + } + } + + Future _presentCompletionDialog( + BuildContext context, + ScheduleWithPreparationEntity schedule, { + bool manual = false, + }) async { + try { + if (_activeDialogSchedule(schedule.id) == null) return; + await showPreparationCompletionDialog( + context: context, + manual: manual, + isLate: _isLate(schedule), + onFinish: () { + final current = _activeDialogSchedule(schedule.id); + if (current == null) return; + final remaining = _timeRemainingBeforeLeaving(current); + _onPreparationFinished(context, remaining, remaining.isNegative); + }, + onContinue: () { + final current = _activeDialogSchedule(schedule.id); + if (current == null || !current.preparation.isAllStepsDone) return; + // A manual prompt can outlive the last timer tick. Its Continue choice + // also acknowledges completion, so do not immediately prompt again. + _hasShownCompletionDialog = true; + _isContinuingAfterCompletion = true; + }, + ); + } finally { + if (mounted) { + // Rebuild against the latest timer state after either prompt closes. + setState(() => _isCompletionDialogOpen = false); + } + } + } + ThemeData _buildAlarmTheme(BuildContext context, bool isLate) { final theme = Theme.of(context); final colorScheme = theme.colorScheme.copyWith( - primary: _lateContinuePrimary, - primaryContainer: _lateContinuePrimaryContainer, - onPrimaryContainer: _lateContinuePrimary, + primary: isLate ? _lateContinuePrimary : AppColors.blue.shade600, + primaryContainer: isLate + ? _lateContinuePrimaryContainer + : AppColors.blue.shade200, + onPrimaryContainer: isLate + ? _lateContinuePrimary + : AppColors.blue.shade900, ); return theme.copyWith(colorScheme: colorScheme); @@ -165,30 +232,10 @@ class _AlarmScreenState extends State { _resetCompletionUiState(); } - if (preparation.isAllStepsDone && !_hasShownCompletionDialog) { - _hasShownCompletionDialog = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - showPreparationCompletionDialog( - context: context, - isLate: _isLate(schedule), - onFinish: () { - final timeRemainingBeforeLeaving = - _timeRemainingBeforeLeaving(schedule); - _onPreparationFinished( - context, - timeRemainingBeforeLeaving, - timeRemainingBeforeLeaving.isNegative, - ); - }, - onContinue: () { - if (!mounted) return; - setState(() { - _isContinuingAfterCompletion = true; - }); - }, - ); - }); + if (preparation.isAllStepsDone && + !_hasShownCompletionDialog && + !_navigateAfterFinish) { + _requestCompletionDialog(context, schedule); } _ensureUiTicker( @@ -251,7 +298,7 @@ class _AlarmScreenState extends State { _ensureUiTicker(false); } - final alarmTheme = _buildAlarmTheme(context, isLateContinueMode); + final alarmTheme = _buildAlarmTheme(context, isLate); return Theme( key: const ValueKey('alarm_screen_theme'), @@ -259,53 +306,66 @@ class _AlarmScreenState extends State { child: Builder( builder: (context) { return Scaffold( - backgroundColor: Theme.of(context).colorScheme.primary, - body: Stack( - children: [ - Column( - children: [ - AlarmScreenTopSection( - isLate: isLate, - beforeOutTime: timeRemainingBeforeLeaving.inSeconds, - preparationName: timerLabel, - showPreparationName: true, - preparationRemainingTime: displayRemainingSeconds, - progress: displayProgress, - ), - const SizedBox(height: 110), - Expanded( - child: AlarmScreenBottomSection( - preparation: preparation, - onSkip: () { - context.read().add( - const ScheduleStepSkipped(), - ); - }, - onEndPreparation: () => _onPreparationFinished( - context, - timeRemainingBeforeLeaving, - isLate, + backgroundColor: isLate ? AppColors.red.shade50 : AppColors.blue, + body: LayoutBuilder( + builder: (context, constraints) => Stack( + children: [ + Column( + children: [ + SizedBox( + height: (constraints.maxHeight * 426 / 844).clamp( + 220.0, + 426.0, + ), + child: AlarmScreenTopSection( + isLate: isLate, + beforeOutTime: timeRemainingBeforeLeaving.inSeconds, + preparationName: timerLabel, + showPreparationName: true, + preparationRemainingTime: displayRemainingSeconds, + progress: displayProgress, ), ), - ), - ], - ), - Positioned( - top: 0, - right: 0, - child: SafeArea( - minimum: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.only(right: 4), - child: IconButton( - key: const Key('alarm_close_button'), - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => context.go('/home'), + Expanded( + child: AlarmScreenBottomSection( + preparation: preparation, + onSkip: () { + context.read().add( + const ScheduleStepSkipped(), + ); + }, + onEndPreparation: () => _requestCompletionDialog( + context, + schedule, + manual: true, + ), + ), + ), + ], + ), + Positioned( + top: 0, + right: 0, + child: SafeArea( + minimum: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: IconButton( + key: const Key('alarm_close_button'), + tooltip: AppLocalizations.of(context)!.home, + icon: Icon( + Icons.close, + color: isLate + ? AppColors.red.shade900 + : AppColors.white, + ), + onPressed: () => context.go('/home'), + ), ), ), ), - ), - ], + ], + ), ), ); }, diff --git a/lib/presentation/calendar/component/schedule_detail.dart b/lib/presentation/calendar/component/schedule_detail.dart index 00b6373e..ba273966 100644 --- a/lib/presentation/calendar/component/schedule_detail.dart +++ b/lib/presentation/calendar/component/schedule_detail.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + 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'; @@ -27,7 +29,7 @@ class _SwipeActionContent extends StatelessWidget { child: SizedBox.expand( child: Container( decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), color: color, ), alignment: Alignment.center, @@ -56,6 +58,7 @@ class ScheduleDetail extends StatefulWidget { required this.schedule, this.preparationTime, this.isEarlyStarted = false, + this.referenceDate, this.onDeleted, this.onEdit, }); @@ -63,8 +66,9 @@ class ScheduleDetail extends StatefulWidget { final ScheduleEntity schedule; final Duration? preparationTime; final bool isEarlyStarted; + final DateTime? referenceDate; final VoidCallback? onEdit; - final VoidCallback? onDeleted; + final FutureOr Function()? onDeleted; final meatballsIcon = SvgPicture.asset('meatballs.svg', package: 'assets'); @@ -73,12 +77,10 @@ class ScheduleDetail extends StatefulWidget { } class _ScheduleDetailState extends State { - static const double _actionWidth = 96.0; - static const EdgeInsets _trailingActionMargin = EdgeInsets.only( - left: 4.0, - top: 4.0, - bottom: 4.0, - ); + bool _expanded = false; + + DateTime get _now => widget.referenceDate ?? DateTime.now(); + double get _actionGap => _expanded ? 10 : 8; String get _scheduleRenderKey => [ widget.schedule.id, @@ -92,17 +94,19 @@ class _ScheduleDetailState extends State { @override Widget build(BuildContext context) { - return SwipeActionCell( - key: ValueKey(_scheduleRenderKey), - backgroundColor: Colors.transparent, - trailingActions: _buildSwipeActions(context), - child: _buildScheduleContent(context), + return ClipRRect( + borderRadius: BorderRadius.circular(16), + child: SwipeActionCell( + key: ValueKey(_scheduleRenderKey), + backgroundColor: Colors.transparent, + trailingActions: _buildSwipeActions(context), + child: _buildScheduleContent(context), + ), ); } List _buildSwipeActions(BuildContext context) { - final theme = Theme.of(context); - final now = DateTime.now(); + final now = _now; final canEdit = widget.schedule.doneStatus == ScheduleDoneStatus.notEnded && !_hasPreparationStarted(now) && @@ -111,21 +115,21 @@ class _ScheduleDetailState extends State { return [ if (canDelete) SwipeAction( - widthSpace: _actionWidth, + widthSpace: _expanded ? 92 : 94, onTap: (handler) async { - await handler(false); - widget.onDeleted?.call(); + await widget.onDeleted?.call(); + if (mounted) await handler(false); }, color: Colors.transparent, content: _SwipeActionContent( icon: const _TrashCanSvg(), - color: theme.colorScheme.error, - margin: _trailingActionMargin, + color: const Color(0xffbf2e22), + margin: EdgeInsets.only(left: _actionGap, right: _expanded ? 0 : 4), ), ), if (canEdit) SwipeAction( - widthSpace: _actionWidth, + widthSpace: _expanded ? 92 : 90, onTap: (handler) async { await handler(false); widget.onEdit?.call(); @@ -133,8 +137,8 @@ class _ScheduleDetailState extends State { color: Colors.transparent, content: _SwipeActionContent( icon: const _EditPencilSvg(), - color: theme.colorScheme.outline, - margin: _trailingActionMargin, + color: const Color(0xff545454), + margin: EdgeInsets.only(left: _actionGap), ), ), ]; @@ -160,219 +164,181 @@ class _ScheduleDetailState extends State { Widget _buildScheduleContent(BuildContext context) { final theme = Theme.of(context); - return Container( - decoration: BoxDecoration( - color: theme.colorScheme.surface, - borderRadius: BorderRadius.circular(16), - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20.0), - child: _ScheduleTimeColumn( - scheduleTime: widget.schedule.scheduleTime, + final schedule = widget.schedule; + final canEdit = + schedule.doneStatus == ScheduleDoneStatus.notEnded && + !_hasPreparationStarted(_now) && + !schedule.occurrenceInstantUtc.isBefore(_now.toUtc()); + final canDelete = schedule.doneStatus == ScheduleDoneStatus.notEnded; + final textScale = MediaQuery.textScalerOf(context).scale(1); + final sourceLayout = textScale <= 1.01 && !schedule.isRecurring; + final details = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + schedule.scheduleName, + style: theme.textTheme.titleLarge?.copyWith( + fontSize: 20, + height: 1.4, + fontWeight: FontWeight.w600, + ), + maxLines: sourceLayout ? 1 : null, + overflow: sourceLayout ? TextOverflow.ellipsis : null, + ), + const SizedBox(height: 4), + Row( + children: [ + const SizedBox(width: 18, height: 19, child: _MapPinFillSvg()), + const SizedBox(width: 4), + Expanded( + child: Text( + schedule.place.placeName, + style: theme.textTheme.bodySmall?.copyWith( + fontSize: 13, + height: 1.4, + color: theme.colorScheme.outline, ), + maxLines: sourceLayout ? 1 : null, + overflow: sourceLayout ? TextOverflow.ellipsis : null, ), - const _VerticalDivider(), - Expanded( - child: _ScheduleDetailsColumn( - 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, + ), + ], + ), + if (schedule.isRecurring) ...[ + Text( + recurrenceText(context, '반복 일정', 'Recurring schedule'), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + if (!schedule.isStarted && + canDelete && + schedule.occurrenceInstantUtc.isBefore(_now.toUtc())) + Text( + recurrenceText(context, '진행 기록 없음', 'No preparation recorded'), + style: theme.textTheme.bodySmall, + ), + ], + if (_expanded) ...[ + const SizedBox(height: 16), + 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: canEdit ? widget.onEdit : null, + onDelete: canDelete ? widget.onDeleted : null, + ), ), ), - ], + child: Text( + recurrenceText(context, '이번 회차 보기', 'View occurrence'), + ), + ), + _ScheduleInfoTile( + label: AppLocalizations.of(context)!.travelTime, + value: formatDuration(context, schedule.moveTime), ), - ), - ), - ); - } -} - -class _ScheduleTimeColumn extends StatelessWidget { - const _ScheduleTimeColumn({required this.scheduleTime}); - - final DateTime scheduleTime; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final locale = AppLocalizations.of(context)!.localeName; - return Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - DateFormat.Hm(locale).format(scheduleTime), - style: theme.textTheme.titleSmall, - textAlign: TextAlign.center, - ), - const SizedBox(height: 4), - Text( - DateFormat('a', 'en').format(scheduleTime), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.outline, + const SizedBox(height: 8), + _ScheduleInfoTile( + label: AppLocalizations.of(context)!.preparationTime, + value: widget.preparationTime == null + ? '-' + : formatDuration(context, widget.preparationTime!), ), - textAlign: TextAlign.center, - ), + const SizedBox(height: 8), + _ScheduleInfoTile( + label: AppLocalizations.of(context)!.spareTime, + value: formatDuration( + context, + schedule.scheduleSpareTime ?? Duration.zero, + ), + ), + ], ], ); - } -} - -class _ScheduleDetailsColumn extends StatelessWidget { - const _ScheduleDetailsColumn({ - 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; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 8.0), - child: Theme( - data: Theme.of(context).copyWith( - visualDensity: const VisualDensity( - vertical: VisualDensity.minimumDensity, - ), - listTileTheme: const ListTileThemeData( - dense: true, - minVerticalPadding: 0, - contentPadding: EdgeInsets.zero, - ), - ), - child: IconTheme( - data: IconTheme.of(context).copyWith(size: 32), - child: ExpansionTile( - shape: const Border(), - collapsedShape: const Border(), - iconColor: - theme.colorScheme.onSurfaceVariant, // Color when expanded - // icon size provided by IconTheme above - collapsedIconColor: - theme.colorScheme.onSurfaceVariant, // Color when collapsed - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - schedule.scheduleName, - style: theme.textTheme.titleLarge, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (schedule.isRecurring) - Text( - recurrenceText(context, '반복 일정', 'Recurring schedule'), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.primary, - ), - ), - if (schedule.isRecurring && - !schedule.isStarted && - schedule.doneStatus == ScheduleDoneStatus.notEnded && - schedule.occurrenceInstantUtc.isBefore( - DateTime.now().toUtc(), - )) - Text( - recurrenceText( - context, - '진행 기록 없음', - 'No preparation recorded', - ), - style: theme.textTheme.bodySmall, - ), - ], + return Material( + key: ValueKey('schedule_card_${schedule.id}'), + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: Semantics( + button: true, + expanded: _expanded, + child: InkWell( + onTap: () => setState(() => _expanded = !_expanded), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: _expanded ? 192 : 82, + maxHeight: sourceLayout + ? (_expanded ? 192 : 82) + : double.infinity, ), - childrenPadding: const EdgeInsets.only(top: 16.0), - subtitle: Padding( - padding: const EdgeInsets.only(top: 4.0), + child: IntrinsicHeight( child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const _MapPinFillSvg(), - const SizedBox(width: 4), - Expanded( - child: Text( - placeName, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.outline, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - 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, + SizedBox( + width: 70, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + DateFormat( + 'h:mm', + 'en', + ).format(schedule.scheduleTime), + style: theme.textTheme.titleSmall?.copyWith( + fontSize: 16, + height: 1.4, + ), + textAlign: TextAlign.center, + ), + Text( + DateFormat('a', 'en').format(schedule.scheduleTime), + style: theme.textTheme.bodySmall?.copyWith( + fontSize: 13, + height: 1.4, + color: theme.colorScheme.outline, + ), + ), + ], ), ), ), - child: Text( - recurrenceText(context, '이번 회차 보기', 'View occurrence'), - ), - ), - Column( - children: [ - _ScheduleInfoTile( - label: AppLocalizations.of(context)!.travelTime, - value: formatDuration(context, schedule.moveTime), - ), - _ScheduleInfoTile( - label: AppLocalizations.of(context)!.preparationTime, - value: preparationTime == null - ? '-' - : formatDuration(context, preparationTime!), + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: _VerticalDivider(), ), - _ScheduleInfoTile( - label: AppLocalizations.of(context)!.spareTime, - value: formatDuration( - context, - schedule.scheduleSpareTime ?? Duration.zero, + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(21, 15, 12, 15), + child: Row( + children: [ + Expanded(child: details), + const SizedBox(width: 8), + RotatedBox( + quarterTurns: _expanded ? 2 : 0, + child: SvgPicture.asset( + 'calendar_chevron_down.svg', + package: 'assets', + ), + ), + ], + ), ), ), ], ), - ], + ), ), ), ), @@ -382,41 +348,27 @@ class _ScheduleDetailsColumn extends StatelessWidget { class _ScheduleInfoTile extends StatelessWidget { const _ScheduleInfoTile({required this.label, required this.value}); - final String label; final String value; @override Widget build(BuildContext context) { final theme = Theme.of(context); - final textTheme = theme.textTheme; - final colorScheme = theme.colorScheme; - return Column( + final style = theme.textTheme.bodySmall?.copyWith( + fontSize: 13, + height: 1.4, + ); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - width: double.infinity, - height: 22.18, - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 18, - children: [ - Text( - label, - style: textTheme.bodyMedium?.copyWith( - color: colorScheme.outline, - ), - ), - Text( - value, - style: textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurface, - ), - ), - ], + Flexible( + child: Text( + label, + style: style?.copyWith(color: theme.colorScheme.outline), ), ), + const SizedBox(width: 18), + Flexible(child: Text(value, style: style)), ], ); } @@ -427,7 +379,7 @@ class _MapPinFillSvg extends StatelessWidget { @override Widget build(BuildContext context) { - return SvgPicture.asset('map_pin_fill.svg', package: 'assets'); + return SvgPicture.asset('calendar_map_pin.svg', package: 'assets'); } } @@ -436,7 +388,7 @@ class _TrashCanSvg extends StatelessWidget { @override Widget build(BuildContext context) { - return SvgPicture.asset('trash_can.svg', package: 'assets'); + return SvgPicture.asset('calendar_trash.svg', package: 'assets'); } } @@ -445,6 +397,6 @@ class _EditPencilSvg extends StatelessWidget { @override Widget build(BuildContext context) { - return SvgPicture.asset('edit_pencil.svg', package: 'assets'); + return SvgPicture.asset('calendar_edit.svg', package: 'assets'); } } diff --git a/lib/presentation/calendar/screens/calendar_screen.dart b/lib/presentation/calendar/screens/calendar_screen.dart index cc53c716..7ed76f6f 100644 --- a/lib/presentation/calendar/screens/calendar_screen.dart +++ b/lib/presentation/calendar/screens/calendar_screen.dart @@ -4,6 +4,7 @@ import 'package:on_time_front/presentation/recurring/recurrence_scope_sheet.dart import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; @@ -27,9 +28,15 @@ typedef CalendarCreateSheetBuilder = Widget Function(BuildContext context, DateTime initialDate); class CalendarScreen extends StatefulWidget { - const CalendarScreen({super.key, this.initialDate, this.createSheetBuilder}); + const CalendarScreen({ + super.key, + this.initialDate, + this.referenceDate, + this.createSheetBuilder, + }); final DateTime? initialDate; + final DateTime? referenceDate; final CalendarCreateSheetBuilder? createSheetBuilder; @override @@ -111,6 +118,12 @@ class _CalendarScreenState extends State { ); } + void _retryVisibleMonth() { + _monthlySchedulesBloc.add( + MonthlySchedulesSubscriptionRequested(date: _selectedDate), + ); + } + void _returnHome() { if (!mounted) { return; @@ -216,6 +229,11 @@ class _CalendarScreenState extends State { final textTheme = theme.textTheme; final colorScheme = theme.colorScheme; final calendarTheme = theme.extension()!; + final viewport = MediaQuery.sizeOf(context); + final sourceLayout = + viewport.width >= 390 && + viewport.height >= 800 && + MediaQuery.textScalerOf(context).scale(1) <= 1.3; return PopScope( canPop: false, @@ -227,19 +245,48 @@ class _CalendarScreenState extends State { child: BlocProvider.value( value: _monthlySchedulesBloc, child: Scaffold( - backgroundColor: colorScheme.surfaceContainerLow, + backgroundColor: const Color(0xfff2f4f6), appBar: AppBar( + toolbarHeight: sourceLayout ? 50 : kToolbarHeight, title: Text(AppLocalizations.of(context)!.calendarTitle), - backgroundColor: colorScheme.surfaceContainerLow, - leading: IconButton( - icon: const Icon(Icons.arrow_back), + centerTitle: true, + backgroundColor: const Color(0xfff2f4f6), + leadingWidth: + (Localizations.localeOf(context).languageCode == 'ko' + ? 80.0 + : 88.0) * + MediaQuery.textScalerOf(context).scale(1), + leading: TextButton( onPressed: _returnHome, + style: TextButton.styleFrom( + foregroundColor: const Color(0xff4f69df), + padding: EdgeInsets.zero, + minimumSize: const Size(80, 44), + ), + child: Row( + children: [ + const SizedBox(width: 18), + SvgPicture.asset('calendar_back.svg', package: 'assets'), + const SizedBox(width: 2), + Text( + AppLocalizations.of(context)!.home, + style: textTheme.bodyMedium?.copyWith( + fontSize: + Localizations.localeOf(context).languageCode == 'ko' + ? 18 + : 14, + fontWeight: FontWeight.w700, + color: const Color(0xff4f69df), + ), + ), + ], + ), ), ), body: Padding( padding: const EdgeInsets.symmetric(horizontal: 18.0) + - EdgeInsets.only(bottom: 12.0), + EdgeInsets.only(top: sourceLayout ? 16 : 0, bottom: 12.0), child: BlocListener( listenWhen: (previous, current) => previous.deleteFailureCount != current.deleteFailureCount, @@ -248,7 +295,9 @@ class _CalendarScreenState extends State { }, child: LayoutBuilder( builder: (context, constraints) { - final detailGap = _calendarDetailGap(constraints.maxHeight); + final detailGap = sourceLayout + ? 32.0 + : _calendarDetailGap(constraints.maxHeight); final selectedDateHeadingGap = _selectedDateHeadingGap( constraints.maxHeight, ); @@ -262,154 +311,246 @@ class _CalendarScreenState extends State { color: colorScheme.surface, ), child: Padding( - padding: const EdgeInsets.symmetric( - vertical: _calendarVerticalPadding, - horizontal: _calendarHorizontalPadding, - ), - child: - BlocBuilder< - MonthlySchedulesBloc, - MonthlySchedulesState - >( - builder: (context, state) { - if (state.status == + padding: + const EdgeInsets.symmetric( + horizontal: _calendarHorizontalPadding, + ) + + EdgeInsets.only( + top: _calendarVerticalPadding, + bottom: sourceLayout + ? 17 + : _calendarVerticalPadding, + ), + child: BlocBuilder( + builder: (context, state) { + if (state.status == + MonthlySchedulesStatus.loading || + state.status == + MonthlySchedulesStatus.initial || + state.status == MonthlySchedulesStatus.error) { - return Text( - AppLocalizations.of(context)!.error, - ); - } - - return TableCalendar( - locale: Localizations.localeOf( - context, - ).toString(), - daysOfWeekHeight: _calendarDaysOfWeekHeight, - rowHeight: _calendarRowHeight, - eventLoader: (day) { - day = DateTime( - day.year, - day.month, - day.day, - ); - return state.schedules[day] ?? []; - }, - focusedDay: _selectedDate, - selectedDayPredicate: (day) => - isSameDay(_selectedDate, day), - firstDay: _firstDay, - lastDay: _lastDay, - calendarFormat: CalendarFormat.month, - headerStyle: calendarTheme.headerStyle, - daysOfWeekStyle: - calendarTheme.daysOfWeekStyle, - calendarStyle: calendarTheme.calendarStyle, - onDaySelected: (selectedDay, focusedDay) { - setState(() { - _selectedDate = _clampDay( - selectedDay, - _firstDay, - _lastDay, - ); - }); - _monthlySchedulesBloc.add( - MonthlySchedulesVisibleDateChanged( - date: _selectedDate, - ), - ); - }, - onPageChanged: (focusedDay) { - final clampedFocusedDay = _clampDay( - focusedDay, - _firstDay, - _lastDay, - ); - - setState(() { - _selectedDate = clampedFocusedDay; - }); - - _monthlySchedulesBloc.add( - MonthlySchedulesVisibleDateChanged( - date: _selectedDate, - ), - ); - - _monthlySchedulesBloc.add( - MonthlySchedulesMonthAdded( - date: DateTime( - clampedFocusedDay.year, - clampedFocusedDay.month, - clampedFocusedDay.day, - ), - ), - ); - }, - calendarBuilders: CalendarBuilders( - headerTitleBuilder: (context, date) { - return CenteredCalendarHeader( - focusedMonth: date, - onLeftArrowTap: _onLeftArrowTap, - onRightArrowTap: _onRightArrowTap, - titleTextStyle: calendarTheme - .headerStyle - .titleTextStyle, - leftIcon: calendarTheme - .headerStyle - .leftChevronIcon, - rightIcon: calendarTheme - .headerStyle - .rightChevronIcon, - ); - }, - markerBuilder: (context, day, events) { - return selectedDayScheduleMarkerBuilder( - selectedDay: _selectedDate, - day: day, - events: events, - ); - }, - selectedBuilder: - (context, day, focusedDay) { - return Container( - margin: const EdgeInsets.all(2.0), - alignment: Alignment.center, - decoration: calendarTheme - .selectedDayDecoration, - child: Text( - DateFormat.d( - Localizations.localeOf( + return SizedBox( + height: sourceLayout ? 361 : 288, + child: Center( + child: + state.status == + MonthlySchedulesStatus.error + ? Transform.translate( + offset: Offset( + 0, + sourceLayout ? -8.5 : 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppLocalizations.of( context, - ).toString(), - ).format(day), - style: calendarTheme - .selectedDayTextStyle, + )!.error, + style: textTheme.titleLarge + ?.copyWith( + fontWeight: + FontWeight.w700, + ), + ), + const SizedBox(height: 16), + ElevatedButton( + key: const Key( + 'calendar_month_retry', + ), + onPressed: _retryVisibleMonth, + style: ElevatedButton.styleFrom( + backgroundColor: + const Color(0xff4f69df), + minimumSize: const Size( + 149, + 44, + ), + maximumSize: const Size( + 149, + 44, + ), + fixedSize: const Size( + 149, + 44, + ), + padding: EdgeInsets.zero, + tapTargetSize: + MaterialTapTargetSize + .shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular( + 8, + ), + ), + ), + child: Text( + AppLocalizations.of( + context, + )!.retry, + style: textTheme.bodyLarge + ?.copyWith( + color: Colors.white, + fontWeight: + FontWeight.w600, + ), + ), + ), + ], + ), + ) + : Transform.translate( + offset: Offset( + 0, + sourceLayout ? -8.5 : 8, + ), + child: const SizedBox( + key: Key( + 'calendar_month_spinner', + ), + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 3, + color: Color(0xff4f69df), ), - ); - }, - todayBuilder: - ( - context, - day, - focusedDay, - ) => Container( - margin: const EdgeInsets.all(2.0), - alignment: Alignment.center, - decoration: - calendarTheme.todayDecoration, - child: Text( - DateFormat.d( - Localizations.localeOf( - context, - ).toString(), - ).format(day), - style: - calendarTheme.todayTextStyle, ), ), + ), + ); + } + + return TableCalendar( + locale: Localizations.localeOf( + context, + ).toString(), + daysOfWeekHeight: _calendarDaysOfWeekHeight, + rowHeight: sourceLayout + ? 51 + : _calendarRowHeight, + eventLoader: (day) { + day = DateTime(day.year, day.month, day.day); + return state.schedules[day] ?? []; + }, + focusedDay: _selectedDate, + selectedDayPredicate: (day) => + isSameDay(_selectedDate, day), + firstDay: _firstDay, + lastDay: _lastDay, + calendarFormat: CalendarFormat.month, + headerStyle: calendarTheme.headerStyle, + daysOfWeekStyle: calendarTheme.daysOfWeekStyle, + calendarStyle: calendarTheme.calendarStyle, + onDaySelected: (selectedDay, focusedDay) { + setState(() { + _selectedDate = _clampDay( + selectedDay, + _firstDay, + _lastDay, + ); + }); + _monthlySchedulesBloc.add( + MonthlySchedulesVisibleDateChanged( + date: _selectedDate, ), ); }, - ), + onPageChanged: (focusedDay) { + final clampedFocusedDay = _clampDay( + focusedDay, + _firstDay, + _lastDay, + ); + + setState(() { + _selectedDate = clampedFocusedDay; + }); + + _monthlySchedulesBloc.add( + MonthlySchedulesVisibleDateChanged( + date: _selectedDate, + ), + ); + + _monthlySchedulesBloc.add( + MonthlySchedulesMonthAdded( + date: DateTime( + clampedFocusedDay.year, + clampedFocusedDay.month, + clampedFocusedDay.day, + ), + ), + ); + }, + calendarBuilders: CalendarBuilders( + headerTitleBuilder: (context, date) { + return Padding( + padding: EdgeInsets.symmetric( + vertical: sourceLayout ? 15 : 0, + ), + child: CenteredCalendarHeader( + focusedMonth: date, + onLeftArrowTap: _onLeftArrowTap, + onRightArrowTap: _onRightArrowTap, + titleTextStyle: calendarTheme + .headerStyle + .titleTextStyle, + leftIcon: calendarTheme + .headerStyle + .leftChevronIcon, + rightIcon: calendarTheme + .headerStyle + .rightChevronIcon, + ), + ); + }, + markerBuilder: (context, day, events) { + return selectedDayScheduleMarkerBuilder( + selectedDay: _selectedDate, + day: day, + events: events, + ); + }, + selectedBuilder: (context, day, focusedDay) { + return Center( + child: Container( + width: 40, + height: 40, + margin: EdgeInsets.zero, + alignment: Alignment.center, + decoration: const BoxDecoration( + color: Color(0xff4f69df), + shape: BoxShape.circle, + ), + child: Text( + day.day.toString(), + style: textTheme.bodyLarge?.copyWith( + color: Colors.white, + ), + ), + ), + ); + }, + todayBuilder: (context, day, focusedDay) => + Container( + margin: const EdgeInsets.all(2.0), + alignment: Alignment.center, + decoration: + calendarTheme.todayDecoration, + child: Text( + DateFormat.d( + Localizations.localeOf( + context, + ).toString(), + ).format(day), + style: calendarTheme.todayTextStyle, + ), + ), + ), + ); + }, + ), ), ), SizedBox(height: detailGap), @@ -440,6 +581,8 @@ class _CalendarScreenState extends State { builder: (context, state) { return _SelectedDateSchedulesContent( selectedDate: _selectedDate, + referenceDate: widget.referenceDate, + sourceLayout: sourceLayout, state: state, onAddSchedule: () => _openCreateScheduleSheet(context), @@ -448,16 +591,17 @@ class _CalendarScreenState extends State { context, schedule: schedule, ), - onDeleteSchedule: (schedule) { + onDeleteSchedule: (schedule) async { if (schedule.isRecurring) { - _deleteRecurring( + await _deleteRecurring( context, schedule, ); return; } - showTwoButtonDeleteDialog( + await showTwoButtonDeleteDialog( context, + sourceCalendarLayout: true, title: AppLocalizations.of( context, )!.scheduleDeleteConfirmTitle, @@ -505,6 +649,8 @@ class _CalendarScreenState extends State { class _SelectedDateSchedulesContent extends StatelessWidget { const _SelectedDateSchedulesContent({ required this.selectedDate, + required this.referenceDate, + required this.sourceLayout, required this.state, required this.onAddSchedule, required this.onEditSchedule, @@ -512,10 +658,12 @@ class _SelectedDateSchedulesContent extends StatelessWidget { }); final DateTime selectedDate; + final DateTime? referenceDate; + final bool sourceLayout; final MonthlySchedulesState state; final VoidCallback onAddSchedule; final ValueChanged onEditSchedule; - final ValueChanged onDeleteSchedule; + final Future Function(ScheduleEntity) onDeleteSchedule; @override Widget build(BuildContext context) { @@ -523,14 +671,16 @@ class _SelectedDateSchedulesContent extends StatelessWidget { if (schedules.isEmpty) { if (state.status == MonthlySchedulesStatus.loading) { - return const Center(child: CircularProgressIndicator()); + return sourceLayout + ? const SizedBox.expand() + : const Center(child: CircularProgressIndicator()); } if (state.status != MonthlySchedulesStatus.success) { return const SizedBox.expand(); } - final now = DateTime.now(); + final now = referenceDate ?? DateTime.now(); final today = DateTime(now.year, now.month, now.day); final selected = DateTime( selectedDate.year, @@ -539,13 +689,15 @@ class _SelectedDateSchedulesContent extends StatelessWidget { ); return _EmptySchedulesView( + sourceLayout: sourceLayout, showAddButton: !selected.isBefore(today), onAddSchedule: onAddSchedule, ); } - return ListView.builder( - padding: EdgeInsets.zero, + return ListView.separated( + padding: EdgeInsets.only(top: sourceLayout ? 6 : 0), + separatorBuilder: (_, _) => const SizedBox(height: 16), itemCount: schedules.length, itemBuilder: (context, index) { final schedule = schedules[index]; @@ -558,6 +710,7 @@ class _SelectedDateSchedulesContent extends StatelessWidget { return ScheduleDetail( schedule: schedule, + referenceDate: referenceDate, preparationTime: state.preparationDurationByScheduleId[schedule.id], isEarlyStarted: isEarlyStarted, @@ -573,10 +726,12 @@ class _SelectedDateSchedulesContent extends StatelessWidget { class _EmptySchedulesView extends StatelessWidget { const _EmptySchedulesView({ + required this.sourceLayout, required this.showAddButton, required this.onAddSchedule, }); + final bool sourceLayout; final bool showAddButton; final VoidCallback onAddSchedule; @@ -585,17 +740,21 @@ class _EmptySchedulesView extends StatelessWidget { final theme = Theme.of(context); final textTheme = theme.textTheme; - return Center( + return Align( + alignment: sourceLayout ? Alignment.topCenter : Alignment.center, child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + padding: EdgeInsets.fromLTRB(16, sourceLayout ? 32 : 12, 16, 12), child: Column( mainAxisSize: MainAxisSize.min, - spacing: 16.0, + spacing: sourceLayout ? 33 : 16, children: [ Text( AppLocalizations.of(context)!.noSchedules, style: textTheme.titleMedium?.copyWith( - color: theme.colorScheme.outlineVariant, + color: sourceLayout + ? theme.colorScheme.onSurface + : theme.colorScheme.outlineVariant, + fontWeight: sourceLayout ? FontWeight.w700 : null, ), textAlign: TextAlign.center, ), @@ -603,23 +762,37 @@ class _EmptySchedulesView extends StatelessWidget { ElevatedButton( onPressed: onAddSchedule, style: ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.surface, - side: BorderSide( - width: 0.5, - color: theme.colorScheme.outlineVariant, - ), - padding: const EdgeInsets.symmetric( - vertical: 4.0, - horizontal: 12.0, + backgroundColor: sourceLayout + ? const Color(0xff4f69df) + : theme.colorScheme.surface, + minimumSize: sourceLayout ? const Size(149, 44) : null, + side: sourceLayout + ? BorderSide.none + : BorderSide( + width: 0.5, + color: theme.colorScheme.outlineVariant, + ), + padding: sourceLayout + ? const EdgeInsets.symmetric(horizontal: 16) + : const EdgeInsets.symmetric(vertical: 4, horizontal: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), ), child: Text( AppLocalizations.of(context)!.addAppointment, maxLines: 1, overflow: TextOverflow.ellipsis, - style: textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), + style: + (sourceLayout + ? textTheme.bodyLarge + : textTheme.bodyMedium) + ?.copyWith( + color: sourceLayout + ? Colors.white + : theme.colorScheme.onSurfaceVariant, + fontWeight: sourceLayout ? FontWeight.w600 : null, + ), ), ), ], diff --git a/lib/presentation/home/components/month_calendar.dart b/lib/presentation/home/components/month_calendar.dart index 009cb74e..c8a83618 100644 --- a/lib/presentation/home/components/month_calendar.dart +++ b/lib/presentation/home/components/month_calendar.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/calendar/bloc/monthly_schedules_bloc.dart'; import 'package:on_time_front/presentation/shared/components/calendar/centered_calendar_header.dart'; import 'package:on_time_front/presentation/shared/components/calendar/schedule_marker_builder.dart'; @@ -10,18 +11,24 @@ class MonthCalendar extends StatefulWidget { const MonthCalendar({ super.key, required this.monthlySchedulesState, + this.initialDate, + this.sixWeekMonthsEnforced = true, this.dispatchBlocEvents = true, this.onDateSelected, this.rowHeight = 50, this.daysOfWeekHeight = 40, + this.headerVerticalPadding = 0, this.contentPadding = const EdgeInsets.all(16.0), }); final MonthlySchedulesState monthlySchedulesState; + final DateTime? initialDate; + final bool sixWeekMonthsEnforced; final bool dispatchBlocEvents; final void Function(DateTime)? onDateSelected; final double rowHeight; final double daysOfWeekHeight; + final double headerVerticalPadding; final EdgeInsetsGeometry contentPadding; @override @@ -49,7 +56,11 @@ class _MonthCalendarState extends State { @override void initState() { super.initState(); - _focusedDay = _clampDay(DateTime.now(), _firstDay, _lastDay); + _focusedDay = _clampDay( + widget.initialDate ?? DateTime.now(), + _firstDay, + _lastDay, + ); _selectedDay = _focusedDay; } @@ -102,108 +113,191 @@ class _MonthCalendarState extends State { verticalPadding: resolvedPadding.vertical, ); - return Container( - padding: widget.contentPadding, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(11)), - child: TableCalendar( - locale: Localizations.localeOf(context).toString(), - eventLoader: (day) { - day = DateTime(day.year, day.month, day.day); - return widget.monthlySchedulesState.schedules[day] ?? []; - }, - sixWeekMonthsEnforced: true, - rowHeight: constrainedRowHeight, - availableGestures: AvailableGestures.none, - focusedDay: _focusedDay, - selectedDayPredicate: (day) => isSameDay(_selectedDay, day), - firstDay: _firstDay, - lastDay: _lastDay, - calendarFormat: CalendarFormat.month, - headerStyle: calendarTheme.headerStyle, - daysOfWeekStyle: calendarTheme.daysOfWeekStyle, - daysOfWeekHeight: widget.daysOfWeekHeight, - calendarStyle: calendarTheme.calendarStyle, - onDaySelected: (selectedDay, focusedDay) { - final clampedSelectedDay = _clampDay( - selectedDay, - _firstDay, - _lastDay, - ); - final clampedFocusedDay = _clampDay( - focusedDay, - _firstDay, - _lastDay, - ); - - setState(() { - _selectedDay = clampedSelectedDay; - _focusedDay = clampedFocusedDay; - }); - - widget.onDateSelected?.call(clampedSelectedDay); - }, - onPageChanged: (focusedDay) { - final clampedFocusedDay = _clampDay( - focusedDay, - _firstDay, - _lastDay, - ); - - setState(() { - _focusedDay = clampedFocusedDay; - }); - - if (widget.dispatchBlocEvents) { - context.read().add( - MonthlySchedulesMonthAdded( - date: DateTime( - clampedFocusedDay.year, - clampedFocusedDay.month, - 1, + final status = widget.monthlySchedulesState.status; + return ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Stack( + children: [ + AbsorbPointer( + absorbing: + status == MonthlySchedulesStatus.loading || + status == MonthlySchedulesStatus.error, + child: Container( + padding: widget.contentPadding, + child: TableCalendar( + locale: Localizations.localeOf(context).toString(), + eventLoader: (day) { + day = DateTime(day.year, day.month, day.day); + return widget.monthlySchedulesState.schedules[day] ?? []; + }, + sixWeekMonthsEnforced: widget.sixWeekMonthsEnforced, + rowHeight: constrainedRowHeight, + availableGestures: AvailableGestures.none, + focusedDay: _focusedDay, + selectedDayPredicate: (day) => isSameDay(_selectedDay, day), + firstDay: _firstDay, + lastDay: _lastDay, + calendarFormat: CalendarFormat.month, + headerStyle: calendarTheme.headerStyle, + daysOfWeekStyle: calendarTheme.daysOfWeekStyle, + daysOfWeekHeight: widget.daysOfWeekHeight, + calendarStyle: calendarTheme.calendarStyle, + onDaySelected: (selectedDay, focusedDay) { + final clampedSelectedDay = _clampDay( + selectedDay, + _firstDay, + _lastDay, + ); + final clampedFocusedDay = _clampDay( + focusedDay, + _firstDay, + _lastDay, + ); + + setState(() { + _selectedDay = clampedSelectedDay; + _focusedDay = clampedFocusedDay; + }); + + widget.onDateSelected?.call(clampedSelectedDay); + }, + onPageChanged: (focusedDay) { + final clampedFocusedDay = _clampDay( + focusedDay, + _firstDay, + _lastDay, + ); + + setState(() { + _focusedDay = clampedFocusedDay; + }); + + if (widget.dispatchBlocEvents) { + context.read().add( + MonthlySchedulesMonthAdded( + date: DateTime( + clampedFocusedDay.year, + clampedFocusedDay.month, + 1, + ), + ), + ); + } + }, + calendarBuilders: CalendarBuilders( + headerTitleBuilder: (context, date) { + return Padding( + padding: EdgeInsets.symmetric( + vertical: widget.headerVerticalPadding, + ), + child: CenteredCalendarHeader( + focusedMonth: date, + onLeftArrowTap: _onLeftArrowTap, + onRightArrowTap: _onRightArrowTap, + titleTextStyle: + calendarTheme.headerStyle.titleTextStyle, + leftIcon: calendarTheme.headerStyle.leftChevronIcon, + rightIcon: + calendarTheme.headerStyle.rightChevronIcon, + ), + ); + }, + markerBuilder: (context, day, events) { + return selectedDayScheduleMarkerBuilder( + selectedDay: _selectedDay, + day: day, + events: events, + ); + }, + selectedBuilder: (context, day, focusedDay) { + return Center( + child: Container( + width: 40, + height: 40, + alignment: Alignment.center, + decoration: const BoxDecoration( + color: Color(0xff4f69df), + shape: BoxShape.circle, + ), + child: Text( + day.day.toString(), + style: theme.textTheme.bodyLarge?.copyWith( + color: Colors.white, + ), + ), + ), + ); + }, + todayBuilder: (context, day, focusedDay) => Container( + margin: const EdgeInsets.all(4.0), + alignment: Alignment.center, + decoration: calendarTheme.todayDecoration, + child: Text( + day.day.toString(), + style: calendarTheme.todayTextStyle, + ), + ), ), ), - ); - } - }, - calendarBuilders: CalendarBuilders( - headerTitleBuilder: (context, date) { - return CenteredCalendarHeader( - focusedMonth: date, - onLeftArrowTap: _onLeftArrowTap, - onRightArrowTap: _onRightArrowTap, - titleTextStyle: calendarTheme.headerStyle.titleTextStyle, - leftIcon: calendarTheme.headerStyle.leftChevronIcon, - rightIcon: calendarTheme.headerStyle.rightChevronIcon, - ); - }, - markerBuilder: (context, day, events) { - return selectedDayScheduleMarkerBuilder( - selectedDay: _selectedDay, - day: day, - events: events, - ); - }, - selectedBuilder: (context, day, focusedDay) { - return Container( - margin: const EdgeInsets.all(4.0), - alignment: Alignment.center, - decoration: calendarTheme.selectedDayDecoration, - child: Text( - day.day.toString(), - style: calendarTheme.selectedDayTextStyle, - ), - ); - }, - todayBuilder: (context, day, focusedDay) => Container( - margin: const EdgeInsets.all(4.0), - alignment: Alignment.center, - decoration: calendarTheme.todayDecoration, - child: Text( - day.day.toString(), - style: calendarTheme.todayTextStyle, ), ), - ), + if (status == MonthlySchedulesStatus.loading) + Positioned.fill( + child: ColoredBox( + key: const Key('home_month_loading_overlay'), + color: const Color(0xB8FFFFFF), + child: Center( + child: SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 3.5, + color: const Color(0xff5c79fb), + ), + ), + ), + ), + ), + if (status == MonthlySchedulesStatus.error) + Positioned.fill( + child: ColoredBox( + key: const Key('home_month_error_overlay'), + color: const Color(0xEBFFFFFF), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppLocalizations.of(context)!.error, + style: theme.textTheme.bodyLarge, + ), + SizedBox( + height: 44, + child: TextButton( + key: const Key('home_month_retry'), + onPressed: widget.dispatchBlocEvents + ? () => context + .read() + .add( + MonthlySchedulesSubscriptionRequested( + date: _focusedDay, + ), + ) + : null, + child: Text( + AppLocalizations.of(context)!.retry, + style: theme.textTheme.bodyMedium?.copyWith( + color: const Color(0xff5c79fb), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], ), ); }, @@ -221,7 +315,13 @@ class _MonthCalendarState extends State { const headerHeight = 64.0; final availableRowsHeight = maxHeight - verticalPadding - headerHeight - widget.daysOfWeekHeight; - final fittedRowHeight = availableRowsHeight / 6; + final firstWeekday = + DateTime(_focusedDay.year, _focusedDay.month, 1).weekday % 7; + final dayCount = DateTime(_focusedDay.year, _focusedDay.month + 1, 0).day; + final rowCount = widget.sixWeekMonthsEnforced + ? 6 + : (firstWeekday + dayCount + 6) ~/ 7; + final fittedRowHeight = availableRowsHeight / rowCount; return fittedRowHeight.clamp(24.0, 56.0).toDouble(); } diff --git a/lib/presentation/home/components/todays_schedule_tile.dart b/lib/presentation/home/components/todays_schedule_tile.dart index b9caba31..7d043fe9 100644 --- a/lib/presentation/home/components/todays_schedule_tile.dart +++ b/lib/presentation/home/components/todays_schedule_tile.dart @@ -2,8 +2,9 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:on_time_front/presentation/home/bloc/schedule_timer_bloc.dart'; +import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; + +enum TodayScheduleTileState { scheduled, active, completed } class TodaysScheduleTile extends StatelessWidget { const TodaysScheduleTile({ @@ -11,204 +12,100 @@ class TodaysScheduleTile extends StatelessWidget { this.schedule, this.onTap, this.compact = false, + this.state = TodayScheduleTileState.scheduled, }); final ScheduleEntity? schedule; final VoidCallback? onTap; final bool compact; - - Widget _noSchedule(BuildContext context) { - final theme = Theme.of(context); - return Padding( - padding: EdgeInsets.symmetric( - horizontal: compact ? 10.0 : 11.0, - vertical: compact ? 10.0 : 16.0, - ), - child: Text( - AppLocalizations.of(context)!.noAppointments, - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.outlineVariant, - height: 22 / 16, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ); - } - - Widget _scheduleExists(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - - return IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: compact ? 10.0 : 16.0), - child: _ScheduleLeftTimeColumn( - scheduleTime: schedule!.scheduleTime, - compact: compact, - ), - ), - VerticalDivider(width: 1, color: colorScheme.primary), - Expanded( - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: compact ? 12.0 : 21.0, - vertical: compact ? 8.0 : 11.0, - ), - child: _ScheduleDetailsColumn( - schedule: schedule!, - compact: compact, - ), - ), - ), - ], - ), - ); - } + final TodayScheduleTileState state; @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final empty = schedule == null; + final background = switch (empty ? null : state) { + null => const Color(0xfff3f3f3), + TodayScheduleTileState.scheduled => const Color(0xffdde4ff), + TodayScheduleTileState.active => AppColors.blue.shade600, + TodayScheduleTileState.completed => AppColors.grey.shade400, + }; + final foreground = switch (empty ? null : state) { + null => AppColors.grey.shade700, + TodayScheduleTileState.scheduled => AppColors.blue.shade700, + TodayScheduleTileState.active || + TodayScheduleTileState.completed => Colors.white, + }; + final textTheme = Theme.of(context).textTheme; + final localizations = AppLocalizations.of(context)!; + return InkWell( borderRadius: BorderRadius.circular(8), onTap: onTap, child: Container( + height: compact ? 48 : 54, + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: compact ? 10 : 20), decoration: BoxDecoration( - color: schedule == null - ? theme.colorScheme.surfaceContainerLow - : theme.colorScheme.primaryContainer, + color: background, borderRadius: BorderRadius.circular(8), ), - width: double.infinity, - constraints: BoxConstraints(minHeight: compact ? 48 : 54), - alignment: schedule == null ? Alignment.centerLeft : null, - child: schedule == null - ? _noSchedule(context) - : _scheduleExists(context), - ), - ); - } -} - -class _ScheduleDetailsColumn extends StatelessWidget { - const _ScheduleDetailsColumn({required this.schedule, required this.compact}); - - final ScheduleEntity schedule; - final bool compact; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final textTheme = theme.textTheme; - final formattedTime = DateFormat.jm( - AppLocalizations.of(context)!.localeName, - ).format(schedule.scheduleTime); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - schedule.scheduleName, - style: (compact ? textTheme.titleMedium : textTheme.titleLarge) - ?.copyWith(color: colorScheme.primary), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - //time PM H:MM - Text( - formattedTime, - style: textTheme.bodySmall?.copyWith(color: colorScheme.primary), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ); - } -} - -class _ScheduleLeftTimeColumn extends StatelessWidget { - const _ScheduleLeftTimeColumn({ - required this.scheduleTime, - required this.compact, - }); - - final DateTime scheduleTime; - final bool compact; - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => - ScheduleTimerBloc()..add(ScheduleTimerStarted(scheduleTime)), - child: BlocBuilder( - builder: (context, state) { - Duration leftTime; - - if (state is ScheduleTimerRunning) { - leftTime = state.remainingDuration; - } else if (state is ScheduleTimerFinished) { - leftTime = Duration.zero; - } else { - // Initial state - calculate immediately - leftTime = scheduleTime.difference(DateTime.now()); - } - - final hours = leftTime.inHours; - final minutes = leftTime.inMinutes % 60; - - return _TimeColumn(hour: hours, minute: minutes, compact: compact); - }, + child: empty + ? Align( + alignment: Alignment.centerLeft, + child: Text( + localizations.noAppointments, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyLarge?.copyWith(color: foreground), + ), + ) + : Row( + children: [ + Flexible( + fit: FlexFit.loose, + child: Text( + _leadingText(context), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyLarge?.copyWith(color: foreground), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + schedule!.scheduleName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + style: textTheme.bodyLarge?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), ), ); } -} -class _TimeColumn extends StatelessWidget { - const _TimeColumn({ - required this.hour, - required this.minute, - required this.compact, - }); - - final int hour; - final int minute; - final bool compact; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - return Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - AppLocalizations.of(context)!.untilAppointment, - style: - (compact ? theme.textTheme.labelSmall : theme.textTheme.bodySmall) - ?.copyWith(color: colorScheme.primary), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - SizedBox(height: compact ? 2 : 4), - Text( - '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}', - style: - (compact - ? theme.textTheme.labelLarge - : theme.textTheme.titleSmall) - ?.copyWith(color: colorScheme.primary), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ); + String _leadingText(BuildContext context) { + final localizations = AppLocalizations.of(context)!; + switch (state) { + case TodayScheduleTileState.active: + return localizations.preparationInProgress; + case TodayScheduleTileState.completed: + return localizations.done; + case TodayScheduleTileState.scheduled: + final date = schedule!.scheduleTime; + final locale = localizations.localeName; + if (locale.startsWith('ko')) { + final dayPeriod = DateFormat('a', locale).format(date); + final hour = date.hour % 12 == 0 ? 12 : date.hour % 12; + final minute = date.minute == 0 ? '' : ' ${date.minute}분'; + return '${date.month}월 ${date.day}일 $dayPeriod $hour시$minute'; + } + return '${DateFormat.MMMd(locale).format(date)} ${DateFormat.jm(locale).format(date)}'; + } } } diff --git a/lib/presentation/home/screens/home_screen_tmp.dart b/lib/presentation/home/screens/home_screen_tmp.dart index e35a3f22..82006246 100644 --- a/lib/presentation/home/screens/home_screen_tmp.dart +++ b/lib/presentation/home/screens/home_screen_tmp.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:go_router/go_router.dart'; import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; 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/calendar/bloc/monthly_schedules_bloc.dart'; @@ -36,7 +37,7 @@ class HomeScreenTmp extends StatelessWidget { ..add(MonthlySchedulesSubscriptionRequested(date: dateOfToday)), child: BlocBuilder( builder: (context, state) { - return HomeScreenContent(state: state); + return HomeScreenContent(state: state, referenceDate: dateOfToday); }, ), ); @@ -45,10 +46,16 @@ class HomeScreenTmp extends StatelessWidget { /// The actual home screen content that can be tested independently class HomeScreenContent extends StatelessWidget { - const HomeScreenContent({super.key, required this.state, this.userScore}); + const HomeScreenContent({ + super.key, + required this.state, + this.userScore, + this.referenceDate, + }); final MonthlySchedulesState state; final double? userScore; + final DateTime? referenceDate; @override Widget build(BuildContext context) { @@ -69,38 +76,41 @@ class HomeScreenContent extends StatelessWidget { builder: (context, constraints) { final metrics = _HomeLayoutMetrics.fromConstraints( constraints: constraints, + viewportHeight: MediaQuery.sizeOf(context).height, textScale: MediaQuery.textScalerOf(context).scale(1), safeAreaTop: MediaQuery.paddingOf(context).top, ); - return Column( + final content = Column( children: [ SizedBox( height: metrics.topSectionHeight, - child: ColoredBox( - color: colorScheme.primary, - child: Column( - children: [ - SizedBox(height: metrics.safeAreaGap), - Expanded( - child: ColoredBox( - color: colorScheme.primary, - child: Column( - children: [ - SizedBox(height: metrics.heroTopPadding), - _CharacterSection( - score: score, - height: metrics.bannerHeight, - topPadding: 8, + child: metrics.compact + ? ColoredBox( + color: colorScheme.primary, + child: Column( + children: [ + SizedBox(height: metrics.safeAreaGap), + Expanded( + child: ColoredBox( + color: colorScheme.primary, + child: Column( + children: [ + SizedBox(height: metrics.heroTopPadding), + _CharacterSection( + score: score, + height: metrics.bannerHeight, + topPadding: 8, + ), + _TodaysScheduleOverlay(metrics: metrics), + ], + ), ), - _TodaysScheduleOverlay(metrics: metrics), - ], - ), + ), + ], ), - ), - ], - ), - ), + ) + : Stack(children: [const _HomeHero()]), ), Expanded( child: Container( @@ -112,11 +122,24 @@ class HomeScreenContent extends StatelessWidget { child: _MonthlySchedule( monthlySchedulesState: state, metrics: metrics, + referenceDate: referenceDate, ), ), ), ], ); + if (metrics.compact) return content; + return Stack( + children: [ + content, + Positioned( + top: 177, + left: 0, + right: 0, + child: _TodaysScheduleOverlay(metrics: metrics), + ), + ], + ); }, ), ); @@ -141,6 +164,14 @@ class _TodaysScheduleOverlay extends StatelessWidget { ? null : scheduleState.schedule; final hasSchedule = todaySchedule != null; + var tileState = TodayScheduleTileState.scheduled; + if (todaySchedule != null && + todaySchedule.doneStatus != ScheduleDoneStatus.notEnded) { + tileState = TodayScheduleTileState.completed; + } else if (scheduleState.status == ScheduleStatus.ongoing || + scheduleState.status == ScheduleStatus.started) { + tileState = TodayScheduleTileState.active; + } final target = resolveTodayTileNavigationTarget( scheduleStatus: scheduleState.status, hasSchedule: hasSchedule, @@ -179,9 +210,11 @@ class _TodaysScheduleOverlay extends StatelessWidget { ), color: theme.colorScheme.surface, elevation: 6, - shadowColor: Colors.black.withValues(alpha: 0.4), + shadowColor: Colors.black.withValues(alpha: 0.12), child: Padding( - padding: EdgeInsets.all(metrics.todayCardPadding), + padding: metrics.compact + ? EdgeInsets.all(metrics.todayCardPadding) + : const EdgeInsets.fromLTRB(20, 20, 20, 22), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -193,7 +226,10 @@ class _TodaysScheduleOverlay extends StatelessWidget { ? theme.textTheme.titleSmall : theme.textTheme.titleMedium) ?.copyWith( - height: metrics.compact ? 22 / 16 : 22 / 18, + height: metrics.compact ? 22 / 16 : 1.4, + fontWeight: metrics.compact + ? FontWeight.w600 + : FontWeight.w700, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -202,6 +238,7 @@ class _TodaysScheduleOverlay extends StatelessWidget { TodaysScheduleTile( key: const Key('today_schedule_tile'), schedule: todaySchedule, + state: tileState, compact: metrics.compact, onTap: target == null ? null @@ -227,13 +264,44 @@ class _MonthlySchedule extends StatelessWidget { const _MonthlySchedule({ required this.monthlySchedulesState, required this.metrics, + required this.referenceDate, }); final MonthlySchedulesState monthlySchedulesState; final _HomeLayoutMetrics metrics; + final DateTime? referenceDate; @override Widget build(BuildContext context) { + Widget calendar({required bool compact}) => MonthCalendar( + key: const Key('home_month_calendar'), + monthlySchedulesState: monthlySchedulesState, + initialDate: referenceDate, + sixWeekMonthsEnforced: compact, + rowHeight: metrics.calendarRowHeight, + daysOfWeekHeight: metrics.calendarDaysOfWeekHeight, + headerVerticalPadding: compact ? 0 : 11, + contentPadding: compact + ? EdgeInsets.only( + left: metrics.calendarPadding, + right: metrics.calendarPadding, + bottom: metrics.calendarPadding + metrics.calendarFabClearance, + ) + : const EdgeInsets.all(16), + onDateSelected: (date) { + context.go(calendarRouteLocation(date), extra: date); + }, + ); + + if (!metrics.compact) { + return Column( + children: [ + const SizedBox(height: 16), + SizedBox(width: 360, height: 391, child: calendar(compact: false)), + ], + ); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -241,20 +309,7 @@ class _MonthlySchedule extends StatelessWidget { Expanded( child: Align( alignment: Alignment.topCenter, - child: MonthCalendar( - key: const Key('home_month_calendar'), - monthlySchedulesState: monthlySchedulesState, - rowHeight: metrics.calendarRowHeight, - daysOfWeekHeight: metrics.calendarDaysOfWeekHeight, - contentPadding: EdgeInsets.only( - left: metrics.calendarPadding, - right: metrics.calendarPadding, - bottom: metrics.calendarPadding + metrics.calendarFabClearance, - ), - onDateSelected: (date) { - context.go(calendarRouteLocation(date), extra: date); - }, - ), + child: calendar(compact: true), ), ), ], @@ -361,6 +416,54 @@ class _CharacterSection extends StatelessWidget { } } +class _HomeHero extends StatelessWidget { + const _HomeHero(); + + @override + Widget build(BuildContext context) { + return SizedBox( + key: const Key('home_hero'), + height: 230, + width: double.infinity, + child: ClipRect( + child: ColoredBox( + color: const Color(0xff4f69df), + child: Stack( + children: [ + Positioned( + left: 18, + top: 81, + width: 200, + child: Text( + AppLocalizations.of(context)!.slogan, + key: const Key('home_hero_copy'), + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.4, + color: Color(0xffdce3ff), + ), + ), + ), + Positioned( + right: -4, + top: 48, + width: 176, + height: 246, + child: Image.asset( + 'home_mascot.png', + package: 'assets', + fit: BoxFit.contain, + ), + ), + ], + ), + ), + ), + ); + } +} + class _HomeLayoutMetrics { const _HomeLayoutMetrics({ required this.compact, @@ -398,11 +501,13 @@ class _HomeLayoutMetrics { final double calendarPadding; final double calendarFabClearance; - double get topSectionHeight => - safeAreaGap + heroTopPadding + bannerHeight + todayOverlayHeight; + double get topSectionHeight => compact + ? safeAreaGap + heroTopPadding + bannerHeight + todayOverlayHeight + : 314; factory _HomeLayoutMetrics.fromConstraints({ required BoxConstraints constraints, + required double viewportHeight, required double textScale, required double safeAreaTop, }) { @@ -410,7 +515,7 @@ class _HomeLayoutMetrics { ? constraints.maxHeight : 800.0; final width = constraints.maxWidth.isFinite ? constraints.maxWidth : 390.0; - final heightPressure = (1 - ((height - 640) / 204)).clamp(0.0, 1.0); + final heightPressure = (1 - ((viewportHeight - 640) / 204)).clamp(0.0, 1.0); final widthPressure = width <= 380 ? 1.0 : 0.0; final textPressure = ((textScale - 1) / 0.3).clamp(0.0, 1.0); final pressure = math.max( @@ -426,7 +531,7 @@ class _HomeLayoutMetrics { final heroTopPadding = safeAreaTop > 0 ? 0.0 : scale(43, 18); const bannerAspectRatio = 1170 / 402; final fullWidthBannerHeight = width / bannerAspectRatio; - final maxBannerHeight = math.min(scale(180, 124), height * 0.22); + final maxBannerHeight = math.min(scale(180, 124), viewportHeight * 0.22); final bannerHeight = math.min(fullWidthBannerHeight, maxBannerHeight); final todayOverlayHeight = scale(137, 137); final todayHeroOverlap = scale(53, todayOverlayHeight / 2); @@ -460,9 +565,9 @@ class _HomeLayoutMetrics { todayOverlayHeight: todayOverlayHeight, todayHeroOverlap: todayHeroOverlap, todayCardPadding: scale(20, 8), - todayTitleGap: scale(21, 6), - cardHorizontalPadding: 16, - sectionHorizontalPadding: scale(16, 8), + todayTitleGap: scale(16, 6), + cardHorizontalPadding: scale(15, 16), + sectionHorizontalPadding: scale(15, 8), sectionBottomPadding: sectionBottomPadding, monthlyHeaderHeight: monthlyHeaderHeight, calendarRowHeight: calendarRowHeight, diff --git a/lib/presentation/my_page/data_state_page.dart b/lib/presentation/my_page/data_state_page.dart new file mode 100644 index 00000000..9f518a0e --- /dev/null +++ b/lib/presentation/my_page/data_state_page.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; + +/// Shared shell for local-data states. System bars are supplied by the OS. +class DataStatePage extends StatelessWidget { + const DataStatePage({ + super.key, + required this.title, + required this.children, + this.onBack, + this.showBack = true, + this.footer, + this.padding = const EdgeInsets.symmetric(horizontal: 19), + this.footerPadding = const EdgeInsets.fromLTRB(19, 16, 19, 33), + this.headerHeight = 44, + }); + + final String title; + final List children; + final VoidCallback? onBack; + final bool showBack; + final Widget? footer; + final EdgeInsets padding; + final EdgeInsets footerPadding; + final double headerHeight; + + @override + Widget build(BuildContext context) => RefreshTheme( + child: Scaffold( + body: SafeArea( + child: Column( + children: [ + SizedBox( + height: headerHeight, + child: Stack( + alignment: Alignment.center, + children: [ + Center( + child: Text( + title, + style: const TextStyle( + fontSize: 18, + height: 1.4, + fontWeight: FontWeight.w600, + ), + ), + ), + if (showBack) + Positioned( + left: 8, + top: 0, + bottom: 0, + child: IconButton( + tooltip: '뒤로', + onPressed: + onBack ?? () => Navigator.of(context).maybePop(), + icon: SvgPicture.asset( + 'chevron_left.svg', + package: 'assets', + width: 8, + height: 14, + ), + ), + ), + ], + ), + ), + Expanded( + child: ListView(padding: padding, children: children), + ), + if (footer != null) Padding(padding: footerPadding, child: footer), + ], + ), + ), + ), + ); +} + +class DataNotice extends StatelessWidget { + const DataNotice({ + super.key, + required this.child, + this.highlighted = false, + this.outlinedIcon = false, + this.padding = const EdgeInsets.all(17), + this.gap = 15, + this.minHeight = 0, + }); + + final Widget child; + final bool highlighted; + final bool outlinedIcon; + final EdgeInsets padding; + final double gap; + final double minHeight; + + @override + Widget build(BuildContext context) => Container( + constraints: BoxConstraints(minHeight: minHeight), + decoration: BoxDecoration( + color: highlighted + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(9), + ), + padding: padding, + child: Row( + crossAxisAlignment: outlinedIcon + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + SvgPicture.asset( + outlinedIcon + ? 'assets/design/information_outline.svg' + : 'assets/design/information_filled.svg', + width: outlinedIcon ? 23 : 22, + height: outlinedIcon ? 23 : 22, + excludeFromSemantics: true, + ), + SizedBox(width: gap), + Expanded(child: child), + ], + ), + ); +} diff --git a/lib/presentation/my_page/my_data_screen.dart b/lib/presentation/my_page/my_data_screen.dart index 05853be5..4d04044f 100644 --- a/lib/presentation/my_page/my_data_screen.dart +++ b/lib/presentation/my_page/my_data_screen.dart @@ -1,13 +1,18 @@ -import 'package:flutter_svg/flutter_svg.dart'; import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; +import 'package:on_time_front/presentation/my_page/data_state_page.dart'; import 'package:on_time_front/presentation/startup/screens/local_data_recovery_screen.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:on_time_front/core/backup/backup_password.dart'; import 'package:on_time_front/core/backup/backup_service.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/domain/use-cases/reconcile_alarms_use_case.dart'; +import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; +import 'package:on_time_front/presentation/shared/components/app_spinner.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 MyDataScreen extends StatefulWidget { const MyDataScreen({super.key, this.initialAction}); @@ -50,65 +55,100 @@ class _MyDataScreenState extends State { Widget build(BuildContext context) { final freshness = _freshness; return Scaffold( - appBar: AppBar(title: const Text('내 데이터')), + appBar: AppBar( + leading: IconButton( + tooltip: '뒤로', + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(left: 8), + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.go('/myPage'); + } + }, + icon: SvgPicture.asset( + 'chevron_left.svg', + package: 'assets', + width: 8, + height: 14, + colorFilter: ColorFilter.mode( + AppColors.grey.shade500, + BlendMode.srcIn, + ), + ), + ), + title: const Text('내 데이터'), + centerTitle: true, + toolbarHeight: 60, + titleTextStyle: Theme.of(context).textTheme.titleLarge?.copyWith( + fontSize: 19, + fontWeight: FontWeight.w600, + ), + ), body: ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 18, 16, 32), children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('백업 상태', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - Text(_freshnessLabel(freshness)), - if (freshness?.reminderDue == true) ...[ - const SizedBox(height: 8), - Text( - '30일 이상 백업되지 않은 변경 사항이 있습니다.', - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ), - ], + Padding( + key: const Key('backupStatusCard'), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '백업 상태', + style: TextStyle( + fontSize: 16, + height: 1.2, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + _freshnessLabel(freshness), + style: const TextStyle(fontSize: 14, height: 1.2), + ), + if (freshness?.reminderDue == true) ...[ + const Text( + '30일 이상 백업되지 않은 변경 사항이 있습니다.', + style: TextStyle(fontSize: 14, height: 1.2), + ), ], - ), + ], ), ), - const SizedBox(height: 12), - ListTile( + const SizedBox(height: 18), + _DataActionRow( + rowKey: const Key('backupExportRow'), enabled: !_busy, - leading: const Icon(Icons.lock_outline), - title: const Text('암호화 백업 내보내기'), - subtitle: const Text('선택한 파일 위치에만 저장합니다.'), + icon: Icons.lock_outline, + title: '암호화 백업 내보내기', + subtitle: '선택한 파일 위치에만 저장합니다.', onTap: _export, ), - ListTile( + const SizedBox(height: 18), + _DataActionRow( + rowKey: const Key('backupRestoreRow'), enabled: !_busy, - leading: const Icon(Icons.restore), - title: const Text('백업에서 복원'), - subtitle: const Text('미리 확인한 뒤 현재 데이터를 완전히 교체합니다.'), + icon: Icons.restore, + title: '백업에서 복원', + subtitle: '미리 확인한 뒤 현재 데이터를 완전히 교체합니다.', onTap: _restore, ), - const Divider(height: 32), - ListTile( + const SizedBox(height: 18), + _DataActionRow( + rowKey: const Key('localDataResetRow'), enabled: !_busy, - leading: Icon( - Icons.delete_forever, - color: Theme.of(context).colorScheme.error, - ), - title: Text( - '로컬 데이터 초기화', - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - subtitle: const Text('이 기기의 OnTime 데이터와 알람을 모두 삭제합니다.'), + icon: Icons.delete_forever, + title: '로컬 데이터 초기화', + subtitle: '이 기기의 OnTime 데이터와 알람을 모두 삭제합니다.', + destructive: true, onTap: _reset, ), if (_busy) const Padding( - padding: EdgeInsets.only(top: 24), - child: Center(child: CircularProgressIndicator()), + padding: EdgeInsets.only(top: 18), + child: Center(child: AppSpinner()), ), ], ), @@ -132,9 +172,7 @@ class _MyDataScreenState extends State { password, ); if (!mounted || !saved) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('암호화 백업을 저장했습니다.'))); + showMyDataResultSnackBar(context, '암호화 백업을 저장했습니다.'); await _loadFreshness(); }); } @@ -148,38 +186,12 @@ class _MyDataScreenState extends State { ); if (!mounted || candidate == null) return; final preview = candidate.preview; - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('복원 내용 확인'), - content: Text( - '백업 시점: ${preview.cutoff.toLocal()}\n' - '앱 버전: ${preview.sourceAppVersion}\n' - '원본 플랫폼: ${preview.sourcePlatform}\n' - '일정 ${preview.scheduleCount}개\n' - '준비 템플릿 ${preview.templateCount}개\n' - '기본 준비 단계 ${preview.defaultPreparationStepCount}개\n\n' - '현재 로컬 데이터는 모두 교체됩니다.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('취소'), - ), - FilledButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('복원'), - ), - ], - ), - ); - if (confirmed != true) return; + final confirmed = await showRestorePreviewDialog(context, preview); + if (confirmed != DialogActionResult.primary) return; await getIt().applyRestore(candidate); await getIt()(); if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('백업을 복원했습니다.'))); + showMyDataResultSnackBar(context, '백업을 복원했습니다.'); await _loadFreshness(); }); } @@ -203,80 +215,11 @@ class _MyDataScreenState extends State { } Future _askPassword({required bool confirm}) async { - final first = TextEditingController(); - final second = TextEditingController(); - String? error; - final result = await showDialog( + return showDialog( context: context, - builder: (context) => StatefulBuilder( - builder: (context, setDialogState) => AlertDialog( - title: Text(confirm ? '백업 비밀번호 만들기' : '백업 비밀번호 입력'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: first, - obscureText: true, - enableSuggestions: false, - autocorrect: false, - autofillHints: null, - decoration: const InputDecoration( - labelText: '백업 비밀번호', - helperText: '15~128자, 대소문자와 공백을 그대로 구분합니다.', - ), - ), - if (confirm) ...[ - const SizedBox(height: 12), - TextField( - controller: second, - obscureText: true, - enableSuggestions: false, - autocorrect: false, - autofillHints: null, - decoration: const InputDecoration(labelText: '백업 비밀번호 확인'), - ), - ], - if (error != null) ...[ - const SizedBox(height: 8), - Text( - error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ], - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('취소'), - ), - FilledButton( - onPressed: () { - try { - final parsed = BackupPassword.parse(first.text); - if (confirm && - parsed.normalized != - BackupPassword.parse(second.text).normalized) { - throw const FormatException('비밀번호가 서로 다릅니다.'); - } - Navigator.pop(context, parsed.normalized); - } on FormatException catch (exception) { - setDialogState( - () => error = exception.message == '비밀번호가 서로 다릅니다.' - ? exception.message - : '백업 비밀번호는 15~128자로 입력해주세요.', - ); - } - }, - child: const Text('계속'), - ), - ], - ), - ), + barrierColor: const Color(0x6B000000), + builder: (context) => _BackupPasswordDialog(confirm: confirm), ); - first.dispose(); - second.dispose(); - return result; } Future _run(Future Function() action) async { @@ -291,68 +234,398 @@ class _MyDataScreenState extends State { } void _showError(Object error) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('작업을 완료하지 못했습니다: $error'))); + showMyDataResultSnackBar(context, '작업을 완료하지 못했습니다: $error'); } } -class LocalDataResetCompleteScreen extends StatelessWidget { - const LocalDataResetCompleteScreen({super.key}); +void showMyDataResultSnackBar(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + message, + style: const TextStyle( + fontFamily: 'Pretendard', + fontSize: 14, + height: 16 / 14, + color: Colors.white, + ), + ), + behavior: SnackBarBehavior.floating, + backgroundColor: const Color(0xFF323232), + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + margin: const EdgeInsets.fromLTRB(16, 0, 16, 48), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + ); +} + +class _BackupPasswordDialog extends StatefulWidget { + const _BackupPasswordDialog({required this.confirm}); + + final bool confirm; @override - 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('확인'), - ), - ], + State<_BackupPasswordDialog> createState() => _BackupPasswordDialogState(); +} + +class _BackupPasswordDialogState extends State<_BackupPasswordDialog> { + final _first = TextEditingController(); + final _second = TextEditingController(); + String? _error; + + @override + void dispose() { + _first.dispose(); + _second.dispose(); + super.dispose(); + } + + void _continue() { + try { + final parsed = BackupPassword.parse(_first.text); + if (widget.confirm && + parsed.normalized != BackupPassword.parse(_second.text).normalized) { + throw const FormatException('비밀번호가 서로 다릅니다.'); + } + Navigator.of(context).pop(parsed.normalized); + } on FormatException catch (exception) { + setState( + () => _error = exception.message == '비밀번호가 서로 다릅니다.' + ? exception.message + : '백업 비밀번호는 15~128자로 입력해주세요.', + ); + } + } + + @override + Widget build(BuildContext context) { + final topInset = _figmaDialogTopInset(context); + final availableHeight = + MediaQuery.sizeOf(context).height - + MediaQuery.viewInsetsOf(context).bottom; + const bodyStyle = TextStyle( + fontFamily: 'Pretendard', + fontSize: 13, + height: 1.4, + color: Color(0xFF545454), + ); + return RefreshTheme( + child: TwoActionDialog( + config: TwoActionDialogConfig( + title: widget.confirm ? '백업 비밀번호 만들기' : '백업 비밀번호 입력', + secondaryAction: const DialogActionConfig(label: '취소'), + primaryAction: const DialogActionConfig( + label: '계속', + variant: ModalWideButtonVariant.primary, + ), + alignment: Alignment.topCenter, + insetPadding: EdgeInsets.only(top: topInset), + innerPadding: const EdgeInsets.all(16), + titleContentSpacing: 12, + contentActionsSpacing: 11, + ), + onSecondaryPressed: () => Navigator.of(context).pop(), + onPrimaryPressed: _continue, + customContent: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: (availableHeight - topInset - 124).clamp( + 80.0, + double.infinity, + ), + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('백업 비밀번호', style: bodyStyle), + const SizedBox(height: 12), + _passwordField(_first, '백업 비밀번호', bodyStyle), + const Text( + '15~128자, 대소문자와 공백을 그대로 구분합니다.', + style: bodyStyle, + textAlign: TextAlign.center, + ), + if (widget.confirm) ...[ + const SizedBox(height: 16), + const Text('백업 비밀번호 확인', style: bodyStyle), + const SizedBox(height: 12), + _passwordField(_second, '백업 비밀번호 확인', bodyStyle), + ], + if (_error != null) ...[ + const SizedBox(height: 8), + Text( + _error!, + style: bodyStyle.copyWith(color: const Color(0xFFBF2E22)), + textAlign: TextAlign.center, + ), + ], + ], + ), ), ), ), - children: [ - const SizedBox(height: 40), - Center( - child: SvgPicture.asset( - 'assets/design/reset_success.svg', - width: 69, - height: 69, + ); + } + + Widget _passwordField( + TextEditingController controller, + String label, + TextStyle style, + ) { + return SizedBox( + height: 44, + child: Center( + child: Semantics( + label: label, + child: TextField( + controller: controller, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + autofillHints: null, + textAlign: TextAlign.center, + style: style, + decoration: const InputDecoration( + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + onChanged: (_) { + if (_error != null) setState(() => _error = null); + }, ), ), - const SizedBox(height: 4), - const Text( - '로컬 데이터 초기화가\n완료되었습니다', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 24, - height: 1.3, - fontWeight: FontWeight.w700, + ), + ); + } +} + +double _figmaDialogTopInset(BuildContext context) { + final availableHeight = + MediaQuery.sizeOf(context).height - + MediaQuery.viewInsetsOf(context).bottom; + final targetTop = (availableHeight - 400).clamp(16.0, 352.0); + final systemTopInset = + View.of(context).padding.top / View.of(context).devicePixelRatio; + return (targetTop - systemTopInset).clamp(0.0, double.infinity); +} + +Future showRestorePreviewDialog( + BuildContext context, + BackupRestorePreview preview, +) { + final description = + '백업 시점: ${_formatBackupCutoff(preview.cutoff)}\n' + '앱 버전: ${preview.sourceAppVersion}\n' + '원본 플랫폼: ${preview.sourcePlatform}\n' + '일정 ${preview.scheduleCount}개\n' + '준비 템플릿 ${preview.templateCount}개\n' + '기본 준비 단계 ${preview.defaultPreparationStepCount}개\n\n' + '현재 로컬 데이터는 모두 교체됩니다.'; + return showDialog( + context: context, + barrierColor: const Color(0x6B000000), + builder: (dialogContext) => RefreshTheme( + child: TwoActionDialog( + onPrimaryPressed: () => + Navigator.of(dialogContext).pop(DialogActionResult.primary), + onSecondaryPressed: () => + Navigator.of(dialogContext).pop(DialogActionResult.secondary), + config: TwoActionDialogConfig( + title: '복원 내용 확인', + secondaryAction: const DialogActionConfig(label: '취소'), + primaryAction: const DialogActionConfig( + label: '복원', + variant: ModalWideButtonVariant.primary, ), + barrierColor: const Color(0x6B000000), + alignment: Alignment.topCenter, + insetPadding: EdgeInsets.only(top: _figmaDialogTopInset(context)), ), - const Text( - '이 기기에 저장된 모든 로컬 데이터가 삭제되었습니다.\nOnTime을 완전히 종료한 뒤 다시 열면 새 로컬 프로필로 시작합니다.', + customContent: Text( + description, + style: const TextStyle( + fontFamily: 'Pretendard', + fontSize: 13, + height: 1.55, + letterSpacing: -0.4, + color: Color(0xFF545454), + ), 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), + ), + ), + ).then((result) => result ?? DialogActionResult.dismissed); +} + +String _formatBackupCutoff(DateTime cutoff) { + final local = cutoff.toLocal(); + final period = local.hour < 12 ? '오전' : '오후'; + final hour = local.hour % 12 == 0 ? 12 : local.hour % 12; + final minute = local.minute.toString().padLeft(2, '0'); + return '${local.year}. ${local.month}. ${local.day}. $period $hour:$minute'; +} + +class _DataActionRow extends StatelessWidget { + const _DataActionRow({ + required this.rowKey, + required this.enabled, + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + this.destructive = false, + }); + + final Key rowKey; + final bool enabled; + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + final bool destructive; + + @override + Widget build(BuildContext context) { + final titleColor = destructive + ? AppColors.red.shade800 + : AppColors.grey[950]!; + return Semantics( + button: true, + enabled: enabled, + label: '$title. $subtitle', + child: Opacity( + opacity: enabled ? 1 : 0.38, + child: InkWell( + key: rowKey, + onTap: enabled ? onTap : null, + borderRadius: BorderRadius.circular(12), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 68), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 8, 14), + child: Row( + children: [ + Icon( + icon, + size: 24, + color: destructive ? titleColor : AppColors.grey.shade700, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 16, + height: 1.2, + fontWeight: FontWeight.w600, + color: titleColor, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: TextStyle( + fontSize: 14, + height: 1.2, + color: AppColors.grey.shade700, + ), + ), + ], + ), + ), + ], + ), + ), ), ), - ], + ), + ); + } +} + +class LocalDataResetCompleteScreen extends StatelessWidget { + const LocalDataResetCompleteScreen({super.key}); + + @override + Widget build(BuildContext context) => DataStatePage( + title: '초기화 완료', + showBack: false, + footerPadding: const EdgeInsets.fromLTRB(19, 16, 19, 19), + footer: ModalWideButton( + text: '다시 시작 안내', + variant: ModalWideButtonVariant.primary, + layout: ModalWideButtonLayout.full, + height: 50, + onPressed: () => 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: 56), + Center( + child: SvgPicture.asset( + 'assets/design/reset_success.svg', + width: 69, + height: 69, + ), + ), + const SizedBox(height: 18), + const Text( + '로컬 데이터 초기화가\n완료되었습니다', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24, + height: 31 / 24, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 16), + const Text( + '이 기기에 저장된 모든 로컬 데이터가 삭제되었습니다.\nOnTime을 완전히 종료한 뒤 다시 열면 새 로컬 프로필로 시작합니다.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + height: 20.5 / 14, + color: Color(0xFF545454), + ), + ), + const SizedBox(height: 26.5), + const DataNotice( + key: Key('resetCompleteBackupNotice'), + outlinedIcon: true, + gap: 13, + padding: EdgeInsets.symmetric(horizontal: 17, vertical: 16), + minHeight: 74, + child: Text( + '외부에 저장된 백업 파일은 삭제되지 않았습니다.\n필요한 경우 직접 삭제해 주세요.', + style: TextStyle( + fontSize: 13, + height: 21 / 13, + color: Color(0xFF545454), + ), + ), + ), + const SizedBox(height: 24), + ], ); } diff --git a/lib/presentation/my_page/my_page_screen.dart b/lib/presentation/my_page/my_page_screen.dart index e200a822..ec5f4f69 100644 --- a/lib/presentation/my_page/my_page_screen.dart +++ b/lib/presentation/my_page/my_page_screen.dart @@ -1,9 +1,6 @@ -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:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; @@ -12,176 +9,222 @@ 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/entities/user_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'; +import 'package:on_time_front/domain/use-cases/get_default_preparation_use_case.dart'; import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; +import 'package:on_time_front/domain/use-cases/stream_user_use_case.dart'; import 'package:on_time_front/l10n/app_localizations.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 MyPageScreen extends StatelessWidget { - const MyPageScreen({super.key, NotificationService? notificationService}) - : _notificationService = notificationService; + const MyPageScreen({ + super.key, + NotificationService? notificationService, + this.referencePreparationMinutes, + this.referenceSpareMinutes, + }) : _notificationService = notificationService; final NotificationService? _notificationService; + final int? referencePreparationMinutes; + final int? referenceSpareMinutes; @override Widget build(BuildContext context) { - return RefreshTheme( - child: Scaffold( - appBar: AppBar( - centerTitle: true, - title: Text( + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: Padding( + padding: const EdgeInsets.only(top: 14), + child: Text( AppLocalizations.of(context)!.myPageTitle, - style: Theme.of(context).textTheme.titleLarge, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700), ), ), - 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', + toolbarHeight: 47, + centerTitle: true, + backgroundColor: Theme.of(context).colorScheme.surface, + ), + body: SingleChildScrollView( + child: Column( + children: [ + _FrameView( + key: const Key('myPageAlarmSection'), + title: recurrenceText(context, '알림 설정', 'Notifications'), + child: Column( + children: [ + const _AlarmStatusView(), + const _DetailedNotificationTile(), + _AppNotificationTile( + notificationService: + _notificationService ?? NotificationService.instance, ), - onTap: () => context.push('/recurringSchedules'), + ], + ), + ), + const SizedBox(height: 18), + _TimeSettingsView( + referencePreparationMinutes: referencePreparationMinutes, + referenceSpareMinutes: referenceSpareMinutes, + ), + const SizedBox(height: 18), + _FrameView( + key: const Key('myPageAppSection'), + title: recurrenceText(context, '일정 관리', 'Schedules'), + child: _SettingTile( + icon: 'my_page_repeat.svg', + title: recurrenceText( + context, + '반복 일정 관리', + 'Recurring schedules', ), + onTap: () => context.push('/recurringSchedules'), ), - _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'), + ), + const SizedBox(height: 18), + _FrameView( + key: const Key('myPageDataSection'), + title: recurrenceText(context, '데이터 관리', 'Data'), + child: Column( + children: [ + _SettingTile( + icon: 'my_page_upload.svg', + title: recurrenceText( + context, + '내 데이터 백업', + 'Back up my data', ), - _SettingTile( - icon: Icons.cloud_download_outlined, - title: recurrenceText( - context, - '내 데이터 복원', - 'Restore my data', - ), - onTap: () => context.push('/myData?action=restore'), + divider: true, + onTap: () => context.push('/myData?action=backup'), + ), + _SettingTile( + icon: 'my_page_download.svg', + title: recurrenceText( + context, + '내 데이터 복원', + 'Restore my data', ), - _SettingTile( - icon: Icons.delete_outline, - title: recurrenceText( - context, - '내 데이터 초기화', - 'Reset local data', - ), - onTap: () => context.push('/myData?action=reset'), + divider: true, + onTap: () => context.push('/myData?action=restore'), + ), + _SettingTile( + icon: 'my_page_trash.svg', + 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'), + ), + const SizedBox(height: 18), + _FrameView( + title: recurrenceText(context, '기타', 'Other'), + child: _SettingTile( + icon: 'my_page_shield.svg', + title: recurrenceText( + context, + '개인정보', + AppLocalizations.of(context)!.privacyPolicy, ), + onTap: () => context.push('/privacyPolicy'), ), - ], - ), + ), + const SizedBox(height: 24), + ], ), ), ); } } -class _DefaultTimeTiles extends StatefulWidget { - const _DefaultTimeTiles(); +class _TimeSettingsView extends StatefulWidget { + const _TimeSettingsView({ + this.referencePreparationMinutes, + this.referenceSpareMinutes, + }); + + final int? referencePreparationMinutes; + final int? referenceSpareMinutes; + @override - State<_DefaultTimeTiles> createState() => _DefaultTimeTilesState(); + State<_TimeSettingsView> createState() => _TimeSettingsViewState(); } -class _DefaultTimeTilesState extends State<_DefaultTimeTiles> { - Future? _duration; - late final Stream? _user = getIt.isRegistered() - ? getIt()() - : null; +class _TimeSettingsViewState extends State<_TimeSettingsView> { + Future? _preparation; + Stream? _user; + @override void initState() { super.initState(); - _load(); - } - - void _load() { - _duration = getIt.isRegistered() - ? getIt()().then((p) => p.totalDuration) - : null; + if (getIt.isRegistered()) { + _preparation = getIt()(); + } + if (getIt.isRegistered()) { + _user = getIt()(); + } } Future _edit() async { await context.push('/defaultPreparationSpareTimeEdit'); - if (mounted) setState(_load); + if (mounted && getIt.isRegistered()) { + setState(() { + _preparation = getIt()(); + }); + } } - 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, + Widget build(BuildContext context) => _FrameView( + title: recurrenceText(context, '시간 설정', 'Time settings'), + child: Column( + children: [ + FutureBuilder( + future: _preparation, + builder: (context, snapshot) => _SettingTile( + icon: 'my_page_stopwatch.svg', + title: recurrenceText(context, '기본 준비시간', 'Default preparation'), + value: _minuteLabel( + widget.referencePreparationMinutes ?? + snapshot.data?.totalDuration.inMinutes, + ), + divider: true, + 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, + StreamBuilder( + stream: _user, + builder: (context, snapshot) => _SettingTile( + icon: 'my_page_stopwatch.svg', + title: recurrenceText(context, '기본 여유시간', 'Default buffer'), + value: _minuteLabel( + widget.referenceSpareMinutes ?? + snapshot.data?.spareTimeOrNull?.inMinutes, + ), + onTap: _edit, + ), ), - ), - ], + ], + ), ); + + String _minuteLabel(int? minutes) => minutes == null + ? '—' + : recurrenceText(context, '$minutes 분', '$minutes min'); } class _AppNotificationTile extends StatefulWidget { - const _AppNotificationTile({required this.service}); - final NotificationService service; + const _AppNotificationTile({required this.notificationService}); + + final NotificationService notificationService; + @override State<_AppNotificationTile> createState() => _AppNotificationTileState(); } @@ -190,6 +233,7 @@ class _AppNotificationTileState extends State<_AppNotificationTile> with WidgetsBindingObserver { bool? _enabled; bool _busy = false; + @override void initState() { super.initState(); @@ -210,12 +254,13 @@ class _AppNotificationTileState extends State<_AppNotificationTile> Future _refresh() async { try { - final status = await widget.service.checkNotificationPermission(); + final status = await widget.notificationService + .checkNotificationPermission(); if (mounted) { setState(() => _enabled = status == AuthorizationStatus.authorized); } } catch (_) { - // The permission row remains usable if the platform status is unavailable. + // Platform permission status can be unavailable while resuming the app. } } @@ -224,9 +269,12 @@ class _AppNotificationTileState extends State<_AppNotificationTile> setState(() => _busy = true); try { if (turnOff) { - await widget.service.openNotificationSettings(); + await widget.notificationService.openNotificationSettings(); } else { - await _handleNotificationPermission(context, widget.service); + await _handleNotificationPermission( + context, + widget.notificationService, + ); } await _refresh(); } finally { @@ -235,25 +283,16 @@ class _AppNotificationTileState extends State<_AppNotificationTile> } @override - Widget build(BuildContext context) => InkWell( + Widget build(BuildContext context) => _SettingTile( + icon: 'my_page_phone.svg', + title: AppLocalizations.of(context)!.allowAppNotifications, 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), - ), - ], + trailing: _SelectionSwitch( + value: _enabled ?? false, + label: AppLocalizations.of(context)!.allowAppNotifications, + onChanged: _busy || _enabled == null + ? null + : (value) => _request(turnOff: !value), ), ); } @@ -295,14 +334,24 @@ class _DetailedNotificationTileState extends State<_DetailedNotificationTile> { @override Widget build(BuildContext context) { - return SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('알림에 일정 이름 표시'), - secondary: const Icon(Icons.person_outline, size: 24), - dense: true, - activeThumbColor: Theme.of(context).colorScheme.primary, - value: _enabled, - onChanged: _loading ? null : _change, + return _SettingTile( + icon: 'my_page_person.svg', + title: recurrenceText( + context, + '알림에 일정 이름 표시', + 'Show schedule name in notifications', + ), + onTap: _loading ? null : () => _change(!_enabled), + trailing: _SelectionSwitch( + key: const Key('detailedNotificationSwitch'), + value: _enabled, + label: recurrenceText( + context, + '알림에 일정 이름 표시', + 'Show schedule name in notifications', + ), + onChanged: _loading ? null : _change, + ), ); } } @@ -464,49 +513,32 @@ class _AlarmStatusViewState extends State<_AlarmStatusView> { @override Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - final colorScheme = Theme.of(context).colorScheme; return Column( children: [ - Row( - children: [ - const Icon(Icons.notifications_none, size: 24), - const SizedBox(width: 16), - Expanded( - child: Text( - AppLocalizations.of(context)!.scheduleNotificationSetting, - style: textTheme.bodyMedium, - ), - ), - Switch( - key: const Key('alarmSettingsSwitch'), - value: _alarmsEnabled, - onChanged: _isUpdating ? null : _toggle, - ), - ], + _SettingTile( + key: const Key('alarmSettingsSwitch'), + icon: 'my_page_bell.svg', + title: recurrenceText( + context, + '일정 알림 설정', + AppLocalizations.of(context)!.scheduleNotificationSetting, + ), + divider: true, + onTap: _isUpdating ? null : () => _toggle(!_alarmsEnabled), ), - 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, - ), - ), - ), - ], + Semantics( + value: _isLoading ? '확인 중' : _statusLabel, + child: _SettingTile( + icon: 'my_page_clock.svg', + title: recurrenceText(context, '예정 알림 상태', 'Notification status'), + value: _isLoading + ? recurrenceText(context, '확인 중', 'Checking') + : _alarmsEnabled + ? recurrenceText(context, '켜짐', 'On') + : recurrenceText(context, '꺼짐', 'Off'), + valueIsStatus: true, + divider: true, + onTap: _load, ), ), ], @@ -584,7 +616,7 @@ bool _shouldRecoverNativeAlarmPermission( } class _FrameView extends StatelessWidget { - const _FrameView({required this.title, required this.child}); + const _FrameView({super.key, required this.title, required this.child}); final String title; final Widget child; @@ -592,24 +624,32 @@ class _FrameView extends StatelessWidget { @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; - final colorScheme = Theme.of(context).colorScheme; return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + padding: const EdgeInsets.symmetric(horizontal: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.only(left: 4, bottom: 6), - child: Text( - title, - style: textTheme.bodySmall!.copyWith( - color: colorScheme.outline, - fontWeight: FontWeight.w600, + SizedBox( + height: 25, + child: Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: 6), + child: Text( + title, + style: textTheme.bodySmall?.copyWith( + color: const Color(0xff545454), + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), ), ), ), - RecurrencePanel( - padding: const EdgeInsets.symmetric(horizontal: 12), + Material( + color: const Color(0xfff6f6f6), + borderRadius: BorderRadius.circular(7), + clipBehavior: Clip.antiAlias, child: child, ), ], @@ -620,45 +660,145 @@ class _FrameView extends StatelessWidget { class _SettingTile extends StatelessWidget { const _SettingTile({ + super.key, + required this.icon, required this.title, required this.onTap, - required this.icon, this.value, + this.trailing, + this.divider = false, + this.valueIsStatus = false, }); + + final String icon; final String title; - final VoidCallback onTap; - final IconData icon; + final VoidCallback? onTap; final String? value; + final Widget? trailing; + final bool divider; + final bool valueIsStatus; + @override - Widget build(BuildContext context) => InkWell( - onTap: onTap, - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 48), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: SizedBox( + height: 46, + child: Stack( children: [ - Icon(icon, size: 24), - const SizedBox(width: 16), - Expanded( - child: Text(title, style: Theme.of(context).textTheme.bodyMedium), + Row( + children: [ + const SizedBox(width: 14), + SvgPicture.asset(icon, package: 'assets'), + const SizedBox(width: 16), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13.5, height: 1.4), + ), + ), + if (value != null) + Padding( + padding: const EdgeInsets.only(right: 16), + child: DecoratedBox( + decoration: BoxDecoration( + color: valueIsStatus + ? Colors.transparent + : Colors.white, + borderRadius: BorderRadius.circular(4), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 3, + ), + child: Text( + value!, + style: TextStyle( + fontSize: valueIsStatus ? 12 : 16, + color: valueIsStatus + ? const Color(0xff4f69df) + : const Color(0xff111111), + ), + ), + ), + ), + ), + ?trailing, + if (trailing == null) + Padding( + padding: const EdgeInsets.only(right: 14), + child: SvgPicture.asset( + 'my_page_chevron.svg', + package: 'assets', + ), + ) + else + const SizedBox(width: 14), + ], ), - if (value != null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - value!, - style: Theme.of(context).textTheme.bodyMedium, + if (divider) + const Positioned( + left: 18, + right: 18, + bottom: 0, + child: Divider( + height: 1, + thickness: 1, + color: Color(0xffeeeeee), ), ), - Icon( - Icons.chevron_right, - size: 20, - color: Theme.of(context).colorScheme.outline, - ), ], ), ), + ); + } +} + +class _SelectionSwitch extends StatelessWidget { + const _SelectionSwitch({ + super.key, + required this.value, + required this.label, + required this.onChanged, + }); + + final bool value; + final String label; + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) => Semantics( + button: true, + toggled: value, + enabled: onChanged != null, + label: label, + child: InkWell( + onTap: onChanged == null ? null : () => onChanged!(!value), + child: SizedBox( + width: 44, + height: 44, + child: Center( + child: value + ? SvgPicture.asset('my_page_toggle_on.svg', package: 'assets') + : Container( + width: 40, + height: 24, + padding: const EdgeInsets.all(2), + alignment: Alignment.centerLeft, + decoration: BoxDecoration( + color: const Color(0xffd4d8e0), + borderRadius: BorderRadius.circular(12), + ), + child: const CircleAvatar( + radius: 10, + backgroundColor: Colors.white, + ), + ), + ), + ), ), ); } diff --git a/lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart b/lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart index 6cea983b..11937cd1 100644 --- a/lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart +++ b/lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart @@ -1,5 +1,6 @@ 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/di/di_setup.dart'; import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; @@ -10,6 +11,7 @@ import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_pr import 'package:on_time_front/presentation/shared/components/modal_wide_button.dart'; import 'package:on_time_front/presentation/shared/components/time_stepper.dart'; import 'package:on_time_front/presentation/shared/components/two_action_dialog.dart'; +import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; class PreparationSpareTimeEditScreen extends StatelessWidget { const PreparationSpareTimeEditScreen({super.key}); @@ -90,16 +92,30 @@ class _PreparationSpareTimeEditView extends StatelessWidget { shadowColor: Colors.transparent, scrolledUnderElevation: 0, backgroundColor: Colors.transparent, + toolbarHeight: 60, + centerTitle: true, leading: IconButton( - icon: Icon( - Icons.arrow_back_ios_rounded, - color: Theme.of(context).colorScheme.outlineVariant, + tooltip: '뒤로', + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(left: 8), + icon: SvgPicture.asset( + 'chevron_left.svg', + package: 'assets', + width: 8, + height: 14, + colorFilter: ColorFilter.mode( + AppColors.grey.shade500, + BlendMode.srcIn, + ), ), onPressed: () => context.pop(), ), title: Text( - AppLocalizations.of(context)!.editDefaultPreparation, - style: Theme.of(context).textTheme.titleLarge, + AppLocalizations.of(context)!.editPreparationSpareTimeHeader, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontSize: 19, + fontWeight: FontWeight.w600, + ), ), actions: [ BlocBuilder< @@ -115,6 +131,15 @@ class _PreparationSpareTimeEditView extends StatelessWidget { previous.isValid != current.isValid, builder: (context, preparationState) { return TextButton( + style: TextButton.styleFrom( + minimumSize: const Size(44, 44), + padding: EdgeInsets.zero, + textStyle: Theme.of(context).textTheme.titleLarge + ?.copyWith( + fontSize: 19, + fontWeight: FontWeight.w600, + ), + ), onPressed: state2.canSubmit && preparationState.isValid ? () { final currentPreparationState = context @@ -131,7 +156,7 @@ class _PreparationSpareTimeEditView extends StatelessWidget { ); } : null, - child: Text(AppLocalizations.of(context)!.ok), + child: Text(AppLocalizations.of(context)!.done), ); }, ); @@ -139,8 +164,8 @@ class _PreparationSpareTimeEditView extends StatelessWidget { ), ], bottom: const PreferredSize( - preferredSize: Size.fromHeight(33), - child: SizedBox(height: 33), + preferredSize: Size.fromHeight(17), + child: SizedBox(height: 17), ), ), body: const SafeArea(child: _PreparationSpareTimeEditBody()), @@ -234,7 +259,7 @@ class _SpareTimeSection extends StatelessWidget { child: Text( AppLocalizations.of(context)!.editSpareTime, textAlign: TextAlign.start, - style: textTheme.titleMedium, + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500), ), ), SizedBox(height: 24.0), @@ -287,7 +312,7 @@ class _PreparationSection extends StatelessWidget { child: Text( AppLocalizations.of(context)!.editPreparationTime, textAlign: TextAlign.start, - style: textTheme.titleMedium, + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500), ), ), SizedBox(height: 24.0), diff --git a/lib/presentation/my_page/privacy_policy_screen.dart b/lib/presentation/my_page/privacy_policy_screen.dart index c584a294..e6c4ae4e 100644 --- a/lib/presentation/my_page/privacy_policy_screen.dart +++ b/lib/presentation/my_page/privacy_policy_screen.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; +import 'package:on_time_front/presentation/my_page/data_state_page.dart'; class PrivacyPolicyScreen extends StatelessWidget { const PrivacyPolicyScreen({super.key}); @@ -28,60 +28,89 @@ class PrivacyPolicyScreen extends StatelessWidget { ('문의', '앱 배포 페이지에 표시된 개발자 연락처를 이용할 수 있습니다.'), ]; @override - 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)), + Widget build(BuildContext context) => DataStatePage( + title: '개인정보 처리방침', + // Keep the iOS back target 44px tall; the seven-pixel header difference is + // removed from the introductory top padding to retain the reference grid. + padding: const EdgeInsets.fromLTRB(16, 11, 16, 24), + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '시행일: 2026년 8월 28일', + style: TextStyle( + fontSize: 12, + height: 17 / 12, + color: Color(0xFF545454), + ), + ), + SizedBox(height: 10), + Text( + 'OnTime 로컬 전용 개인정보 처리방침', + style: TextStyle(fontSize: 13, height: 18 / 13), + ), + SizedBox(height: 25), + ], ), - 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, - ), + ), + for (var i = 0; i < sections.length; i++) ...[ + Container( + key: Key('privacySection${i + 1}'), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(10), + ), + child: SelectionArea( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 16, + child: Text( + '${i + 1}.', + style: const TextStyle( + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w700, + color: Color(0xFF4F69DF), ), ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - sections[i].$1, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + sections[i].$1, + style: const TextStyle( + fontSize: 13.5, + height: 20 / 13.5, + fontWeight: FontWeight.w700, ), - const SizedBox(height: 6), - Text( - sections[i].$2, - style: const TextStyle(fontSize: 13, height: 1.4), + ), + const SizedBox(height: 5), + Text( + sections[i].$2, + style: const TextStyle( + fontSize: 12.3, + height: 17 / 12.3, + color: Color(0xFF383838), ), - ], - ), + ), + ], ), - ], - ), + ), + ], ), ), + ), + const SizedBox(height: 6), ], - ), + ], ); } diff --git a/lib/presentation/notification_allow/screens/notification_allow_screen.dart b/lib/presentation/notification_allow/screens/notification_allow_screen.dart index a591e4f3..bf84e0a7 100644 --- a/lib/presentation/notification_allow/screens/notification_allow_screen.dart +++ b/lib/presentation/notification_allow/screens/notification_allow_screen.dart @@ -5,7 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:on_time_front/core/services/notification_service.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/app/cubit/notification_gate_cubit.dart'; -import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; abstract interface class NotificationPermissionGateway { Future checkNotificationPermission(); @@ -82,28 +82,32 @@ class _NotificationAllowScreenState extends State @override Widget build(BuildContext context) { - return Scaffold( - body: Padding( - padding: const EdgeInsets.only(bottom: 72.0), - child: Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - spacing: 68.50, - children: [ - Expanded( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - spacing: 40, - children: [_Image(), _Title()], + return RefreshTheme( + child: Scaffold( + body: SafeArea( + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + 16, + (MediaQuery.sizeOf(context).height * .244 - + MediaQuery.paddingOf(context).top) + .clamp(24, 206), + 16, + 24, + ), + child: const Column( + children: [_Image(), SizedBox(height: 40), _Title()], + ), ), ), - ), - _Buttons(permissionGateway: widget.permissionGateway), - ], + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: _Buttons(permissionGateway: widget.permissionGateway), + ), + ], + ), ), ), ); @@ -123,22 +127,36 @@ class _Buttons extends StatelessWidget { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, - spacing: 24, + spacing: 16, children: [ - FilledButton( - onPressed: () async { - await _handleNotificationPermission(context, permissionGateway); - }, - child: Text( - AppLocalizations.of(context)!.allowNotifications, - textAlign: TextAlign.center, - style: textTheme.titleMedium?.copyWith( - color: colorScheme.onPrimary, + SizedBox( + width: double.infinity, + height: 58, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: colorScheme.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () async { + await _handleNotificationPermission(context, permissionGateway); + }, + child: Text( + AppLocalizations.of(context)!.allowNotifications, + textAlign: TextAlign.center, + style: textTheme.titleMedium?.copyWith( + color: colorScheme.onPrimary, + ), ), ), ), - GestureDetector( - onTap: () async { + TextButton( + style: TextButton.styleFrom( + minimumSize: const Size(double.infinity, 44), + foregroundColor: const Color(0xFF545454), + ), + onPressed: () async { await context.read().dismissPrompt(); if (!context.mounted) return; context.go('/home'); @@ -149,9 +167,7 @@ class _Buttons extends StatelessWidget { AppLocalizations.of(context)!.doItLater, textAlign: TextAlign.center, style: textTheme.bodyLarge?.copyWith( - color: AppColors.grey[400], - decoration: TextDecoration.underline, - decorationColor: AppColors.grey[400], + color: const Color(0xFF545454), ), ), ), @@ -201,15 +217,16 @@ class _Image extends StatelessWidget { return Container( width: 70, height: 70, - padding: const EdgeInsets.all(17.50), + alignment: Alignment.center, decoration: ShapeDecoration( color: colorScheme.primaryContainer, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(35)), ), child: SvgPicture.asset( - 'bell-ringing.svg', - package: 'assets', - colorFilter: ColorFilter.mode(colorScheme.primary, BlendMode.srcIn), + 'assets/design/notification_bell.svg', + width: 36, + height: 37, + excludeFromSemantics: true, ), ); } diff --git a/lib/presentation/onboarding/components/onboarding_page_view_layout.dart b/lib/presentation/onboarding/components/onboarding_page_view_layout.dart index 74f5b294..5dab0228 100644 --- a/lib/presentation/onboarding/components/onboarding_page_view_layout.dart +++ b/lib/presentation/onboarding/components/onboarding_page_view_layout.dart @@ -8,12 +8,14 @@ class OnboardingPageViewLayout extends StatefulWidget { this.subTitle, this.hint, required this.child, + this.contentSpacing = 18, }); final String title; final String? hint; final RichText? subTitle; final Widget child; + final double contentSpacing; @override State createState() => @@ -23,24 +25,31 @@ class OnboardingPageViewLayout extends StatefulWidget { class _OnboardingPageViewLayoutState extends State { @override Widget build(BuildContext context) { - return Column( - children: [ - SizedBox( - width: double.infinity, - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 27.0, - horizontal: 8.0, - ), - child: OnboardingTitle( - title: widget.title, - subTitle: widget.subTitle, - hint: widget.hint, + return LayoutBuilder( + builder: (context, constraints) => Column( + children: [ + ConstrainedBox( + constraints: BoxConstraints(maxHeight: constraints.maxHeight * .6), + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.only( + top: 40, + bottom: widget.contentSpacing, + ), + child: SizedBox( + width: double.infinity, + child: OnboardingTitle( + title: widget.title, + subTitle: widget.subTitle, + hint: widget.hint, + ), + ), + ), ), ), - ), - Expanded(child: widget.child), - ], + Expanded(child: widget.child), + ], + ), ); } } diff --git a/lib/presentation/onboarding/components/onboarding_title.dart b/lib/presentation/onboarding/components/onboarding_title.dart index f9976d79..b21357b9 100644 --- a/lib/presentation/onboarding/components/onboarding_title.dart +++ b/lib/presentation/onboarding/components/onboarding_title.dart @@ -20,9 +20,14 @@ class OnboardingTitle extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ RichText( + textScaler: MediaQuery.textScalerOf(context), text: TextSpan( text: title, - style: textTheme.titleLarge, + style: textTheme.titleLarge?.copyWith( + fontSize: 24, + height: 1.4, + fontWeight: FontWeight.w500, + ), children: hint != null ? [ TextSpan( @@ -35,8 +40,10 @@ class OnboardingTitle extends StatelessWidget { : [], ), ), - SizedBox(height: 8.0), - subTitle ?? const SizedBox.shrink(), + if (subTitle != null) ...[ + const SizedBox(height: 7), + SizedBox(width: double.infinity, child: subTitle!), + ], ], ); } diff --git a/lib/presentation/onboarding/preparation_name_select/components/create_icon_button.dart b/lib/presentation/onboarding/preparation_name_select/components/create_icon_button.dart index 324ab724..b788cdb4 100644 --- a/lib/presentation/onboarding/preparation_name_select/components/create_icon_button.dart +++ b/lib/presentation/onboarding/preparation_name_select/components/create_icon_button.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; class CreateIconButton extends StatelessWidget { const CreateIconButton({super.key, required this.onCreationRequested}); @@ -7,20 +9,20 @@ class CreateIconButton extends StatelessWidget { @override Widget build(BuildContext context) { - final ColorScheme colorScheme = Theme.of(context).colorScheme; return IconButton( - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all( - colorScheme.surfaceContainerHigh, + tooltip: AppLocalizations.of(context)?.addPreparationStep, + constraints: const BoxConstraints.tightFor(width: 44, height: 44), + padding: EdgeInsets.zero, + onPressed: onCreationRequested, + icon: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: AppColors.grey[250], + borderRadius: BorderRadius.circular(16), ), + child: Icon(Icons.add, size: 24, color: AppColors.grey.shade700), ), - onPressed: () { - onCreationRequested(); - }, - color: colorScheme.onPrimary, - icon: Icon(Icons.add), - padding: EdgeInsets.zero, - iconSize: 30.0, ); } } diff --git a/lib/presentation/onboarding/preparation_name_select/components/preparation_name_select_field.dart b/lib/presentation/onboarding/preparation_name_select/components/preparation_name_select_field.dart index e6bc7287..84ec85a3 100644 --- a/lib/presentation/onboarding/preparation_name_select/components/preparation_name_select_field.dart +++ b/lib/presentation/onboarding/preparation_name_select/components/preparation_name_select_field.dart @@ -55,7 +55,11 @@ class _PreparationNameSelectFieldState padding: const EdgeInsets.only(bottom: 8.0), child: Tile( key: ValueKey(widget.preparationStep.preparationId), - style: TileStyle(padding: EdgeInsets.all(16.0)), + style: TileStyle( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + borderRadius: BorderRadius.circular(9999), + backgroundColor: const Color(0xFFF6F6F6), + ), leading: SizedBox( width: 30, height: 30, @@ -66,7 +70,7 @@ class _PreparationNameSelectFieldState ), child: Expanded( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 18.0), + padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Container( constraints: BoxConstraints(minHeight: 30), child: Center( @@ -83,7 +87,12 @@ class _PreparationNameSelectFieldState decoration: InputDecoration( isDense: true, border: InputBorder.none, - contentPadding: EdgeInsets.all(3.0), + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, ), style: textTheme.bodyLarge, focusNode: focusNode, diff --git a/lib/presentation/onboarding/preparation_name_select/cubit/preparation_name/preparation_name_state.dart b/lib/presentation/onboarding/preparation_name_select/cubit/preparation_name/preparation_name_state.dart index bb945529..d36a4a55 100644 --- a/lib/presentation/onboarding/preparation_name_select/cubit/preparation_name/preparation_name_state.dart +++ b/lib/presentation/onboarding/preparation_name_select/cubit/preparation_name/preparation_name_state.dart @@ -31,7 +31,7 @@ final class PreparationNameState extends Equatable { final onBoardingPreparationSuggestion = [ PreparationStepNameState( - preparationName: PreparationNameInputModel.dirty('화장실 가기'), + preparationName: PreparationNameInputModel.dirty('샤워하기'), isSelected: false, ), PreparationStepNameState( @@ -42,6 +42,10 @@ final onBoardingPreparationSuggestion = [ preparationName: PreparationNameInputModel.dirty('머리 세팅하기'), isSelected: false, ), + PreparationStepNameState( + preparationName: PreparationNameInputModel.dirty('옷 고르기/입기'), + isSelected: false, + ), PreparationStepNameState( preparationName: PreparationNameInputModel.dirty('짐 챙기기'), isSelected: false, diff --git a/lib/presentation/onboarding/preparation_order/components/reorderable_tile.dart b/lib/presentation/onboarding/preparation_order/components/reorderable_tile.dart index 1eda7b38..a60ddb87 100644 --- a/lib/presentation/onboarding/preparation_order/components/reorderable_tile.dart +++ b/lib/presentation/onboarding/preparation_order/components/reorderable_tile.dart @@ -11,13 +11,17 @@ class ReorderableTile extends StatelessWidget { required this.index, }); - final dragIndicatorSvg = SvgPicture.asset( - 'drag_indicator.svg', - package: 'assets', - semanticsLabel: 'drag indicator', - height: 14, - width: 14, - fit: BoxFit.contain, + final dragIndicatorSvg = SizedBox( + height: 24, + width: 24, + child: Center( + child: SvgPicture.asset( + 'assets/design/onboarding_drag.svg', + semanticsLabel: 'drag indicator', + height: 16, + width: 18, + ), + ), ); final PreparationStepOrderState preparationStepOrderState; final int index; @@ -28,8 +32,10 @@ class ReorderableTile extends StatelessWidget { final textTheme = Theme.of(context).textTheme; return Tile( style: TileStyle( - backgroundColor: Color(0xFFE6E9F9), - padding: EdgeInsets.all(18.0) + EdgeInsets.only(right: 15), + backgroundColor: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(9999), + minimumSize: const Size(0, 62), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), ), leading: Container( height: 22, @@ -43,8 +49,8 @@ class ReorderableTile extends StatelessWidget { (index + 1).toString(), style: TextStyle( color: colorScheme.surface, - fontSize: 10.48, - fontWeight: FontWeight.w600, + fontSize: 14, + fontWeight: FontWeight.w400, height: 1.4, ), ), @@ -54,12 +60,12 @@ class ReorderableTile extends StatelessWidget { index: index, child: dragIndicatorSvg, ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Text( - preparationStepOrderState.preparationName, - style: textTheme.bodyLarge?.copyWith( - color: colorScheme.onPrimaryContainer, + child: Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + preparationStepOrderState.preparationName, + style: textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface), ), ), ), diff --git a/lib/presentation/onboarding/preparation_order/screens/preparation_order_form.dart b/lib/presentation/onboarding/preparation_order/screens/preparation_order_form.dart index d0dcf513..1b951720 100644 --- a/lib/presentation/onboarding/preparation_order/screens/preparation_order_form.dart +++ b/lib/presentation/onboarding/preparation_order/screens/preparation_order_form.dart @@ -22,6 +22,7 @@ class _PreparationOrderFormState extends State { @override Widget build(BuildContext context) { return OnboardingPageViewLayout( + contentSpacing: 40, title: AppLocalizations.of(context)!.preparationOrderTitle, child: BlocBuilder( builder: (context, state) { diff --git a/lib/presentation/onboarding/preparation_time/components/preparation_time_input_list.dart b/lib/presentation/onboarding/preparation_time/components/preparation_time_input_list.dart index d8bc86c6..d3158bdc 100644 --- a/lib/presentation/onboarding/preparation_time/components/preparation_time_input_list.dart +++ b/lib/presentation/onboarding/preparation_time/components/preparation_time_input_list.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; import 'package:on_time_front/presentation/onboarding/preparation_time/components/preparation_time_tile.dart'; import 'package:on_time_front/presentation/onboarding/preparation_time/cubit/preparation_time_cubit.dart'; @@ -23,7 +22,9 @@ class _PreparationTimeInputFieldListState @override Widget build(BuildContext context) { return SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 16), child: ListView.builder( + padding: EdgeInsets.zero, physics: NeverScrollableScrollPhysics(), shrinkWrap: true, itemCount: widget.preparationTimeList.length, diff --git a/lib/presentation/onboarding/preparation_time/components/preparation_time_tile.dart b/lib/presentation/onboarding/preparation_time/components/preparation_time_tile.dart index df52d355..4453564b 100644 --- a/lib/presentation/onboarding/preparation_time/components/preparation_time_tile.dart +++ b/lib/presentation/onboarding/preparation_time/components/preparation_time_tile.dart @@ -1,11 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/onboarding/preparation_time/cubit/preparation_time_cubit.dart'; -import 'package:on_time_front/presentation/shared/components/cupertino_picker_modal.dart'; -import 'package:on_time_front/presentation/shared/components/tile.dart'; -import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; -import 'package:on_time_front/presentation/shared/theme/tile_style.dart'; -class PreparationTimeTile extends StatelessWidget { +class PreparationTimeTile extends StatefulWidget { const PreparationTimeTile({ super.key, required this.value, @@ -15,66 +13,122 @@ class PreparationTimeTile extends StatelessWidget { final PreparationStepTimeState value; final int index; + final void Function(int index, Duration value) onPreparationTimeChanged; - final Function(int index, Duration value) onPreparationTimeChanged; + @override + State createState() => _PreparationTimeTileState(); +} + +class _PreparationTimeTileState extends State { + late final TextEditingController _controller = TextEditingController( + text: widget.value.preparationTime.value.inMinutes.toString().padLeft( + 2, + '0', + ), + ); + final _focusNode = FocusNode(); + + void _focusAndSelect() { + _focusNode.requestFocus(); + _controller.selection = TextSelection( + baseOffset: 0, + extentOffset: _controller.text.length, + ); + } + + @override + void didUpdateWidget(covariant PreparationTimeTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (!_focusNode.hasFocus && + oldWidget.value.preparationTime.value != + widget.value.preparationTime.value) { + _controller.text = widget.value.preparationTime.value.inMinutes + .toString() + .padLeft(2, '0'); + } + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - return Tile( - style: TileStyle( - margin: EdgeInsets.only(bottom: 8), - backgroundColor: Color(0xFFE6E9F9), - padding: EdgeInsets.only(left: 36, right: 40, top: 18, bottom: 18), - ), - trailing: Row( - children: [ - GestureDetector( - child: Container( - decoration: BoxDecoration( - color: AppColors.blue.shade300, - borderRadius: BorderRadius.circular(4), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - vertical: 1.0, - ), + final colors = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _focusAndSelect, + child: Container( + constraints: const BoxConstraints(minHeight: 62), + decoration: BoxDecoration( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(9999), + ), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 9), + child: Row( + children: [ + Expanded( child: Text( - (value.preparationTime.value.inMinutes < 10 ? '0' : '') + - (value.preparationTime.value.inMinutes < 0 - ? '0' - : value.preparationTime.value.inMinutes.toString()), - style: textTheme.titleSmall?.copyWith( - color: colorScheme.onPrimaryContainer, + widget.value.preparationName, + style: const TextStyle(fontSize: 16, height: 1.4), + ), + ), + const SizedBox(width: 12), + Semantics( + label: + '${widget.value.preparationName} ${l10n.preparationTime} (${l10n.minutes})', + child: SizedBox( + width: Localizations.localeOf(context).languageCode == 'ko' + ? (_controller.text.length > 4 ? 108 : 68) + : (_controller.text.length > 4 ? 152 : 112), + child: TextFormField( + key: ValueKey('onboardingMinutes${widget.index}'), + controller: _controller, + focusNode: _focusNode, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16, height: 1.4), + decoration: InputDecoration( + filled: true, + fillColor: colors.primaryContainer, + suffixText: l10n.minutes, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 10, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: BorderSide.none, + ), + ), + onTap: _focusAndSelect, + onChanged: (text) => widget.onPreparationTimeChanged( + widget.index, + Duration(minutes: int.tryParse(text) ?? 0), + ), + onFieldSubmitted: (_) => _focusNode.unfocus(), + onTapOutside: (_) => _focusNode.unfocus(), ), ), ), - ), - onTap: () { - context.showCupertinoMinutePickerModal( - title: '시간을 선택해주세요', - initialValue: value.preparationTime.value, - onSaved: (value) { - onPreparationTimeChanged(index, value); - }, - ); - }, + ], ), - SizedBox(width: 10), - Text( - '분', - style: textTheme.bodyLarge?.copyWith( - color: colorScheme.onPrimaryContainer, - ), - ), - ], - ), - child: Text( - value.preparationName, - style: textTheme.bodyLarge?.copyWith( - color: colorScheme.onPrimaryContainer, ), ), ); diff --git a/lib/presentation/onboarding/preparation_time/screens/preparation_time_form.dart b/lib/presentation/onboarding/preparation_time/screens/preparation_time_form.dart index 6cce3e0b..ea0b7f83 100644 --- a/lib/presentation/onboarding/preparation_time/screens/preparation_time_form.dart +++ b/lib/presentation/onboarding/preparation_time/screens/preparation_time_form.dart @@ -21,21 +21,36 @@ class _PreparationTimeFormState extends State { @override Widget build(BuildContext context) { - return OnboardingPageViewLayout( - title: AppLocalizations.of(context)!.preparationTimeTitle, - child: BlocBuilder( - builder: (context, state) { - return PreparationTimeInputFieldList( + return BlocBuilder( + builder: (context, state) { + final total = state.preparationTimeList.fold( + 0, + (sum, step) => sum + step.preparationTime.value.inMinutes, + ); + return OnboardingPageViewLayout( + title: AppLocalizations.of(context)!.preparationTimeTitle, + contentSpacing: 12, + subTitle: RichText( + textAlign: TextAlign.right, + textScaler: MediaQuery.textScalerOf(context), + text: TextSpan( + text: + '${AppLocalizations.of(context)!.totalTime}$total${Localizations.localeOf(context).languageCode == 'ko' ? '분' : ' min'}', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 16, + height: 1.4, + color: Color(0xFF545454), + ), + ), + ), + child: PreparationTimeInputFieldList( preparationTimeList: state.preparationTimeList, - onPreparationTimeChanged: (index, value) { - context.read().preparationTimeChanged( - index, - value, - ); - }, - ); - }, - ), + onPreparationTimeChanged: (index, value) => context + .read() + .preparationTimeChanged(index, value), + ), + ); + }, ); } } diff --git a/lib/presentation/onboarding/screens/onboarding_screen.dart b/lib/presentation/onboarding/screens/onboarding_screen.dart index 2d95b782..183838ce 100644 --- a/lib/presentation/onboarding/screens/onboarding_screen.dart +++ b/lib/presentation/onboarding/screens/onboarding_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/svg.dart'; import 'package:go_router/go_router.dart'; import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; import 'package:on_time_front/presentation/onboarding/preparation_order/screens/preparation_order_form.dart'; import 'package:on_time_front/presentation/onboarding/preparation_name_select/cubit/preparation_name/preparation_name_cubit.dart'; import 'package:on_time_front/presentation/onboarding/preparation_name_select/screens/preparation_name_form.dart'; @@ -13,7 +14,6 @@ import 'package:on_time_front/presentation/onboarding/schedule_spare_time/screen import 'package:on_time_front/presentation/onboarding/cubit/onboarding_cubit.dart'; import 'package:on_time_front/presentation/onboarding/preparation_order/cubit/preparation_order_cubit.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'; @@ -24,7 +24,12 @@ class OnboardingScreen extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) => getIt.get(), - child: Scaffold(resizeToAvoidBottomInset: false, body: _OnboardingForm()), + child: const RefreshTheme( + child: Scaffold( + resizeToAvoidBottomInset: false, + body: _OnboardingForm(), + ), + ), ); } } @@ -66,7 +71,12 @@ class _OnboardingFormState extends State<_OnboardingForm> Widget build(BuildContext context) { return SafeArea( child: Padding( - padding: const EdgeInsets.all(16.0), + padding: EdgeInsets.fromLTRB( + 16, + 12, + 16, + 13 + MediaQuery.viewInsetsOf(context).bottom, + ), child: MultiBlocProvider( providers: [ BlocProvider( @@ -111,25 +121,34 @@ class _OnboardingFormState extends State<_OnboardingForm> ], ), ), - SizedBox( - height: 58, - width: double.infinity, - child: ElevatedButton( - onPressed: - !_isSubmitting && - context.select( - (OnboardingCubit cubit) => cubit.state.isValid, + if (MediaQuery.viewInsetsOf(context).bottom == 0) + SizedBox( + height: 58, + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of( + context, + ).colorScheme.primary, + ), + onPressed: + !_isSubmitting && + context.select( + (OnboardingCubit cubit) => + cubit.state.isValid, + ) + ? () => _onNextPageButtonClicked(context) + : null, + child: _isSubmitting + ? const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + ), ) - ? () => _onNextPageButtonClicked(context) - : null, - child: _isSubmitting - ? const SizedBox.square( - dimension: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(AppLocalizations.of(context)!.next), + : Text(AppLocalizations.of(context)!.next), + ), ), - ), ], ); }, @@ -207,7 +226,7 @@ class _OnboardingFormState extends State<_OnboardingForm> } class _AppBar extends StatelessWidget { - _AppBar({ + const _AppBar({ required this.tabController, required this.onUpdateCurrentPageIndex, }); @@ -215,43 +234,96 @@ class _AppBar extends StatelessWidget { final TabController tabController; final void Function(int) onUpdateCurrentPageIndex; - final SvgPicture _previousIcon = SvgPicture.asset( - 'chevron_left.svg', - package: 'assets', - semanticsLabel: 'Previous Icon', - fit: BoxFit.contain, - ); - @override - Widget build(BuildContext context) { - const double iconButtonSize = 32.0; - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - SizedBox( - width: iconButtonSize, - height: iconButtonSize, - child: IconButton( - padding: EdgeInsets.zero, - onPressed: () { - onUpdateCurrentPageIndex(tabController.index - 1); - }, - icon: _previousIcon, + Widget build(BuildContext context) => SizedBox( + height: 39, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: -8, + top: -4, + child: SizedBox( + width: 44, + height: 44, + child: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: () => + onUpdateCurrentPageIndex(tabController.index - 1), + icon: SvgPicture.asset( + 'chevron_left.svg', + package: 'assets', + width: 8, + height: 14, + colorFilter: const ColorFilter.mode( + Colors.black, + BlendMode.srcIn, + ), + ), + ), ), ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: StepProgress( - currentStep: tabController.index, - totalSteps: tabController.length, + Positioned.fill( + left: 20, + child: Center( + child: Semantics( + label: 'STEP ${tabController.index + 1} / 4', + child: ExcludeSemantics( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < 3; i++) ...[ + SvgPicture.asset( + 'assets/design/onboarding_progress_${i < tabController.index + ? 'complete' + : i == tabController.index + ? 'current' + : 'pending'}.svg', + width: 74, + height: 11, + ), + const SizedBox(width: 6), + ], + SvgPicture.asset( + 'assets/design/onboarding_progress_last_${tabController.index == 3 ? 'current' : 'pending'}.svg', + width: 11, + height: 11, + ), + ], + ), + const SizedBox(height: 9), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < 4; i++) ...[ + if (i > 0) const SizedBox(width: 38), + Text( + 'STEP ${i + 1}', + textScaler: TextScaler.noScaling, + style: TextStyle( + fontSize: 12, + height: 1.4, + color: i <= tabController.index + ? const Color(0xFF4F69DF) + : const Color(0xFF949494), + ), + ), + ], + ], + ), + ], + ), + ), + ), ), ), ), - SizedBox(width: iconButtonSize), ], - ); - } + ), + ); } diff --git a/lib/presentation/onboarding/screens/onboarding_start_screen.dart b/lib/presentation/onboarding/screens/onboarding_start_screen.dart index 5e442409..69d5a1bd 100644 --- a/lib/presentation/onboarding/screens/onboarding_start_screen.dart +++ b/lib/presentation/onboarding/screens/onboarding_start_screen.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; import 'package:go_router/go_router.dart'; -import 'package:on_time_front/presentation/shared/theme/theme.dart'; +import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; class OnboardingStartScreen extends StatelessWidget { @@ -9,28 +8,35 @@ class OnboardingStartScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 35), - child: Center( - child: Column( - children: [ - Expanded( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _Title(), - SizedBox(height: 37), - _OnboardingCharacterImage(), - ], - ), + return RefreshTheme( + child: Scaffold( + body: SafeArea( + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + 16, + (MediaQuery.sizeOf(context).height * .174 - + MediaQuery.paddingOf(context).top) + .clamp(24, 147), + 16, + 24, + ), + child: const Column( + children: [ + _Title(), + SizedBox(height: 46), + _OnboardingCharacterImage(), + ], ), ), - _OnboardingStartButton(), - ], - ), + ), + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 13), + child: _OnboardingStartButton(), + ), + ], ), ), ), @@ -57,7 +63,7 @@ class _Title extends StatelessWidget { AppLocalizations.of(context)!.onboardingStartSubtitle, textAlign: TextAlign.center, key: Key('onboarding_start_subtitle'), - style: textTheme.titleExtraSmall, + style: textTheme.bodyLarge?.copyWith(color: const Color(0xFF777777)), ), ], ); @@ -69,12 +75,11 @@ class _OnboardingCharacterImage extends StatelessWidget { @override Widget build(BuildContext context) { - return SvgPicture.asset( - 'characters/onboarding_character.svg', - package: 'assets', - semanticsLabel: 'character onboarding', - height: 271, - width: 280, + return Image.asset( + 'assets/design/onboarding_greeting.png', + excludeFromSemantics: true, + height: 280, + width: 271, fit: BoxFit.contain, ); } @@ -87,7 +92,11 @@ class _OnboardingStartButton extends StatelessWidget { Widget build(BuildContext context) { return SizedBox( width: double.infinity, + height: 58, child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + ), onPressed: () { context.go('/onboarding'); }, diff --git a/lib/presentation/recurring/recurrence_components.dart b/lib/presentation/recurring/recurrence_components.dart index 343fca2d..1a09b6a9 100644 --- a/lib/presentation/recurring/recurrence_components.dart +++ b/lib/presentation/recurring/recurrence_components.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.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 { @@ -31,6 +31,7 @@ class RecurrenceSheet extends StatelessWidget { this.footer, this.showBack = true, this.spacing = 16, + this.contentPadding = const EdgeInsets.fromLTRB(17, 15, 17, 16), }); final String title; final String? action; @@ -40,6 +41,7 @@ class RecurrenceSheet extends StatelessWidget { final Widget? footer; final bool showBack; final double spacing; + final EdgeInsets contentPadding; @override Widget build(BuildContext context) => RefreshTheme( child: Builder( @@ -54,19 +56,40 @@ class RecurrenceSheet extends StatelessWidget { children: [ SizedBox( height: 44, - child: TopBar( - title: title, - showAction: false, - onPreviousPageButtonClicked: showBack - ? onBack ?? () => Navigator.of(context).maybePop() - : null, - onNextPageButtonClicked: null, - isNextButtonEnabled: false, + child: Row( + children: [ + SizedBox( + width: 64, + child: IconButton( + tooltip: recurrenceText(context, '뒤로', 'Back'), + onPressed: showBack + ? onBack ?? () => Navigator.of(context).maybePop() + : null, + icon: SvgPicture.asset( + 'recurrence_scope_tonemuted.svg', + package: 'assets', + ), + ), + ), + Expanded( + child: Center( + child: Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + height: 1.4, + ), + ), + ), + ), + const SizedBox(width: 64), + ], ), ), Expanded( child: ListView( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + padding: contentPadding, children: [ for (final child in children) Padding( @@ -78,7 +101,7 @@ class RecurrenceSheet extends StatelessWidget { ), if (footer != null || action != null) Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + padding: const EdgeInsets.fromLTRB(17, 8, 17, 13), child: footer ?? ScreenActions( @@ -191,7 +214,7 @@ class RecurrencePanel extends StatelessWidget { color: highlighted ? Theme.of(context).colorScheme.primaryContainer : Theme.of(context).colorScheme.surfaceContainerLowest, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(10), child: SizedBox( width: double.infinity, child: Padding(padding: padding, child: child), @@ -223,26 +246,25 @@ class RecurrenceChoice extends StatelessWidget { color: selected ? colors.primaryContainer : colors.surfaceContainerLowest, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(12), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(8), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(top: 1), - child: Icon( + child: SvgPicture.asset( selected - ? Icons.radio_button_checked - : Icons.radio_button_off, - color: selected ? colors.primary : AppColors.grey.shade700, - size: 24, + ? 'recurrence_scope_stateselected.svg' + : 'recurrence_scope_stateunselected.svg', + package: 'assets', ), ), - const SizedBox(width: 12), + const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -251,6 +273,8 @@ class RecurrenceChoice extends StatelessWidget { label, style: Theme.of(context).textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w600, + fontSize: 14, + height: 1.55, ), ), if (description != null) ...[ @@ -258,7 +282,10 @@ class RecurrenceChoice extends StatelessWidget { Text( description!, style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: AppColors.grey.shade700), + ?.copyWith( + color: AppColors.grey.shade700, + height: 1.5, + ), ), ], if (child != null) @@ -285,12 +312,14 @@ class RecurrenceValue extends StatelessWidget { required this.value, this.onTap, this.icon, + this.asset, this.compact = false, }); final String label; final String value; final VoidCallback? onTap; final IconData? icon; + final String? asset; final bool compact; @override Widget build(BuildContext context) => Material( @@ -303,8 +332,11 @@ class RecurrenceValue extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( children: [ - if (icon != null) ...[ - Icon(icon, size: 24), + if (icon != null || asset != null) ...[ + if (asset != null) + SvgPicture.asset(asset!, package: 'assets') + else + Icon(icon, size: 24), const SizedBox(width: 16), ], Expanded( @@ -339,3 +371,52 @@ class RecurrenceValue extends StatelessWidget { ), ); } + +/// The Figma information symbol composes a provided circle asset and a text glyph. +class RecurrenceNotice extends StatelessWidget { + const RecurrenceNotice({ + super.key, + required this.child, + this.asset = 'recurrence_scope_vector.svg', + this.glyph = '!', + this.glyphColor = Colors.white, + this.highlighted = false, + this.bare = false, + }); + final Widget child; + final String asset; + final String glyph; + final Color glyphColor; + final bool highlighted; + final bool bare; + @override + Widget build(BuildContext context) { + final row = Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 22, + height: 22, + child: Stack( + alignment: Alignment.center, + children: [ + SvgPicture.asset(asset, package: 'assets'), + Text( + glyph, + style: TextStyle( + fontSize: 15, + height: 1.1, + fontWeight: FontWeight.w700, + color: glyphColor, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded(child: child), + ], + ); + return bare ? row : RecurrencePanel(highlighted: highlighted, child: row); + } +} diff --git a/lib/presentation/recurring/recurrence_labels.dart b/lib/presentation/recurring/recurrence_labels.dart index e3eb2593..a37b65d6 100644 --- a/lib/presentation/recurring/recurrence_labels.dart +++ b/lib/presentation/recurring/recurrence_labels.dart @@ -45,3 +45,29 @@ String recurrenceEndLabel(BuildContext context, RecurrenceRule rule) => : rule.until != null ? '${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(rule.until!)}${recurrenceText(context, '까지', ' inclusive')}' : recurrenceText(context, '종료 없음', 'No end date'); + +/// A compact recurrence label for the date/time and review cards. +String recurrencePatternLabel( + BuildContext context, + RecurrenceRule rule, { + bool includeTime = false, +}) { + if (Localizations.localeOf(context).languageCode != 'ko') { + return recurrenceLabel(context, rule); + } + final pattern = switch (rule.frequency) { + RecurrenceFrequency.daily => + rule.interval == 1 ? '매일' : '${rule.interval}일마다', + RecurrenceFrequency.weekly => + '${rule.interval == 1 ? '매주' : '${rule.interval}주마다'} ${(rule.weekdays.toList()..sort()).map((d) => weekdayLabel(context, d)).join(' · ')}', + RecurrenceFrequency.monthly => + '${rule.interval == 1 ? '매월' : '${rule.interval}개월마다'} ${switch (rule.monthly) { + MonthlyRecurrence.dayOfMonth => '${rule.monthDay}일', + MonthlyRecurrence.lastDay => '마지막 날', + MonthlyRecurrence.nthWeekday => '${rule.ordinal == -1 ? '마지막' : '${rule.ordinal}번째'} ${weekdayLabel(context, rule.monthWeekday)}요일', + }}', + }; + return includeTime + ? '$pattern ${DateFormat.jm('ko').format(rule.start)}' + : pattern; +} diff --git a/lib/presentation/recurring/recurrence_occurrence_sheet.dart b/lib/presentation/recurring/recurrence_occurrence_sheet.dart index 051bdad6..521c3bb8 100644 --- a/lib/presentation/recurring/recurrence_occurrence_sheet.dart +++ b/lib/presentation/recurring/recurrence_occurrence_sheet.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:flutter_svg/flutter_svg.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'; @@ -9,94 +11,172 @@ class RecurrenceOccurrenceSheet extends StatelessWidget { required this.schedule, this.onEdit, this.onDelete, + this.now, }); final ScheduleEntity schedule; final VoidCallback? onEdit; final VoidCallback? onDelete; + final DateTime Function()? now; @override Widget build(BuildContext context) { final unrecorded = !schedule.isStarted && schedule.doneStatus == ScheduleDoneStatus.notEnded && - schedule.occurrenceInstantUtc.isBefore(DateTime.now().toUtc()); + schedule.occurrenceInstantUtc.isBefore( + (now?.call() ?? DateTime.now()).toUtc(), + ); return RecurrenceSheet( title: schedule.scheduleName, footer: ScreenActions( action: recurrenceText(context, '뒤로', 'Back'), onAction: () => Navigator.of(context).pop(), ), + spacing: 0, + contentPadding: const EdgeInsets.fromLTRB(17, 22, 17, 16), 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, + recurrenceText(context, '이번 회차', 'This occurrence'), + style: const TextStyle(fontSize: 14), ), - if (schedule.recurringOverrides.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - recurrenceText( - context, - '이 회차는 개별 수정되었습니다.', - 'This occurrence has individual changes.', - ), + const SizedBox(height: 3), + Text( + DateFormat( + recurrenceText( + context, + schedule.scheduleTime.minute == 0 + ? 'M/d EEEE H시' + : 'M/d EEEE H시 mm분', + 'MMM d EEEE HH:mm', + ), + Localizations.localeOf(context).toString(), + ).format(schedule.scheduleTime), + style: const TextStyle( + fontSize: 23, + height: 1.3, + fontWeight: FontWeight.w700, + ), + ), + if (schedule.recurringOverrides.isNotEmpty) ...[ + const SizedBox(height: 5), + Text( + recurrenceText( + context, + '이 회차는 개별 수정되었습니다.', + 'This occurrence has individual changes.', + ), + style: const TextStyle( + fontSize: 12, + color: Color(0xff545454), ), ), + ], ], ), ), - 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.'), + const SizedBox(height: 20), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.symmetric(vertical: 18), + child: Row( + children: [ + Text( + recurrenceText(context, '진행 기록', 'Preparation record'), + style: const TextStyle(fontSize: 16), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + unrecorded + ? recurrenceText( + context, + '진행 기록이 없습니다.', + 'No preparation recorded.', + ) + : schedule.isStarted + ? recurrenceText( + context, + '준비 진행 기록이 있습니다.', + 'Preparation has been recorded.', + ) + : recurrenceText( + context, + '예정된 일정입니다.', + 'Upcoming occurrence.', + ), + textAlign: TextAlign.end, + style: const TextStyle( + fontSize: 12, + color: Color(0xff545454), + ), + ), + ), + ], + ), ), - if (unrecorded) - RecurrencePanel( + const Divider(height: 1), + if (unrecorded) ...[ + const SizedBox(height: 16), + RecurrenceNotice( highlighted: true, + glyph: 'i', + asset: 'recurrence_review_ellipse.svg', child: Text( recurrenceText( context, '이 회차는 별도 출근 진행 기록을 남기지 않습니다. 따라서 완료, 지각 결과 및 점수가 자동으로 생성되지 않습니다.', 'No completion, late result, or score is generated automatically for an unrecorded occurrence.', ), + style: const TextStyle(fontSize: 13, height: 1.6), ), ), + ], + const SizedBox(height: 24), if (onEdit != null) - RecurrenceValue( - label: recurrenceText(context, '일정 수정', 'Edit'), - value: recurrenceText(context, '수정하기', 'Edit occurrence'), - onTap: () { - Navigator.of(context).pop(); - onEdit!(); - }, + _action( + context, + recurrenceText(context, '수정하기', 'Edit occurrence'), + onEdit!, ), - if (onDelete != null) - RecurrenceValue( - label: recurrenceText(context, '일정 삭제', 'Delete'), - value: recurrenceText(context, '이 회차 삭제하기', 'Delete occurrence'), - onTap: () { - Navigator.of(context).pop(); - onDelete!(); - }, + if (onDelete != null) ...[ + const SizedBox(height: 8), + _action( + context, + recurrenceText(context, '이 회차 삭제하기', 'Delete occurrence'), + onDelete!, ), + ], ], ); } + + Widget _action(BuildContext context, String label, VoidCallback callback) => + Material( + color: const Color(0xfff6f6f6), + borderRadius: BorderRadius.circular(8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () { + Navigator.of(context).pop(); + callback(); + }, + child: Padding( + padding: const EdgeInsets.all(15), + child: Row( + children: [ + Expanded( + child: Text(label, style: const TextStyle(fontSize: 16)), + ), + SvgPicture.asset( + 'recurrence_occurrence_frame.svg', + package: 'assets', + ), + ], + ), + ), + ), + ); } diff --git a/lib/presentation/recurring/recurrence_review_sheet.dart b/lib/presentation/recurring/recurrence_review_sheet.dart index 04183c0e..de9d248e 100644 --- a/lib/presentation/recurring/recurrence_review_sheet.dart +++ b/lib/presentation/recurring/recurrence_review_sheet.dart @@ -1,5 +1,6 @@ import 'package:intl/intl.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:on_time_front/core/time/civil_time_resolver.dart'; import 'package:on_time_front/domain/recurrence/recurrence_engine.dart'; import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; @@ -51,6 +52,13 @@ class _RecurrenceReviewSheetState extends State { : recurrenceText(context, '반복 일정 확인', 'Review recurrence'); return RecurrenceSheet( title: title, + spacing: 12, + contentPadding: EdgeInsets.fromLTRB( + 16, + review.conflicts.isEmpty && review.detached.isEmpty ? 0 : 15, + 16, + 16, + ), action: review.persistentConflict ? recurrenceText(context, '반복 설정 수정', 'Change settings') : recurrenceText( @@ -68,303 +76,564 @@ class _RecurrenceReviewSheetState extends State { ? () => Navigator.of(context).pop(Set.from(_excluded)) : null, children: [ - if (review.persistentConflict) ...[ - RecurrencePanel( - highlighted: true, + if (review.persistentConflict) + ..._persistent(form, review) + else if (byKey.isNotEmpty) + ..._conflicts(form, review, byKey, remaining.length, canExclude) + else if (review.detached.isNotEmpty) + ..._detached(review) + else + ..._summary(form, review, remaining.toList()), + if (review.totalOccurrences != null && review.conflicts.isEmpty) + Text( + recurrenceText( + context, + '반복 ${review.totalOccurrences! - _excluded.length}회${review.detached.isEmpty ? '' : ' + 독립 일정 ${review.detached.length}회'}', + '${review.totalOccurrences! - _excluded.length} recurring occurrences', + ), + style: const TextStyle(fontSize: 12), + ), + if (review.skipped.isNotEmpty) ...[ + RecurrenceNotice( + asset: 'recurrence_review_ellipse.svg', + glyph: 'i', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - recurrenceText( - context, - '반복 일정이 계속 겹쳐요', - 'These recurring rules keep overlapping', + recurrenceText(context, '건너뛴 날짜', 'Skipped dates'), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, ), - style: Theme.of(context).textTheme.titleMedium, ), - const SizedBox(height: 8), - Text( - recurrenceText( - context, - '시간이나 요일을 수정해야 저장할 수 있어요.', - 'Change the time or repeat pattern before saving.', + const SizedBox(height: 6), + for (final skip in review.skipped.take(5)) + Text( + _skip(context, skip), + style: const TextStyle( + fontSize: 12, + height: 1.6, + color: Color(0xff545454), + ), ), - ), ], ), ), - if (form.recurrenceRule != null) - RecurrenceValue( - label: recurrenceText(context, '반복 요일', 'Repeat pattern'), - value: recurrenceLabel(context, form.recurrenceRule!), - ), - RecurrenceValue( - label: form.scheduleName ?? '', - value: recurrenceDate(context, form.scheduleTime!), - ), - for (final conflict in review.conflicts.take(3)) - RecurrenceValue( - label: conflict.other.scheduleName, - value: recurrenceDate(context, conflict.other.scheduleTime), + if (review.skipped.length > 5) + TextButton( + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => SizedBox( + height: MediaQuery.sizeOf(context).height * .9, + child: RecurrenceSheet( + title: recurrenceText(context, '건너뛴 날짜', 'Skipped dates'), + children: [ + for (final skip in review.skipped) + Text(_skip(context, skip)), + ], + ), + ), + ), + child: Text( + recurrenceText( + context, + '건너뛴 날짜 ${review.skipped.length}개 모두 보기', + 'View all ${review.skipped.length} skipped dates', + ), + ), ), - ] else if (byKey.isNotEmpty) ...[ + ], + ], + ); + } + + String _clock(DateTime time) => + DateFormat.jm(Localizations.localeOf(context).toString()).format(time); + String _day(DateTime time) => DateFormat( + recurrenceText(context, 'M월 d일 (E)', 'MMM d (E)'), + Localizations.localeOf(context).toString(), + ).format(time); + + List _persistent(ScheduleFormState form, RecurrenceReview review) => [ + RecurrencePanel( + highlighted: true, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( recurrenceText( context, - canExclude - ? '겹치는 회차를 확인하고 제외할 일정을 직접 선택해 주세요.' - : '다른 일정과 겹쳐요. 시간이나 준비과정을 조정해 주세요.', - canExclude - ? 'Select the conflicting occurrences to exclude.' - : 'This overlaps another schedule. Adjust the time or preparation.', + '반복 일정이 계속 겹쳐요', + 'These recurring rules keep overlapping', ), - ), - 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(' · ')}', - ), - ), - ], + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Color(0xff4f69df), ), ), + const SizedBox(height: 8), Text( recurrenceText( context, - '전체 ${review.slots.length}회차 · 선택 ${_excluded.length}개 제외', - '${review.slots.length} occurrences · ${_excluded.length} excluded', + '시간이나 요일을 수정해야 저장할 수 있어요.', + 'Change the time or repeat pattern before saving.', ), - style: Theme.of(context).textTheme.titleSmall, + style: const TextStyle(fontSize: 14), ), - 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, - ), - ), - ), - RecurrencePanel( - child: Text( - recurrenceText( - context, - '모든 회차를 제외하면 저장할 수 없습니다. 제외한 회차는 총횟수에서 차감되며 다른 날짜로 채우지 않아요.', - 'At least one occurrence must remain. Excluded occurrences consume the count and are not replaced.', + ], + ), + ), + const SizedBox(height: 4), + Text( + recurrenceText(context, '반복 요일', 'Repeat pattern'), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + if (form.recurrenceRule != null) + Wrap( + spacing: 7, + runSpacing: 8, + children: [ + for (var day = 1; day <= 7; day++) + Container( + width: 44, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: form.recurrenceRule!.weekdays.contains(day) + ? const Color(0xff4f69df) + : const Color(0xfff6f6f6), ), - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ] else ...[ - if (review.detached.isEmpty) ...[ - Text( - recurrenceText( - context, - '아래 내용으로 반복 일정을 저장할까요?', - 'Save this recurring schedule?', + child: Text( + weekdayLabel(context, day), + style: TextStyle( + fontSize: 14, + color: form.recurrenceRule!.weekdays.contains(day) + ? Colors.white + : const Color(0xff545454), + ), ), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, ), - RecurrencePanel( - highlighted: true, - child: Column( + ], + ), + const SizedBox(height: 0), + _periodPanel( + form.scheduleName ?? '', + form.scheduleTime!.subtract( + form.totalPreparationTime + + (form.moveTime ?? Duration.zero) + + (form.scheduleSpareTime ?? Duration.zero), + ), + form.scheduleTime!, + ), + for (final conflict in review.conflicts.take(3)) + _periodPanel( + conflict.other.scheduleName, + null, + conflict.other.scheduleTime, + ), + ]; + + Widget _periodPanel( + String title, + DateTime? start, + DateTime end, + ) => RecurrencePanel( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 5), + Text( + start == null + ? '${recurrenceText(context, '약속 시각', 'Appointment time')} ${_clock(end)}' + : '${_clock(start)} – ${_clock(end)}', + style: const TextStyle(fontSize: 14, color: Color(0xff545454)), + ), + ], + ), + ); + + List _conflicts( + ScheduleFormState form, + RecurrenceReview review, + Map> byKey, + int remaining, + bool canExclude, + ) => [ + Text( + recurrenceText( + context, + canExclude + ? '개인 일정을 고려해 참석이 어려운 회차를 선택해 주세요.\n선택한 회차는 일정에서 제외됩니다.' + : '다른 일정과 겹쳐요. 시간이나 준비과정을 조정해 주세요.', + canExclude + ? 'Select the conflicting occurrences to exclude.' + : 'This overlaps another schedule. Adjust the time or preparation.', + ), + style: const TextStyle( + fontSize: 13, + height: 1.6, + color: Color(0xff545454), + ), + ), + RecurrencePanel( + highlighted: true, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Column( + children: [ + for (final entry in byKey.entries) + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( 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, + SizedBox( + width: 93, + child: Text( + _day( + review.occurrences[entry.key]?.schedule.scheduleTime ?? + DateTime.parse(entry.key), + ), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Color(0xff4f69df), ), - ), - 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))}', + const SizedBox(width: 8), + Expanded( + child: Text( + recurrenceText( + context, + '${entry.value.map((c) => c.other.scheduleName).toSet().join(' · ')}이 있어 참석이 어렵습니다.', + entry.value + .map((c) => c.other.scheduleName) + .toSet() + .join(' · '), ), + style: const TextStyle(fontSize: 12), ), + ), ], ), ), - if (remaining.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - recurrenceText(context, '예정된 날짜', 'Upcoming occurrences'), - style: Theme.of(context).textTheme.titleSmall, - ), - RecurrencePanel( - child: Column( + ], + ), + ), + Row( + children: [ + Expanded( + child: Text( + recurrenceText( + context, + '전체 ${review.slots.length}회차', + '${review.slots.length} occurrences', + ), + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700), + ), + ), + Text( + recurrenceText( + context, + '선택 ${_excluded.length}개 제외 · $remaining개 저장 예정', + '$remaining remaining', + ), + style: const TextStyle(fontSize: 10, color: Color(0xff545454)), + ), + ], + ), + Column( + children: [ + for (var index = 0; index < review.slots.length; index++) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: RecurrencePanel( + padding: EdgeInsets.zero, + highlighted: _excluded.contains(review.slots[index].key), + child: CheckboxListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + dense: true, + visualDensity: const VisualDensity(vertical: -4), + minTileHeight: 38, + value: _excluded.contains(review.slots[index].key), + onChanged: canExclude + ? (v) => setState( + () => v == true + ? _excluded.add(review.slots[index].key) + : _excluded.remove(review.slots[index].key), + ) + : null, + title: Row( 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), - ), - ], + SizedBox( + width: 64, + child: Text( + recurrenceText( + context, + '${index + 1}회차', + '#${index + 1}', ), + style: const TextStyle(fontSize: 12), ), - ], + ), + Expanded( + child: Text( + _day(_time(review.slots[index])), + style: const TextStyle(fontSize: 12), + ), + ), ], ), ), - ], - ], - if (review.detached.isNotEmpty) ...[ + ), + ), + ], + ), + RecurrenceNotice( + asset: 'recurrence_detached_vector.svg', + glyph: 'i', + glyphColor: const Color(0xff545454), + child: Text( + recurrenceText( + context, + '모든 회차를 제외하면 저장할 수 없습니다.\n제외한 회차는 총횟수에서 차감되며 다른 날짜로 채우지 않아요.', + 'At least one occurrence must remain. Excluded occurrences consume the count and are not replaced.', + ), + style: const TextStyle( + fontSize: 11, + height: 1.6, + color: Color(0xff545454), + ), + ), + ), + ]; + + List _summary( + ScheduleFormState form, + RecurrenceReview review, + List remaining, + ) => [ + Text( + recurrenceText( + context, + '아래 내용으로 반복 일정을 저장할까요?', + 'Save this recurring schedule?', + ), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, + height: 1.5, + color: Color(0xff545454), + ), + ), + RecurrencePanel( + highlighted: true, + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + form.scheduleName ?? '', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.4, + ), + ), + const SizedBox(height: 3), + if (form.recurrenceRule != null) Text( - recurrenceText( + recurrencePatternLabel( context, - '규칙 변경으로 기존과 다른 일정이 발생하는 회차가 있습니다. 아래 일정을 단독 일정으로 유지하고 저장할까요?', - 'Individually edited occurrences differ from the new rule. Keep these as standalone schedules?', - ), - ), - for (final value in review.detached) - RecurrenceValue( - label: value.scheduleName, - value: recurrenceDate(context, value.scheduleTime), + form.recurrenceRule!, + includeTime: true, ), - RecurrencePanel( - highlighted: true, - child: Text( - recurrenceText( - context, - '개별 수정한 일정은 기존 시간과 준비과정을 그대로 유지합니다. 분리되는 일정도 남은 횟수에 포함돼요.', - 'Detached schedules count toward the remaining total and keep their time and preparation.', - ), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + height: 1.5, ), ), - ], - if (review.totalOccurrences != null) + if (remaining.isNotEmpty) Text( - recurrenceText( - context, - '반복 ${review.totalOccurrences! - _excluded.length}회${review.detached.isEmpty ? '' : ' + 독립 일정 ${review.detached.length}회'}', - '${review.totalOccurrences! - _excluded.length} recurring occurrences', + '${recurrenceText(context, '첫 일정', 'First occurrence')}: ${_day(_time(remaining.first))}', + style: const TextStyle( + fontSize: 12, + height: 1.7, + color: Color(0xff545454), ), - style: Theme.of(context).textTheme.bodySmall, ), - ], - if (review.skipped.isNotEmpty) ...[ - RecurrencePanel( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + const Divider(height: 28), + Row( + children: [ + SvgPicture.asset( + 'recurrence_review_clock.svg', + package: 'assets', + ), + const SizedBox(width: 8), + Expanded( + child: 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', + ), + style: const TextStyle(fontSize: 12, height: 1.6), + ), + ), + ], + ), + if (remaining.isNotEmpty && + review.occurrences[remaining.first.key] != null) ...[ + const SizedBox(height: 8), + Row( children: [ - Text( - recurrenceText(context, '건너뛴 날짜', 'Skipped dates'), - style: Theme.of(context).textTheme.titleSmall, + SvgPicture.asset( + 'recurrence_review_play.svg', + package: 'assets', ), - 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, - ), + const SizedBox(width: 8), + Expanded( + child: Text( + '${recurrenceText(context, '준비 시작', 'Preparation starts')} ${_clock(CivilTimeResolver.civilTimeAt(review.occurrences[remaining.first.key]!.preparationStartUtc, review.occurrences[remaining.first.key]!.schedule.timeZoneId))}', + style: const TextStyle(fontSize: 12, height: 1.6), ), + ), ], ), + ], + ], + ), + ), + if (remaining.isNotEmpty) ...[ + const SizedBox(height: 0), + Row( + children: [ + Expanded( + child: Text( + recurrenceText(context, '예정된 날짜', 'Upcoming occurrences'), + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700), + ), ), - if (review.skipped.length > 5) - TextButton( - onPressed: () => showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - builder: (context) => SizedBox( - height: MediaQuery.sizeOf(context).height * .9, - child: RecurrenceSheet( - title: recurrenceText(context, '건너뛴 날짜', 'Skipped dates'), - children: [ - for (final skip in review.skipped) - Text(_skip(context, skip)), - ], + Text( + recurrenceText( + context, + '처음 ${remaining.take(3).length}개의 일정을 확인했어요.', + 'First occurrences', + ), + style: const TextStyle(fontSize: 11, color: Color(0xff545454)), + ), + ], + ), + RecurrencePanel( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Column( + children: [ + for (final slot in remaining.take(3)) ...[ + if (slot != remaining.first) const Divider(height: 16), + Row( + children: [ + Expanded( + child: Text( + _day(_time(slot)), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), ), - ), + Text( + _clock(_time(slot)), + style: const TextStyle( + fontSize: 12, + color: Color(0xff545454), + ), + ), + ], ), - child: Text( - recurrenceText( - context, - '건너뛴 날짜 ${review.skipped.length}개 모두 보기', - 'View all ${review.skipped.length} skipped dates', + ], + ], + ), + ), + ], + ]; + + List _detached(RecurrenceReview review) => [ + Text( + recurrenceText( + context, + '규칙 변경으로 인해 기존과 다른 일정이 발생하는 회차가 있습니다. 아래 일정을 단독 일정으로 유지하고 저장할까요?', + 'Individually edited occurrences differ from the new rule. Keep these as standalone schedules?', + ), + style: const TextStyle( + fontSize: 14, + height: 1.8, + color: Color(0xff545454), + ), + ), + for (final value in review.detached) + RecurrencePanel( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + DateFormat( + recurrenceText(context, 'M월 d일 EEEE', 'MMM d EEEE'), + Localizations.localeOf(context).toString(), + ).format(value.scheduleTime), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 18), + Row( + children: [ + SvgPicture.asset( + 'recurrence_detached_timeicon.svg', + package: 'assets', ), - ), + const SizedBox(width: 12), + Text( + _clock(value.scheduleTime), + style: const TextStyle( + fontSize: 16, + color: Color(0xff545454), + ), + ), + ], ), - ], - ], - ); - } + ], + ), + ), + RecurrenceNotice( + highlighted: true, + asset: 'recurrence_detached_vector.svg', + glyph: 'i', + glyphColor: const Color(0xff212f6f), + child: Text( + recurrenceText( + context, + '개별 수정한 일정은 기존 시간과 준비과정을 그대로 유지합니다. 분리되는 일정도 남은 횟수에 포함돼요.', + 'Detached schedules count toward the remaining total and keep their time and preparation.', + ), + style: const TextStyle( + fontSize: 13, + height: 1.7, + color: Color(0xff212f6f), + ), + ), + ), + ]; DateTime _time(RecurrenceSlot slot) => widget.review.occurrences[slot.key]?.schedule.scheduleTime ?? diff --git a/lib/presentation/recurring/recurrence_scope_sheet.dart b/lib/presentation/recurring/recurrence_scope_sheet.dart index 6f0c1a25..1c6f6702 100644 --- a/lib/presentation/recurring/recurrence_scope_sheet.dart +++ b/lib/presentation/recurring/recurrence_scope_sheet.dart @@ -32,6 +32,7 @@ class _ScopeSheetState extends State<_ScopeSheet> { widget.deleting ? '삭제 범위' : '변경 범위', widget.deleting ? 'Delete scope' : 'Edit scope', ), + spacing: 10, footer: ScreenActions( action: recurrenceText( context, @@ -43,9 +44,20 @@ class _ScopeSheetState extends State<_ScopeSheet> { onAction: () => Navigator.of(context).pop(_scope), ), children: [ - Text( - recurrenceText(context, '어떤 일정까지 변경할지 선택해 주세요.', 'Which occurrences?'), - style: Theme.of(context).textTheme.titleLarge, + Padding( + padding: const EdgeInsets.only(top: 4, bottom: 11), + child: Text( + recurrenceText( + context, + '어떤 일정까지 변경할지 선택해 주세요.', + 'Which occurrences?', + ), + style: const TextStyle( + fontSize: 14, + height: 1.6, + color: Color(0xff545454), + ), + ), ), for (final scope in RecurringEditScope.values) RecurrenceChoice( @@ -68,15 +80,26 @@ class _ScopeSheetState extends State<_ScopeSheet> { selected: _scope == scope, onTap: () => setState(() => _scope = scope), ), - Text( - recurrenceText( - context, - widget.deleting - ? '이후 삭제에는 개별 수정한 회차도 포함돼요. 진행 중·과거 회차는 유지합니다.' - : '개별 수정한 항목과 진행 중·과거 회차는 유지합니다.', - widget.deleting - ? 'Following occurrences include individual edits. Active and historical occurrences are preserved.' - : 'Individual overrides, active preparations and history are preserved.', + Padding( + padding: const EdgeInsets.only(top: 16), + child: RecurrenceNotice( + bare: true, + child: Text( + recurrenceText( + context, + widget.deleting + ? '이후 삭제에는 개별 수정한 회차도 포함돼요. 진행 중·과거 회차는 유지합니다.' + : '이미 진행 중이거나 완료된 회차는 변경되지 않으며,\n이전에 개별적으로 수정한 항목도 그대로 유지됩니다.', + widget.deleting + ? 'Following occurrences include individual edits. Active and historical occurrences are preserved.' + : 'Individual overrides, active preparations and history are preserved.', + ), + style: const TextStyle( + fontSize: 12, + height: 1.6, + color: Color(0xff545454), + ), + ), ), ), ], diff --git a/lib/presentation/recurring/recurrence_settings_sheet.dart b/lib/presentation/recurring/recurrence_settings_sheet.dart index fcfb1023..6062b8b9 100644 --- a/lib/presentation/recurring/recurrence_settings_sheet.dart +++ b/lib/presentation/recurring/recurrence_settings_sheet.dart @@ -1,6 +1,8 @@ import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:intl/intl.dart'; import 'package:on_time_front/domain/recurrence/recurrence_engine.dart'; import 'package:on_time_front/presentation/recurring/recurrence_components.dart'; import 'package:on_time_front/presentation/recurring/recurrence_labels.dart'; @@ -20,12 +22,14 @@ class RecurrenceSettingsSheet extends StatefulWidget { this.initial, this.allowNone = true, this.leadTime = Duration.zero, + this.now, }); final DateTime start; final String timeZoneId; final RecurrenceRule? initial; final bool allowNone; final Duration leadTime; + final DateTime Function()? now; @override State createState() => _RecurrenceSettingsSheetState(); @@ -143,7 +147,10 @@ class _RecurrenceSettingsSheetState extends State { bool compact = false, }) => TextFormField( controller: controller, - style: const TextStyle(fontSize: 16), + style: TextStyle( + fontSize: 16, + fontWeight: compact ? FontWeight.w700 : FontWeight.w400, + ), textAlign: compact ? TextAlign.center : TextAlign.start, decoration: InputDecoration( labelText: compact ? null : label, @@ -169,7 +176,7 @@ class _RecurrenceSettingsSheetState extends State { .expand( r, through: r.until ?? DateTime(widget.start.year + 50, 12, 31), - preparationNotBeforeUtc: DateTime.now(), + preparationNotBeforeUtc: widget.now?.call() ?? DateTime.now(), leadTime: widget.leadTime, limit: 1, ) @@ -221,15 +228,24 @@ class _RecurrenceSettingsSheetState extends State { 'frequency' => '반복 단위', 'ending' => '반복 종료', 'monthly' => '월간 규칙 입력', + 'monthlyOverview' => '월간 반복', _ => '반복 설정', }, switch (_page) { 'frequency' => 'Frequency', 'ending' => 'Ends', 'monthly' => 'Monthly rule', + 'monthlyOverview' => 'Monthly recurrence', _ => 'Repeat', }, ), + spacing: _page == 'settings' ? 0 : 8, + contentPadding: EdgeInsets.fromLTRB( + _page == 'settings' ? 22 : 17, + _page == 'settings' ? 0 : 15, + _page == 'settings' ? 22 : 17, + 16, + ), action: recurrenceText(context, '적용', 'Apply'), onBack: _back, onAction: () { @@ -244,6 +260,8 @@ class _RecurrenceSettingsSheetState extends State { ..._frequencyFields() else if (_page == 'ending') ..._endingFields() + else if (_page == 'monthlyOverview') + ..._monthlyOverview() else if (_page == 'monthly') ..._monthlyFields() else @@ -272,7 +290,7 @@ class _RecurrenceSettingsSheetState extends State { }), ), if (_frequency >= 0) ...[ - const SizedBox(height: 8), + const SizedBox(height: 16), _number( _interval, recurrenceText( @@ -284,7 +302,7 @@ class _RecurrenceSettingsSheetState extends State { Text( recurrenceText( context, - '첫 일정부터 선택한 간격으로 반복됩니다.', + '예시: ${['매일', '매주', '매월'][_frequency]} · ${_interval.text}${['일', '주', '개월'][_frequency]}마다\n첫 일정부터 선택한 간격으로 반복됩니다.', 'Repeats at the selected interval from the first occurrence.', ), ), @@ -298,9 +316,20 @@ class _RecurrenceSettingsSheetState extends State { '일정을 언제, 얼마나 자주 반복할지 설정하세요.', 'Choose when and how often to repeat.', ), - style: Theme.of(context).textTheme.bodySmall, + style: const TextStyle( + fontSize: 12, + height: 20 / 12, + color: Color(0xff545454), + ), ), - _heading('반복 단위', 'Frequency'), + const SizedBox(height: 25), + GestureDetector( + onTap: widget.allowNone + ? () => setState(() => _page = 'frequency') + : null, + child: _heading('반복 단위', 'Frequency'), + ), + const SizedBox(height: 10), Row( children: [ for (var i = 0; i < 3; i++) ...[ @@ -308,13 +337,14 @@ class _RecurrenceSettingsSheetState extends State { Expanded( child: TextButton( style: TextButton.styleFrom( - minimumSize: const Size(44, 44), + minimumSize: const Size(44, 38), + padding: EdgeInsets.zero, backgroundColor: _frequency == i ? AppColors.blue.shade600 - : Theme.of(context).colorScheme.surfaceContainerLowest, + : const Color(0xfff6f6f6), foregroundColor: _frequency == i - ? Theme.of(context).colorScheme.onPrimary - : Theme.of(context).colorScheme.onSurface, + ? Colors.white + : const Color(0xff111111), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), @@ -322,6 +352,7 @@ class _RecurrenceSettingsSheetState extends State { onPressed: () => setState(() { _frequency = i; _error = null; + if (i == 2) _page = 'monthlyOverview'; }), child: Text( recurrenceText( @@ -340,20 +371,24 @@ class _RecurrenceSettingsSheetState extends State { ], ), if (_frequency >= 0) ...[ + const SizedBox(height: 23), _heading('${['일', '주', '월'][_frequency]} 반복 간격', 'Repeat interval'), + const SizedBox(height: 12), Row( children: [ - Text(recurrenceText(context, '매', 'Every')), - const SizedBox(width: 8), SizedBox( - width: 64, + width: 20, + child: Text(recurrenceText(context, '매', 'Every')), + ), + SizedBox( + width: 56, child: _number( _interval, recurrenceText(context, '간격', 'Interval'), compact: true, ), ), - const SizedBox(width: 12), + const SizedBox(width: 10), Expanded( child: Text( recurrenceText( @@ -366,7 +401,9 @@ class _RecurrenceSettingsSheetState extends State { ], ), if (_frequency == 1) ...[ + const SizedBox(height: 23), _heading('반복 요일', 'Repeat on'), + const SizedBox(height: 10), LayoutBuilder( builder: (context, constraints) => Wrap( spacing: constraints.maxWidth >= 344 @@ -390,12 +427,10 @@ class _RecurrenceSettingsSheetState extends State { ), backgroundColor: _weekdays.contains(day) ? AppColors.blue.shade600 - : Theme.of( - context, - ).colorScheme.surfaceContainerLowest, + : const Color(0xfff6f6f6), foregroundColor: _weekdays.contains(day) - ? Theme.of(context).colorScheme.onPrimary - : Theme.of(context).colorScheme.onSurface, + ? Colors.white + : const Color(0xff545454), ), onPressed: () => setState(() { _weekdays.contains(day) @@ -417,36 +452,56 @@ class _RecurrenceSettingsSheetState extends State { ), ), ], - if (_frequency == 2) + if (_frequency == 2) ...[ + const SizedBox(height: 18), 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'), + value: recurrenceLabel(context, _rule()), + onTap: () => setState(() => _page = 'monthlyOverview'), ), + ], + const SizedBox(height: 29), _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', + const SizedBox(height: 8), + Material( + color: const Color(0xfff6f6f6), + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: () => setState(() => _page = 'ending'), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + Expanded( + child: Text( + recurrenceText( + context, + _end == 0 + ? '종료 없음' + : _end == 1 + ? recurrenceDay(context, _until) + : '총 ${_count.text}회', + _end == 0 + ? 'Never' + : _end == 1 + ? recurrenceDay(context, _until) + : 'After ${_count.text} occurrences', + ), + style: const TextStyle(fontSize: 14), + ), + ), + const Icon( + Icons.chevron_right, + size: 20, + color: Color(0xff545454), + ), + ], + ), + ), ), - onTap: () => setState(() => _page = 'ending'), ), + const SizedBox(height: 23), RecurrencePanel( highlighted: true, child: Column( @@ -454,39 +509,200 @@ class _RecurrenceSettingsSheetState extends State { children: [ Text( recurrenceText(context, '반복 예시', 'Preview'), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppColors.blue.shade600, + style: const TextStyle( + fontSize: 12, + height: 20 / 12, + color: Color(0xff4f69df), fontWeight: FontWeight.w700, ), ), - const SizedBox(height: 12), + const SizedBox(height: 10), Text( _preview(), - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(height: 1.8), + style: const TextStyle(fontSize: 13, height: 25 / 13), ), ], ), ), + if (widget.allowNone) + Padding( + padding: const EdgeInsets.only(top: 8), + child: TextButton( + onPressed: () => setState(() => _page = 'frequency'), + child: Text( + recurrenceText( + context, + '반복 단위 변경 / 반복 안 함', + 'Change frequency / do not repeat', + ), + style: const TextStyle(fontSize: 12), + ), + ), + ), ], - if (widget.allowNone) - TextButton( - onPressed: () => setState(() => _page = 'frequency'), - child: Text( - recurrenceText( - context, - '반복 단위 변경 / 반복 안 함', - 'Change frequency / do not repeat', + ]; + + List _monthlyOverview() => [ + _heading('반복 간격', 'Repeat interval'), + Text( + recurrenceText( + context, + '몇 개월마다 반복할지 선택하세요.', + 'Choose the monthly interval.', + ), + style: const TextStyle(fontSize: 12, color: Color(0xff545454)), + ), + RecurrencePanel( + padding: const EdgeInsets.all(6), + child: Row( + children: [ + IconButton( + onPressed: (int.tryParse(_interval.text) ?? 1) > 1 + ? () => setState( + () => _interval.text = '${int.parse(_interval.text) - 1}', + ) + : null, + icon: const Icon(Icons.remove), + ), + Expanded( + child: Center( + child: Text( + recurrenceText( + context, + '${_interval.text} 개월마다', + 'Every ${_interval.text} months', + ), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), ), - style: const TextStyle(fontSize: 12), + IconButton( + onPressed: () => setState( + () => + _interval.text = '${(int.tryParse(_interval.text) ?? 1) + 1}', + ), + icon: const Icon(Icons.add), + ), + ], + ), + ), + const SizedBox(height: 8), + InkWell( + onTap: () => setState(() => _page = 'monthly'), + child: _heading('월 규칙', 'Monthly rule'), + ), + Text( + recurrenceText( + context, + '매월 날짜를 정하는 방식을 선택하세요.', + 'Choose how to determine each monthly date.', + ), + style: const TextStyle(fontSize: 12, color: Color(0xff545454)), + ), + for (var option = 0; option < 4; option++) + RecurrenceChoice( + label: recurrenceText( + context, + ['날짜 지정', '순번 요일', '마지막 요일', '말일'][option], + ['Day of month', 'Nth weekday', 'Last weekday', 'Last day'][option], + ), + description: recurrenceText( + context, + [ + '매월 같은 날짜에 반복합니다.', + '예) 매월 첫째 월요일, 셋째 금요일', + '예) 매월 마지막 목요일', + '매월 마지막 날에 반복합니다.', + ][option], + 'Monthly repeat pattern', + ), + selected: switch (option) { + 0 => _monthly == MonthlyRecurrence.dayOfMonth, + 1 => _monthly == MonthlyRecurrence.nthWeekday && _ordinal != -1, + 2 => _monthly == MonthlyRecurrence.nthWeekday && _ordinal == -1, + _ => _monthly == MonthlyRecurrence.lastDay, + }, + onTap: () => setState(() { + _monthly = switch (option) { + 0 => MonthlyRecurrence.dayOfMonth, + 1 || 2 => MonthlyRecurrence.nthWeekday, + _ => MonthlyRecurrence.lastDay, + }; + if (option == 1) { + if (_ordinal == -1) _ordinal = 1; + _page = 'monthly'; + } + if (option == 2) { + _ordinal = -1; + _page = 'monthly'; + } + }), + child: _monthly == MonthlyRecurrence.dayOfMonth && option == 0 + ? DropdownButtonFormField( + initialValue: int.tryParse(_monthDay.text), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 14, + color: const Color(0xff111111), + ), + decoration: InputDecoration( + isDense: true, + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: Color(0xff949494)), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + items: [ + for (var day = 1; day <= 31; day++) + DropdownMenuItem( + value: day, + child: Text( + recurrenceText(context, '$day일', 'Day $day'), + style: const TextStyle(fontSize: 14), + ), + ), + ], + onChanged: (v) => setState(() => _monthDay.text = '$v'), + ) + : null, + ), + RecurrenceNotice( + highlighted: true, + asset: 'recurrence_review_ellipse.svg', + glyph: 'i', + child: Text( + recurrenceText( + context, + '선택한 날짜가 없는 달에는 해당 일정을 건너뜁니다. 이 경우 총 횟수에서 제외됩니다.', + 'Months without the selected date are skipped and do not consume the count.', ), + style: const TextStyle(fontSize: 11, height: 1.5), ), + ), ]; String _preview() { try { - return '${recurrenceLabel(context, _rule())}\n${_first()}'; + final r = _rule(); + final ko = Localizations.localeOf(context).languageCode == 'ko'; + if (ko && r.frequency == RecurrenceFrequency.weekly) { + final days = (r.weekdays.toList()..sort()) + .map((d) => weekdayLabel(context, d)) + .join('·'); + return '매주 $days에 반복됩니다.\n${r.interval}주마다, ${recurrenceEndLabel(context, r)}\n${_first()}'; + } + return '${recurrenceLabel(context, r)}\n${_first()}'; } catch (_) { return _first(); } @@ -549,6 +765,11 @@ class _RecurrenceSettingsSheetState extends State { ), RecurrenceChoice( label: recurrenceText(context, '매월 마지막 날에 반복', 'Last day of month'), + description: recurrenceText( + context, + '예) 매월 마지막 날', + 'The last date of every month', + ), selected: _monthly == MonthlyRecurrence.lastDay, onTap: () => setState(() => _monthly = MonthlyRecurrence.lastDay), ), @@ -558,6 +779,11 @@ class _RecurrenceSettingsSheetState extends State { '특정 요일의 마지막 날에 반복', 'Last weekday of month', ), + description: recurrenceText( + context, + '예) 매월 마지막 수요일', + 'For example, the last Wednesday', + ), selected: _monthly == MonthlyRecurrence.nthWeekday && _ordinal == -1, onTap: () => setState(() { _monthly = MonthlyRecurrence.nthWeekday; @@ -567,7 +793,10 @@ class _RecurrenceSettingsSheetState extends State { ? _weekdayField() : null, ), - RecurrencePanel( + RecurrenceNotice( + asset: 'recurrence_detached_vector.svg', + glyph: 'i', + glyphColor: const Color(0xff545454), child: Text( recurrenceText( context, @@ -592,13 +821,19 @@ class _RecurrenceSettingsSheetState extends State { ); List _endingFields() => [ - Text( - recurrenceText( - context, - '언제까지 반복할지 선택하세요.', - 'Choose when the series ends.', + if (_end != 1) + Text( + recurrenceText( + context, + '언제까지 반복할지 선택하세요.', + 'Choose when the series ends.', + ), + style: const TextStyle( + fontSize: 12, + height: 20 / 12, + color: Color(0xff545454), + ), ), - ), for (var i = 0; i < 3; i++) RecurrenceChoice( label: recurrenceText( @@ -618,39 +853,71 @@ class _RecurrenceSettingsSheetState extends State { selected: _end == i, onTap: () => setState(() => _end = i), child: i == 2 && _end == 2 - ? _number( - _count, - recurrenceText(context, '반복 횟수', 'Occurrence count'), + ? Row( + children: [ + SizedBox( + width: 86, + child: _number( + _count, + recurrenceText(context, '반복 횟수', 'Occurrence count'), + compact: true, + ), + ), + const SizedBox(width: 10), + Text( + recurrenceText(context, '회', 'occurrences'), + style: const TextStyle(fontSize: 14), + ), + ], ) : 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); - }, + if (_end == 1) ...[ + const SizedBox(height: 8), + Text( + recurrenceText(context, '마지막 날짜', 'Inclusive end date'), + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), ), - RecurrencePanel( + InkWell( + onTap: _pickUntil, + child: Container( + constraints: const BoxConstraints(minHeight: 44), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Color(0xff949494))), + ), + child: Row( + children: [ + Expanded( + child: Text( + DateFormat('yyyy. M. d.').format(_until), + style: const TextStyle(fontSize: 16), + ), + ), + SvgPicture.asset( + 'recurrence_date_time_calendar.svg', + package: 'assets', + width: 22, + height: 22, + ), + const SizedBox(width: 8), + ], + ), + ), + ), + const SizedBox(height: 3), + ], + RecurrenceNotice( + asset: _end == 1 + ? 'recurrence_detached_vector.svg' + : 'recurrence_ending_ellipse.svg', + glyph: 'i', + glyphColor: _end == 1 ? const Color(0xff4f69df) : Colors.white, highlighted: _end == 1, child: Text( recurrenceText( context, _end == 1 - ? '지정한 날짜가 포함되어 반복이 종료됩니다. 일정의 시간대를 기준으로 적용됩니다.' + ? '지정한 날짜(${DateFormat('yyyy. M. d.').format(_until)})가 포함되어 반복이 종료됩니다. 일정의 시간대를 기준으로 적용됩니다.' : '실제로 존재하는 회차만 횟수에 포함됩니다. 삭제한 회차는 보충되지 않습니다.', _end == 1 ? 'The end date is inclusive, in the schedule time zone.' @@ -660,4 +927,18 @@ class _RecurrenceSettingsSheetState extends State { ), ), ]; + Future _pickUntil() 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); + } } diff --git a/lib/presentation/recurring/recurrence_time_choice_sheet.dart b/lib/presentation/recurring/recurrence_time_choice_sheet.dart index ef6e68ad..fd109af4 100644 --- a/lib/presentation/recurring/recurrence_time_choice_sheet.dart +++ b/lib/presentation/recurring/recurrence_time_choice_sheet.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:on_time_front/core/time/civil_time_resolver.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'; @@ -24,7 +27,7 @@ class _RecurrenceTimeChoiceSheetState extends State { action: recurrenceText(context, '적용', 'Apply'), onAction: () => Navigator.of(context).pop(_choice), children: [ - const SizedBox(height: 24), + const SizedBox(height: 16), Text( recurrenceText(context, '반복 시각 확인', 'Choose the repeated time'), style: Theme.of(context).textTheme.headlineSmall, @@ -40,15 +43,18 @@ class _RecurrenceTimeChoiceSheetState extends State { RecurrenceChoice( label: recurrenceText( context, - choice == RepeatedCivilTime.first ? '첫 번째 시각' : '두 번째 시각', + '${choice == RepeatedCivilTime.first ? '첫 번째' : '두 번째'} ${DateFormat('H:mm').format(widget.date)}', choice == RepeatedCivilTime.first ? 'First occurrence' : 'Second occurrence', ), + description: _offsetDescription(context, choice), selected: _choice == choice, onTap: () => setState(() => _choice = choice), ), - RecurrencePanel( + RecurrenceNotice( + asset: 'recurrence_time_exceptions_info.svg', + glyph: 'i', child: Text( recurrenceText( context, @@ -60,6 +66,14 @@ class _RecurrenceTimeChoiceSheetState extends State { ), ], ); + String _offsetDescription(BuildContext context, RepeatedCivilTime choice) { + final options = CivilTimeResolver.resolve(widget.date, widget.timeZoneId); + if (options.length != 2) { + return recurrenceText(context, '선택한 시간대 기준', 'In the selected time zone'); + } + final option = options[choice.index]; + return '${CivilTimeResolver.formatUtcOffset(option.offsetSeconds)} · ${DateFormat('H:mm').format(widget.date)}'; + } } class RecurrenceSaveErrorSheet extends StatelessWidget { @@ -71,18 +85,60 @@ class RecurrenceSaveErrorSheet extends StatelessWidget { action: recurrenceText(context, '다시 저장하기', 'Retry saving'), onAction: () => Navigator.of(context).pop(true), children: [ - const SizedBox(height: 12), RecurrencePanel( - child: Column( + padding: const EdgeInsets.all(22), + child: Row( 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.', + SizedBox( + width: 28, + height: 28, + child: Stack( + alignment: Alignment.center, + children: [ + SvgPicture.asset( + 'recurrence_save_error_vector1.svg', + package: 'assets', + ), + const Text( + '!', + style: TextStyle( + fontSize: 20, + height: 1, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], + ), + ), + const SizedBox(width: 22), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + message, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + height: 1.5, + ), + ), + const SizedBox(height: 6), + Text( + recurrenceText( + context, + '입력한 내용은 유지되었어요. 조건을 확인하거나 다시 시도해 주세요.', + 'Your input is preserved. Check the conditions or retry.', + ), + style: const TextStyle( + fontSize: 13.5, + height: 1.5, + color: Color(0xff5d6272), + ), + ), + ], ), ), ], diff --git a/lib/presentation/recurring/recurring_management_screen.dart b/lib/presentation/recurring/recurring_management_screen.dart index 5eae2cd4..e5e60dc3 100644 --- a/lib/presentation/recurring/recurring_management_screen.dart +++ b/lib/presentation/recurring/recurring_management_screen.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.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/recurrence/recurrence_rule.dart'; import 'package:on_time_front/domain/use-cases/recurring_schedules_use_case.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/screens/schedule_edit_screen.dart'; -import 'package:on_time_front/presentation/shared/components/modal_wide_button.dart'; class RecurringManagementScreen extends StatefulWidget { const RecurringManagementScreen({super.key, this.useCase}); @@ -45,133 +47,425 @@ class _RecurringManagementScreenState extends State { if (changed == true && mounted) _reload(); } + Future _create() async { + final saved = await context.push('/scheduleCreate'); + if (saved == true && mounted) _reload(); + } + @override Widget build(BuildContext context) => Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + surfaceTintColor: Colors.white, + centerTitle: true, + toolbarHeight: 36, + leadingWidth: 48, + leading: IconButton( + tooltip: recurrenceText(context, '뒤로', 'Back'), + onPressed: () => Navigator.of(context).pop(), + icon: SvgPicture.asset('chevron_left.svg', package: 'assets'), + ), + title: Text( + recurrenceText(context, '반복 일정 관리', 'Recurring schedules'), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + bottom: const PreferredSize( + preferredSize: Size.fromHeight(7), + child: Column( + children: [ + SizedBox(height: 6), + Divider(height: 1, thickness: 1, color: Color(0xff949494)), + ], + ), + ), + ), body: FutureBuilder>( 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()) - else if (snapshot.hasError) ...[ - Text( - recurrenceText( - context, - '반복 일정을 불러오지 못했어요.', - 'Could not load recurring schedules.', - ), - ), - TextButton( - onPressed: _reload, - child: Text(recurrenceText(context, '다시 시도', 'Retry')), - ), - ] else if (snapshot.data!.isEmpty) ...[ - 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.', + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + recurrenceText( + context, + '반복 일정을 불러오지 못했어요.', + 'Could not load recurring schedules.', + ), ), - ), + TextButton( + onPressed: _reload, + child: Text(recurrenceText(context, '다시 시도', 'Retry')), + ), + ], ), - const SizedBox(height: 8), - Text( - recurrenceText( + ); + } + final schedules = snapshot.data!; + if (schedules.isEmpty) { + return _RecurringEmptyView(onCreate: _create); + } + final active = schedules.where((s) => s.next != null).toList(); + final ended = schedules.where((s) => s.next == null).toList(); + return ListView( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 24), + children: [ + _RecurringSectionTitle( + title: recurrenceText( context, - '등록된 반복 일정 · 0개', - 'Recurring schedules · 0', + '진행 중인 반복 일정', + 'Active recurring schedules', ), - style: Theme.of(context).textTheme.titleSmall, + count: active.length, ), - RecurrencePanel( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 44), - child: Text( - recurrenceText( + const SizedBox(height: 10), + for (var i = 0; i < active.length; i++) ...[ + _RecurringScheduleCard( + summary: active[i], + onTap: () => _open(active[i]), + ), + if (i != active.length - 1) const SizedBox(height: 13), + ], + if (ended.isNotEmpty) ...[ + const SizedBox(height: 33), + _RecurringSectionTitle( + title: recurrenceText( context, - '아직 등록된 반복 일정이 없습니다.\n지금 약속을 추가하고 반복 일정을 관리해보세요.', - 'No recurring schedules yet.\nAdd an appointment to get started.', + '종료된 반복 일정', + 'Ended recurring schedules', ), - textAlign: TextAlign.center, - ), - ), - ] else ...[ - Text( - recurrenceText(context, '반복 중인 일정', 'Active schedules'), - 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.titleSmall, + count: ended.length, ), - for (final summary in snapshot.data!.where((s) => s.next == null)) - _row(context, summary), + const SizedBox(height: 10), + for (var i = 0; i < ended.length; i++) ...[ + _RecurringScheduleCard( + summary: ended[i], + onTap: () => _open(ended[i]), + ), + if (i != ended.length - 1) const SizedBox(height: 13), + ], + ], ], - ], - ), + ); + }, ), ); - 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( +} + +class _RecurringSectionTitle extends StatelessWidget { + const _RecurringSectionTitle({required this.title, required this.count}); + + final String title; + final int count; + + @override + Widget build(BuildContext context) => SizedBox( + height: 26, + child: Row( + children: [ + Expanded( + child: Text( + title, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + ), + Text( + '$count${recurrenceText(context, '개', '')}', + style: const TextStyle(fontSize: 14, color: Color(0xff545454)), + ), + ], + ), + ); +} + +class _RecurringScheduleCard extends StatelessWidget { + const _RecurringScheduleCard({required this.summary, required this.onTap}); + + final RecurringScheduleSummary summary; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final next = summary.next; + return Material( + key: ValueKey('recurring_card_${summary.segment.id}'), + color: const Color(0xfff6f6f6), + borderRadius: BorderRadius.circular(9), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(9), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: next == null ? 71 : 99), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 10), + child: Row( + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + summary.segment.schedule.scheduleName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + height: 1.5, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + if (next == null) + Text( + _endedLabel(context, summary.segment.rule), + style: const TextStyle( + fontSize: 13, + color: Color(0xff545454), + ), + ) + else ...[ + Text( + _compactRule(context, summary.segment.rule), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + color: Color(0xff545454), + ), + ), + const SizedBox(height: 5), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 4, + runSpacing: 2, + children: [ + SvgPicture.asset( + 'recurring_card_calendar.svg', + package: 'assets', + ), + Text( + recurrenceText(context, '다음 일정', 'Next'), + style: const TextStyle( + fontSize: 12, + color: Color(0xff545454), + ), + ), + Text( + DateFormat('M/d').format(next.scheduleTime), + style: const TextStyle( + fontSize: 12, + color: Color(0xff545454), + ), + ), + const SizedBox( + width: 1, + height: 12, + child: ColoredBox(color: Color(0xff949494)), + ), + SvgPicture.asset( + 'recurring_card_clock.svg', + package: 'assets', + ), + Text( + recurrenceText( + context, + '준비 ${summary.segment.preparation.totalDuration.inMinutes}분', + 'Prep ${summary.segment.preparation.totalDuration.inMinutes} min', + ), + style: const TextStyle( + fontSize: 12, + color: Color(0xff545454), + ), + ), + ], + ), + ], + ], + ), + ), + const SizedBox(width: 10), + SvgPicture.asset( + 'recurring_card_chevron.svg', + package: 'assets', + ), + ], + ), + ), + ), + ), + ); + } + + String _endedLabel(BuildContext context, RecurrenceRule rule) { + final until = rule.until; + final suffix = until == null ? '' : ' · ${DateFormat('M/d').format(until)}'; + return '${recurrenceText(context, '종료됨', 'Ended')}$suffix'; + } + + String _compactRule(BuildContext context, RecurrenceRule rule) { + if (Localizations.localeOf(context).languageCode != 'ko') { + return recurrenceLabel(context, rule); + } + final frequency = switch (rule.frequency) { + RecurrenceFrequency.daily => + rule.interval == 1 ? '매일' : '${rule.interval}일마다', + RecurrenceFrequency.weekly => + rule.interval == 1 ? '매주' : '${rule.interval}주마다', + RecurrenceFrequency.monthly => + rule.interval == 1 ? '매월' : '${rule.interval}개월마다', + }; + final detail = switch (rule.frequency) { + RecurrenceFrequency.daily => '', + RecurrenceFrequency.weekly => + (rule.weekdays.toList()..sort()) + .map((day) => weekdayLabel(context, day)) + .join(' · '), + RecurrenceFrequency.monthly => switch (rule.monthly) { + MonthlyRecurrence.dayOfMonth => '${rule.monthDay}일', + MonthlyRecurrence.lastDay => '마지막 날', + MonthlyRecurrence.nthWeekday => + '${rule.ordinal == -1 ? '마지막' : '${rule.ordinal}번째'} ${weekdayLabel(context, rule.monthWeekday)}요일', + }, + }; + final time = rule.start.minute == 0 + ? '${rule.start.hour}시' + : '${rule.start.hour}시 ${rule.start.minute}분'; + return [frequency, if (detail.isNotEmpty) detail, time].join(' '); + } +} + +class _RecurringEmptyView extends StatelessWidget { + const _RecurringEmptyView({required this.onCreate}); + + final VoidCallback onCreate; + + @override + Widget build(BuildContext context) => SafeArea( + top: false, + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 11, 20, 14), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - summary.segment.schedule.scheduleName, - style: Theme.of(context).textTheme.titleMedium, + Container( + key: const Key('recurring_empty_info'), + constraints: const BoxConstraints(minHeight: 115), + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + decoration: BoxDecoration( + color: const Color(0xffdce3ff), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + SvgPicture.asset( + 'recurring_calendar_outline.svg', + package: 'assets', + ), + const SizedBox(width: 24), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recurrenceText( + context, + '약속을 생성할 때 날짜·시간 단계에서\n반복을 설정할 수 있습니다.', + 'Set repeat in the date and time step when creating an appointment.', + ), + style: const TextStyle( + fontSize: 13, + height: 1.35, + color: Color(0xff4f69df), + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + recurrenceText( + context, + '반복으로 등록하면 각 일정별 준비과정도\n함께 관리할 수 있습니다.', + 'Each repeated appointment can keep its own preparation.', + ), + style: const TextStyle( + fontSize: 11, + height: 1.35, + ), + ), + ], + ), + ), + ], + ), ), - const SizedBox(height: 6), - Text( - recurrenceLabel(context, summary.segment.rule), - style: Theme.of(context).textTheme.bodyMedium, + const SizedBox(height: 24), + const _RecurringSectionTitle(title: '등록된 반복 일정', count: 0), + const SizedBox(height: 7), + Container( + key: const Key('recurring_empty_card'), + constraints: const BoxConstraints(minHeight: 122), + width: double.infinity, + padding: const EdgeInsets.all(12), + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xfff6f6f6), + borderRadius: BorderRadius.circular(9), + ), + child: Text( + recurrenceText( + context, + '아직 등록된 반복 일정이 없습니다.\n지금 약속을 추가하고 반복 일정을 관리해보세요.', + 'No recurring schedules yet. Add an appointment to get started.', + ), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, + height: 1.65, + color: Color(0xff545454), + ), + ), ), - 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, + const Spacer(), + SizedBox( + width: double.infinity, + height: 47, + child: ElevatedButton( + key: const Key('recurring_empty_add'), + onPressed: onCreate, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xff4f69df), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + recurrenceText(context, '약속 추가', 'Add appointment'), + style: const TextStyle( + fontSize: 16, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), ), ], ), ), - const SizedBox(width: 8), - const Icon(Icons.chevron_right, size: 20), - ], + ), ), ), ), @@ -211,22 +505,25 @@ class _SeriesDetailState extends State<_SeriesDetail> { height: MediaQuery.sizeOf(context).height * .94, child: RecurrenceSheet( title: recurrenceText(context, '반복 종료', 'End series'), + spacing: 12, + contentPadding: const EdgeInsets.fromLTRB(20, 26, 20, 16), 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)}부터 이후 회차의 반복을 종료할까요?', + '${DateFormat('M/d').format(next.scheduleTime)}부터 이후 회차의 반복을 종료할까요?', 'End the series from ${recurrenceDay(context, next.scheduleTime)}?', ), - style: Theme.of(context).textTheme.titleSmall, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontSize: 23, + fontWeight: FontWeight.w700, + ), ), Text( recurrenceText( @@ -235,8 +532,11 @@ class _SeriesDetailState extends State<_SeriesDetail> { 'Deletes upcoming occurrences. Active preparations and history are preserved.', ), ), - RecurrencePanel( + RecurrenceNotice( highlighted: true, + asset: 'recurrence_end_confirm_vector.svg', + glyph: 'i', + glyphColor: const Color(0xff212f6f), child: Text( recurrenceText( context, @@ -287,12 +587,74 @@ class _SeriesDetailState extends State<_SeriesDetail> { } } + Widget _detailAction( + String label, + String? asset, + VoidCallback? action, { + bool stop = false, + }) => Material( + color: stop ? const Color(0xfff6f6f6) : const Color(0xffdce3ff), + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: action, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row( + children: [ + if (stop) + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: const Color(0xff545454), + borderRadius: BorderRadius.circular(4), + ), + ) + else + SvgPicture.asset(asset!, package: 'assets'), + const SizedBox(width: 15), + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 13.5, + height: 1.6, + fontWeight: FontWeight.w700, + color: stop + ? const Color(0xff545454) + : const Color(0xff4f69df), + ), + ), + ), + if (_busy && stop) + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + SvgPicture.asset( + stop + ? 'recurrence_detail_frame4.svg' + : 'recurrence_detail_frame2.svg', + package: 'assets', + ), + ], + ), + ), + ), + ); + @override Widget build(BuildContext context) { final segment = widget.summary.segment; + final locale = Localizations.localeOf(context).toString(); + final next = widget.summary.next; return RecurrenceSheet( title: segment.schedule.scheduleName, - spacing: 10, + spacing: 0, + contentPadding: const EdgeInsets.fromLTRB(16, 15, 17, 16), footer: ScreenActions( action: recurrenceText(context, '뒤로', 'Back'), onAction: () => Navigator.of(context).pop(), @@ -304,82 +666,149 @@ class _SeriesDetailState extends State<_SeriesDetail> { children: [ Text( recurrenceText(context, '반복 규칙', 'Repeat'), - style: Theme.of(context).textTheme.bodySmall, + style: const TextStyle(fontSize: 14, color: Color(0xff545454)), ), - const SizedBox(height: 6), + const SizedBox(height: 4), Text( - recurrenceLabel(context, segment.rule), - style: Theme.of(context).textTheme.titleSmall, - ), - Text( - recurrenceEndLabel(context, segment.rule), - style: Theme.of(context).textTheme.bodySmall, + recurrencePatternLabel( + context, + segment.rule, + includeTime: true, + ), + style: const TextStyle( + fontSize: 16, + height: 1.5, + fontWeight: FontWeight.w700, + ), ), - if (widget.summary.next != null) ...[ + if (segment.rule.count != null || segment.rule.until != null) + Text( + recurrenceEndLabel(context, segment.rule), + style: const TextStyle( + fontSize: 12, + color: Color(0xff545454), + ), + ), + if (next != null) ...[ const SizedBox(height: 16), Text( recurrenceText(context, '다음 일정', 'Next occurrence'), - style: Theme.of(context).textTheme.bodySmall, + style: const TextStyle( + fontSize: 14, + color: Color(0xff545454), + ), ), - const SizedBox(height: 6), + const SizedBox(height: 4), Text( - recurrenceDate(context, widget.summary.next!.scheduleTime), - style: Theme.of(context).textTheme.titleSmall, + DateFormat('M/d (E) H:mm', locale).format(next.scheduleTime), + style: const TextStyle( + fontSize: 16, + height: 1.5, + fontWeight: FontWeight.w700, + ), ), ], ], ), ), + const SizedBox(height: 18), Text( recurrenceText(context, '전용 준비', 'Preparation for this series'), - style: Theme.of(context).textTheme.titleSmall, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), ), - 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 SizedBox(height: 6), + Text( + recurrenceText( + context, + '이 반복 일정에 필요한 준비 과정이에요.', + 'The preparation steps for this series.', + ), + style: const TextStyle(fontSize: 12, color: Color(0xff545454)), + ), + const SizedBox(height: 14), + for (final step in segment.preparation.ordered.preparationStepList) ...[ + Material( + color: const Color(0xfff6f6f6), + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: next == null || _busy ? null : _edit, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, ), - ], + child: Row( + children: [ + Expanded( + child: Text( + step.preparationName, + style: const TextStyle(fontSize: 16), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 4, + ), + color: Colors.white, + child: Text( + '${step.preparationTime.inMinutes}${recurrenceText(context, ' 분', ' min')}', + style: const TextStyle(fontSize: 16), + ), + ), + SvgPicture.asset( + 'recurrence_detail_frame.svg', + package: 'assets', + ), + ], + ), + ), ), ), - const Divider(height: 24), + const SizedBox(height: 7), + ], + const SizedBox(height: 14), + const Divider(height: 1), + const SizedBox(height: 12), Text( recurrenceText(context, '독립 관리 안내', 'Manage independently'), - style: Theme.of(context).textTheme.titleSmall, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), ), + const SizedBox(height: 4), Text( recurrenceText( context, '반복 규칙과 준비 과정은 각각 독립적으로 관리할 수 있어요. 필요에 따라 따로 수정할 수 있습니다.', 'You can edit the recurrence rule and preparation independently.', ), + style: const TextStyle( + fontSize: 12, + height: 1.6, + color: Color(0xff545454), + ), ), - if (widget.summary.next != null) ...[ - ModalWideButton( - text: recurrenceText(context, '반복 규칙 수정', 'Edit recurrence'), - variant: ModalWideButtonVariant.subtle, - layout: ModalWideButtonLayout.full, - height: 48, - onPressed: _busy ? null : _edit, + if (next != null) ...[ + const SizedBox(height: 14), + _detailAction( + recurrenceText(context, '반복 규칙 수정', 'Edit recurrence'), + 'recurrence_detail_frame1.svg', + _busy ? null : _edit, ), - ModalWideButton( - text: recurrenceText(context, '준비과정 수정', 'Edit preparation'), - variant: ModalWideButtonVariant.subtle, - layout: ModalWideButtonLayout.full, - height: 48, - onPressed: _busy ? null : _edit, + const SizedBox(height: 8), + _detailAction( + recurrenceText(context, '준비과정 수정', 'Edit preparation'), + 'recurrence_detail_frame3.svg', + _busy ? null : _edit, ), - const Divider(), - ModalWideButton( - text: recurrenceText(context, '반복 종료', 'End series'), - variant: ModalWideButtonVariant.neutral, - layout: ModalWideButtonLayout.full, - height: 48, - onPressed: _busy ? null : _end, - isLoading: _busy, + const SizedBox(height: 18), + const Divider(height: 1), + const SizedBox(height: 15), + _detailAction( + recurrenceText(context, '반복 종료', 'End series'), + null, + _busy ? null : _end, + stop: true, ), ], if (_error != null) 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 8030434b..21c179be 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 @@ -4,6 +4,7 @@ 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:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; @@ -52,14 +53,18 @@ class ScheduleDateTimeForm extends StatelessWidget { const SizedBox(height: 16), TextField( readOnly: true, + style: const TextStyle(fontSize: 13), decoration: InputDecoration( filled: true, + isDense: true, + floatingLabelBehavior: FloatingLabelBehavior.always, + contentPadding: const EdgeInsets.symmetric(vertical: 9), labelStyle: const TextStyle( - fontSize: 12, + fontSize: 10, color: Color(0xFF545454), ), floatingLabelStyle: const TextStyle( - fontSize: 12, + fontSize: 10, color: Color(0xFF545454), ), fillColor: Theme.of(context).colorScheme.surfaceContainerLowest, @@ -71,9 +76,23 @@ class ScheduleDateTimeForm extends StatelessWidget { 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, + prefixIcon: Padding( + padding: const EdgeInsets.symmetric(horizontal: 17), + child: SvgPicture.asset( + 'recurrence_date_time_calendar.svg', + package: 'assets', + ), + ), + prefixIconConstraints: const BoxConstraints(minWidth: 55), + suffixIcon: Padding( + padding: const EdgeInsets.only(right: 16), + child: SvgPicture.asset( + 'recurrence_date_time_chevron.svg', + package: 'assets', + ), + ), + suffixIconConstraints: const BoxConstraints(minWidth: 30), + labelText: recurrenceText(context, '날짜', 'Date'), hintText: _localizedDateString(context, DateTime.now()), hintStyle: fadedHintStyle, ), @@ -103,14 +122,18 @@ class ScheduleDateTimeForm extends StatelessWidget { const SizedBox(height: 8), TextField( readOnly: true, + style: const TextStyle(fontSize: 13), decoration: InputDecoration( filled: true, + isDense: true, + floatingLabelBehavior: FloatingLabelBehavior.always, + contentPadding: const EdgeInsets.symmetric(vertical: 9), labelStyle: const TextStyle( - fontSize: 12, + fontSize: 10, color: Color(0xFF545454), ), floatingLabelStyle: const TextStyle( - fontSize: 12, + fontSize: 10, color: Color(0xFF545454), ), fillColor: Theme.of(context).colorScheme.surfaceContainerLowest, @@ -122,8 +145,22 @@ class ScheduleDateTimeForm extends StatelessWidget { borderSide: BorderSide.none, borderRadius: BorderRadius.circular(8), ), - prefixIcon: const Icon(Icons.schedule, size: 24), - suffixIcon: const Icon(Icons.chevron_right, size: 20), + prefixIcon: Padding( + padding: const EdgeInsets.symmetric(horizontal: 17), + child: SvgPicture.asset( + 'recurrence_date_time_clock.svg', + package: 'assets', + ), + ), + prefixIconConstraints: const BoxConstraints(minWidth: 55), + suffixIcon: Padding( + padding: const EdgeInsets.only(right: 16), + child: SvgPicture.asset( + 'recurrence_date_time_chevron.svg', + package: 'assets', + ), + ), + suffixIconConstraints: const BoxConstraints(minWidth: 30), labelText: recurrenceText(context, '시간', 'Time'), hintText: DateFormat.jm( Localizations.localeOf(context).toString(), @@ -157,52 +194,68 @@ class ScheduleDateTimeForm extends StatelessWidget { ), const SizedBox(height: 24), if (form.originalSchedule == null || form.recurrenceRule != null) - RecurrenceValue( - label: recurrenceText(context, '반복 설정', 'Repeat'), - icon: Icons.repeat, - value: form.recurrenceRule == null - ? recurrenceText(context, '반복 안 함', 'Does not repeat') - : recurrenceLabel(context, form.recurrenceRule!), - onTap: state.selectedScheduleDateTime == null - ? null - : () async { - final bloc = context.read(); - final cubit = context.read(); - final selected = - await showModalBottomSheet< - RecurrenceSettingsResult - >( - context: context, - isScrollControlled: true, - useSafeArea: true, - builder: (context) => SizedBox( - height: MediaQuery.sizeOf(context).height * .94, - child: RecurrenceSettingsSheet( - start: state.selectedScheduleDateTime!, - timeZoneId: form.timeZoneId, - initial: form.recurrenceRule, - allowNone: form.originalSchedule == null, - leadTime: - form.totalPreparationTime + - (form.moveTime ?? Duration.zero) + - (form.scheduleSpareTime ?? Duration.zero), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recurrenceText(context, '반복 설정', 'Repeat'), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 9), + RecurrenceValue( + compact: true, + label: recurrenceText(context, '반복 설정', 'Repeat'), + asset: 'recurrence_date_time_repeat.svg', + value: form.recurrenceRule == null + ? recurrenceText(context, '반복 안 함', 'Does not repeat') + : recurrencePatternLabel(context, form.recurrenceRule!), + onTap: state.selectedScheduleDateTime == null + ? null + : () async { + final bloc = context.read(); + final cubit = context.read(); + final selected = + await showModalBottomSheet< + RecurrenceSettingsResult + >( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => SizedBox( + height: + MediaQuery.sizeOf(context).height * .94, + child: RecurrenceSettingsSheet( + start: state.selectedScheduleDateTime!, + timeZoneId: form.timeZoneId, + initial: form.recurrenceRule, + allowNone: form.originalSchedule == null, + leadTime: + form.totalPreparationTime + + (form.moveTime ?? Duration.zero) + + (form.scheduleSpareTime ?? + Duration.zero), + ), + ), + ); + if (selected != null && + !bloc.isClosed && + !cubit.isClosed) { + bloc.add( + ScheduleFormRecurringChanged( + selected.rule, + countChanged: + form.recurrenceCountChanged || + selected.countChanged, ), - ), - ); - if (selected != null && - !bloc.isClosed && - !cubit.isClosed) { - bloc.add( - ScheduleFormRecurringChanged( - selected.rule, - countChanged: - form.recurrenceCountChanged || - selected.countChanged, - ), - ); - cubit.setRecurring(selected.rule != null); - } - }, + ); + cubit.setRecurring(selected.rule != null); + } + }, + ), + ], ), if (state.isRecurring && form.recurrenceRule != null) ...[ const SizedBox(height: 16), @@ -218,7 +271,10 @@ class ScheduleDateTimeForm extends StatelessWidget { ), ), const SizedBox(height: 6), - Text(recurrenceLabel(context, form.recurrenceRule!)), + Text( + '${recurrencePatternLabel(context, form.recurrenceRule!)}, ${recurrenceEndLabel(context, form.recurrenceRule!)}', + style: const TextStyle(fontSize: 12, height: 1.8), + ), const SizedBox(height: 14), Text( recurrenceText(context, '첫 일정 날짜', 'First occurrence'), @@ -339,7 +395,7 @@ String _occurrenceLabel(BuildContext context, int index, int offsetSeconds) { String _localizedDateString(BuildContext context, DateTime date) { final locale = Localizations.localeOf(context).languageCode; if (locale == 'ko') { - return DateFormat('yyyy년 MM월 dd일', 'ko').format(date); + return DateFormat('yyyy년 M월 d일 (E)', 'ko').format(date); } else { return DateFormat( 'yyyy.MM.dd.', diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_create_list.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_create_list.dart index 0953229e..5c9a84e0 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_create_list.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_create_list.dart @@ -53,11 +53,11 @@ class PreparationFormCreateList extends StatelessWidget { ), ), ), - SizedBox(height: 28.0), + const SizedBox(height: 8), Center( child: SizedBox( - height: 30, - width: 30, + height: 44, + width: 44, child: CreateIconButton(onCreationRequested: onCreationRequested), ), ), diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_list_field.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_list_field.dart index bee5dd03..990cd795 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_list_field.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_list_field.dart @@ -9,6 +9,7 @@ import 'package:on_time_front/presentation/onboarding/preparation_time/input_mod import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_time_input.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_step_form_cubit.dart'; import 'package:on_time_front/presentation/shared/components/tile.dart'; +import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; import 'package:on_time_front/presentation/shared/theme/tile_style.dart'; class PreparationFormListField extends StatefulWidget { @@ -58,10 +59,10 @@ class _PreparationFormListFieldState extends State { bool _hasRequestedInitialFocus = false; bool _isTimePickerOpen = false; final dragIndicatorSvg = SvgPicture.asset( - 'drag_indicator.svg', + 'preparation_edit_drag.svg', package: 'assets', semanticsLabel: 'drag indicator', - height: 18, + height: 16, width: 18, fit: BoxFit.contain, ); @@ -206,7 +207,10 @@ class _PreparationFormListFieldState extends State { return TextFieldTapRegion( child: Tile( key: ValueKey(widget.preparationStep.id), - style: TileStyle(padding: EdgeInsets.fromLTRB(21, 19, 21, 19)), + style: TileStyle( + backgroundColor: AppColors.grey.shade50, + padding: const EdgeInsets.fromLTRB(21, 16, 30, 16), + ), leading: widget.index == null ? dragIndicatorSvg : ReorderableDragStartListener( diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_time_input.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_time_input.dart index 56137d66..c97d2015 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_time_input.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_time_input.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/shared/components/cupertino_picker_modal.dart'; import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; @@ -22,12 +23,15 @@ class PreparationTimeInput extends StatelessWidget { Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; + final minutes = time.inMinutes < 0 ? 0 : time.inMinutes; + final scale = MediaQuery.textScalerOf(context).scale(1); + final l10n = AppLocalizations.of(context)!; return Row( children: [ GestureDetector( child: Container( - width: 50, - height: 30, + width: 50 * scale, + height: 30 * scale, decoration: BoxDecoration( color: AppColors.white, border: hasError @@ -37,10 +41,9 @@ class PreparationTimeInput extends StatelessWidget { ), child: Center( child: Text( - (time.inMinutes < 10 ? '0' : '') + - (time.inMinutes < 0 ? '0' : time.inMinutes.toString()), - style: textTheme.titleSmall?.copyWith( - color: colorScheme.onPrimaryContainer, + '${minutes.toString().padLeft(2, '0')} ${l10n.localeName.startsWith('ko') ? '분' : 'min'}', + style: textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface, ), ), ), @@ -48,7 +51,7 @@ class PreparationTimeInput extends StatelessWidget { onTap: () { onTap?.call(); context.showCupertinoMinutePickerModal( - title: '시간을 선택해주세요', + title: l10n.selectTime, initialValue: time, onSaved: (value) { onPreparationTimeChanged?.call(value); diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/screens/preparation_edit_form.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/screens/preparation_edit_form.dart index 0d8844cc..bb5ffab9 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/screens/preparation_edit_form.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/screens/preparation_edit_form.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/shared/utils/duration_format.dart'; import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc.dart'; @@ -38,6 +40,8 @@ class _PreparationEditFormState extends State { return Column( children: [ TopBar( + title: AppLocalizations.of(context)!.editPreparationTime, + actionLabel: AppLocalizations.of(context)!.done, onNextPageButtonClicked: state.isValid ? () { getIt.get().setDraft( @@ -49,24 +53,37 @@ class _PreparationEditFormState extends State { onPreviousPageButtonClicked: context.pop, isNextButtonEnabled: state.isValid, ), + const SizedBox(height: 31), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Align( + alignment: Alignment.centerRight, + child: Text( + '${AppLocalizations.of(context)!.totalTime}${formatDurationAsMinutes(context, Duration(minutes: state.preparationStepList.fold(0, (sum, step) => sum + step.preparationTime.value.inMinutes)))}', + ), + ), + ), const SizedBox(height: 24), Expanded( - child: PreparationFormCreateList( - preparationNameState: state, - onNameChanged: - ({required int index, required String value}) { - context.read().add( - PreparationFormPreparationStepNameChanged( - index: index, - preparationStepName: value, - ), - ); - }, - onCreationRequested: () { - context.read().add( - PreparationFormPreparationStepCreationRequested(), - ); - }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: PreparationFormCreateList( + preparationNameState: state, + onNameChanged: + ({required int index, required String value}) { + context.read().add( + PreparationFormPreparationStepNameChanged( + index: index, + preparationStepName: value, + ), + ); + }, + onCreationRequested: () { + context.read().add( + PreparationFormPreparationStepCreationRequested(), + ); + }, + ), ), ), ], 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 1b215116..96ff190b 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 @@ -2,6 +2,7 @@ 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_svg/flutter_svg.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; @@ -185,7 +186,8 @@ class _ScheduleSpareAndPreparingTimeFormState 'Review your preparation\nand edit as needed.', ), style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontSize: 23, + fontSize: 21, + height: 30 / 21, fontWeight: FontWeight.w700, ), ), @@ -196,7 +198,11 @@ class _ScheduleSpareAndPreparingTimeFormState '이 약속의 반복에만 적용되는 준비 과정입니다.\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), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 13.5, + height: 21 / 13.5, + color: const Color(0xff545454), + ), ), const SizedBox(height: 20), ReorderableListView.builder( @@ -229,13 +235,61 @@ class _ScheduleSpareAndPreparingTimeFormState child: RecurrencePanel( padding: EdgeInsets.zero, child: ListTile( + minTileHeight: 63, + horizontalTitleGap: 24, leading: ReorderableDragStartListener( index: i, - child: const Icon(Icons.drag_handle), + child: SvgPicture.asset( + 'recurrence_preparation_drag.svg', + package: 'assets', + ), + ), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + steps[i].preparationName, + style: const TextStyle(fontSize: 18), + ), + const SizedBox(height: 7), + ClipRect( + child: SizedBox( + width: double.infinity, + child: Align( + alignment: Alignment.centerLeft, + child: SvgPicture.asset( + 'recurrence_preparation_underline.svg', + package: 'assets', + ), + ), + ), + ), + ], ), - title: Text(steps[i].preparationName), - trailing: Text( - formatDurationAsMinutes(context, steps[i].preparationTime), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + color: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 3, + ), + child: Text( + formatDurationAsMinutes( + context, + steps[i].preparationTime, + ), + style: const TextStyle(fontSize: 16), + ), + ), + const SizedBox(width: 12), + SvgPicture.asset( + 'recurrence_preparation_chevron.svg', + package: 'assets', + ), + ], ), onTap: () => _editPreparation(state), ), @@ -248,23 +302,54 @@ class _ScheduleSpareAndPreparingTimeFormState 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), + RecurrencePanel( + padding: EdgeInsets.zero, + child: ListTile( + minTileHeight: 63, + horizontalTitleGap: 24, + leading: SvgPicture.asset( + 'recurrence_preparation_drag.svg', + package: 'assets', + ), + title: Text( + recurrenceText( + context, + '여유 (준비 합계 제외)', + 'Buffer (excluded from preparation total)', + ), + style: const TextStyle(fontSize: 18), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + color: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 3, + ), + child: Text( + formatDurationAsMinutes( + context, + state.spareTime.value ?? Duration.zero, + ), + style: const TextStyle(fontSize: 16), + ), + ), + const SizedBox(width: 12), + SvgPicture.asset( + 'recurrence_preparation_chevron.svg', + package: 'assets', + ), + ], + ), + onTap: () => context.showCupertinoMinutePickerModal( + title: AppLocalizations.of(context)!.enterTime, + initialValue: state.spareTime.value ?? Duration.zero, + onSaved: (value) => context + .read() + .spareTimeChanged(value), + ), ), ), const SizedBox(height: 16), diff --git a/lib/presentation/shared/components/app_spinner.dart b/lib/presentation/shared/components/app_spinner.dart new file mode 100644 index 00000000..08429631 --- /dev/null +++ b/lib/presentation/shared/components/app_spinner.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class AppSpinner extends StatefulWidget { + const AppSpinner({ + super.key, + this.assetPath = 'feedback_spinner.svg', + this.assetPackage = 'assets', + }); + + final String assetPath; + final String? assetPackage; + + @override + State createState() => _AppSpinnerState(); +} + +class _AppSpinnerState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _rotation = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(); + + @override + void dispose() { + _rotation.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Semantics( + label: '작업 중', + liveRegion: true, + child: RotationTransition( + turns: _rotation, + child: SvgPicture.asset( + widget.assetPath, + package: widget.assetPackage, + width: 40, + height: 40, + excludeFromSemantics: true, + ), + ), + ); +} diff --git a/lib/presentation/shared/components/bottom_nav_bar_scaffold.dart b/lib/presentation/shared/components/bottom_nav_bar_scaffold.dart index b9f931b9..3bed9848 100644 --- a/lib/presentation/shared/components/bottom_nav_bar_scaffold.dart +++ b/lib/presentation/shared/components/bottom_nav_bar_scaffold.dart @@ -55,12 +55,15 @@ class _BottomNavigationBarScaffoldState extends State { final colorScheme = Theme.of(context).colorScheme; final currentLocation = GoRouterState.of(context).uri.path; if (currentLocation == '/myPage') { + final scaledLabelHeight = MediaQuery.textScalerOf(context).scale(11); + final navigationHeight = + 56.0 + (scaledLabelHeight - 11).clamp(0.0, 30.0).toDouble() * 2; return Scaffold( body: widget.child, bottomNavigationBar: SafeArea( top: false, child: SizedBox( - height: 56, + height: navigationHeight, child: Row( children: [ Expanded( @@ -136,10 +139,17 @@ class _BottomNavigationBarScaffoldState extends State { topLeft: Radius.circular(16.0), topRight: Radius.circular(16.0), ), - child: Container( - color: AppColors.white, + child: DecoratedBox( + decoration: BoxDecoration( + color: currentLocation == '/home' + ? AppColors.blue.shade100 + : AppColors.white, + border: const Border.fromBorderSide( + BorderSide(color: Color(0xffe8e8e8)), + ), + ), child: Padding( - padding: const EdgeInsets.only(bottom: 32.0), + padding: const EdgeInsets.only(bottom: 25.0), child: SizedBox( height: 56, child: Row( @@ -148,16 +158,17 @@ class _BottomNavigationBarScaffoldState extends State { children: [ _BottomNavDestination( selected: currentLocation == '/home', - color: colorScheme.primary, - unselectedColor: colorScheme.onPrimaryContainer, + color: const Color(0xff111111), + unselectedColor: const Color(0xff111111), label: AppLocalizations.of(context)!.home, onTap: () => onDestinationSelected(0), child: const _HomeIcon(), ), + const SizedBox(width: 70), _BottomNavDestination( selected: currentLocation == '/myPage', - color: colorScheme.primary, - unselectedColor: colorScheme.onPrimaryContainer, + color: const Color(0xff111111), + unselectedColor: const Color(0xff111111), label: AppLocalizations.of(context)!.myPage, onTap: () => onDestinationSelected(1), child: const _MyIcon(), @@ -168,27 +179,35 @@ class _BottomNavigationBarScaffoldState extends State { ), ), ), - floatingActionButton: SizedBox( - height: 76, - width: 76, - child: FilledButton( - onPressed: () { - showModalBottomSheet( - context: context, - isDismissible: false, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => const ScheduleCreateScreen(), - ); - }, - // onPressed: () { - // context.go('/scheduleCreate'); - // }, - style: FilledButton.styleFrom( - shape: CircleBorder(), - padding: EdgeInsets.all(13), + floatingActionButton: Transform.translate( + offset: const Offset(0, 12), + child: SizedBox( + key: const Key('primaryAddButton'), + height: 76, + width: 76, + child: FilledButton( + onPressed: () { + showModalBottomSheet( + context: context, + isDismissible: false, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => const ScheduleCreateScreen(), + ); + }, + // onPressed: () { + // context.go('/scheduleCreate'); + // }, + style: FilledButton.styleFrom( + backgroundColor: const Color(0xff4f69df), + shape: CircleBorder(), + padding: EdgeInsets.all(13), + ), + child: Semantics( + label: AppLocalizations.of(context)!.plus, + child: _PlusIcon(), + ), ), - child: _PlusIcon(), ), ), floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, @@ -244,12 +263,11 @@ class _HomeIcon extends StatelessWidget { Widget build(BuildContext context) { final iconTheme = IconTheme.of(context); return SvgPicture.asset( - 'Home.svg', + 'home_nav_home.svg', package: 'assets', semanticsLabel: AppLocalizations.of(context)!.home, height: iconTheme.size, fit: BoxFit.contain, - colorFilter: ColorFilter.mode(iconTheme.color!, BlendMode.srcIn), ); } } @@ -261,12 +279,11 @@ class _MyIcon extends StatelessWidget { Widget build(BuildContext context) { final iconTheme = IconTheme.of(context); return SvgPicture.asset( - 'My.svg', + 'home_nav_my.svg', package: 'assets', semanticsLabel: AppLocalizations.of(context)!.myPage, height: iconTheme.size, fit: BoxFit.contain, - colorFilter: ColorFilter.mode(iconTheme.color!, BlendMode.srcIn), ); } } @@ -277,7 +294,7 @@ class _PlusIcon extends StatelessWidget { @override Widget build(BuildContext context) { return SvgPicture.asset( - 'Plus.svg', + 'home_nav_add.svg', package: 'assets', semanticsLabel: AppLocalizations.of(context)!.plus, height: 50, diff --git a/lib/presentation/shared/components/cupertino_picker_modal.dart b/lib/presentation/shared/components/cupertino_picker_modal.dart index bbaa330a..b62ac2ec 100644 --- a/lib/presentation/shared/components/cupertino_picker_modal.dart +++ b/lib/presentation/shared/components/cupertino_picker_modal.dart @@ -1,6 +1,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/shared/components/modal_wide_button.dart'; extension ModalBottomSheetExtension on BuildContext { void showCupertinoTimerPickerModal({ @@ -10,73 +11,18 @@ extension ModalBottomSheetExtension on BuildContext { required Function(Duration value) onSaved, VoidCallback? onDisposed, }) { - showModalBottomSheet( - isDismissible: true, - context: this, - builder: (BuildContext context) { - final textTheme = Theme.of(context).textTheme; - Duration duration = initialValue; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 29.0, vertical: 28.0), - child: SizedBox( - height: 334, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: textTheme.titleMedium), - Expanded( - child: Center( - child: CupertinoTimerPicker( - mode: mode, - initialTimerDuration: initialValue, - itemExtent: 32, - onTimerDurationChanged: (value) => duration = value, - ), - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: MainAxisSize.max, - children: [ - Expanded( - flex: 1, - child: ElevatedButton( - onPressed: () { - //calling order matters - Navigator.pop(context); - }, - style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll( - Color.fromARGB(255, 220, 227, 255), - ), - foregroundColor: WidgetStatePropertyAll( - Color.fromARGB(255, 92, 121, 251), - ), - ), - child: Text(AppLocalizations.of(this)!.cancel), - ), - ), - SizedBox(width: 20.0), - Expanded( - flex: 1, - child: ElevatedButton( - onPressed: () { - Navigator.pop(context); - onSaved(duration); - }, - child: Text(AppLocalizations.of(this)!.ok), - ), - ), - ], - ), - ], - ), - ), - ); - }, - ).whenComplete(() { - onDisposed?.call(); - }); + var duration = initialValue; + _showPicker( + title: title, + onSaved: () => onSaved(duration), + onDisposed: onDisposed, + picker: CupertinoTimerPicker( + mode: mode, + initialTimerDuration: initialValue, + itemExtent: 32, + onTimerDurationChanged: (value) => duration = value, + ), + ); } void showCupertinoMinutePickerModal({ @@ -85,93 +31,29 @@ extension ModalBottomSheetExtension on BuildContext { required Function(Duration value) onSaved, VoidCallback? onDisposed, }) { - showModalBottomSheet( - isDismissible: true, - context: this, - builder: (BuildContext context) { - final textTheme = Theme.of(context).textTheme; - int minutes = initialValue.inMinutes; - return ClipRRect( - borderRadius: BorderRadius.vertical(top: Radius.circular(20.0)), - child: Container( - color: Theme.of(context).colorScheme.surface, - padding: const EdgeInsets.symmetric( - horizontal: 29.0, - vertical: 28.0, - ), - child: SizedBox( - height: 334, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: textTheme.titleMedium), - Expanded( - child: Center( - child: SizedBox( - width: 69.0, - child: CupertinoPicker( - scrollController: FixedExtentScrollController( - initialItem: initialValue.inMinutes, - ), - looping: true, - itemExtent: 32, - onSelectedItemChanged: (int value) { - minutes = value; - }, - children: List.generate( - 60, - (index) => Text( - (index < 10 ? '0' : '') + index.toString(), - ), - ), - ), - ), - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: MainAxisSize.max, - children: [ - Expanded( - flex: 1, - child: ElevatedButton( - onPressed: () { - //calling order matters - Navigator.pop(context); - }, - style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll( - Color.fromARGB(255, 220, 227, 255), - ), - foregroundColor: WidgetStatePropertyAll( - Color.fromARGB(255, 92, 121, 251), - ), - ), - child: Text(AppLocalizations.of(this)!.cancel), - ), - ), - SizedBox(width: 20.0), - Expanded( - flex: 1, - child: ElevatedButton( - onPressed: () { - Navigator.pop(context); - onSaved(Duration(minutes: minutes)); - }, - child: Text(AppLocalizations.of(this)!.ok), - ), - ), - ], - ), - ], - ), - ), - ), - ); + var minutes = initialValue.inMinutes; + final controller = FixedExtentScrollController(initialItem: minutes); + _showPicker( + title: title, + onSaved: () => onSaved(Duration(minutes: minutes)), + onDisposed: () { + controller.dispose(); + onDisposed?.call(); }, - ).whenComplete(() { - onDisposed?.call(); - }); + picker: SizedBox( + width: 69, + child: CupertinoPicker( + scrollController: controller, + looping: true, + itemExtent: 32, + onSelectedItemChanged: (value) => minutes = value, + children: List.generate( + 60, + (index) => Text(index.toString().padLeft(2, '0')), + ), + ), + ), + ); } void showCupertinoDatePickerModal({ @@ -180,75 +62,109 @@ extension ModalBottomSheetExtension on BuildContext { required Function(DateTime value) onSaved, required CupertinoDatePickerMode mode, VoidCallback? onDisposed, + }) { + var dateTime = initialValue; + _showPicker( + title: title, + onSaved: () => onSaved(dateTime), + onDisposed: onDisposed, + picker: CupertinoDatePicker( + mode: mode, + initialDateTime: initialValue, + itemExtent: 32, + onDateTimeChanged: (value) => dateTime = value, + ), + ); + } + + void _showPicker({ + required String title, + required Widget picker, + required VoidCallback onSaved, + VoidCallback? onDisposed, }) { showModalBottomSheet( - isDismissible: true, context: this, - builder: (BuildContext context) { - final textTheme = Theme.of(context).textTheme; - DateTime dateTime = initialValue; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 29.0, vertical: 28.0), - child: SizedBox( - height: 334, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: textTheme.titleMedium), - Expanded( - child: Center( - child: CupertinoDatePicker( - mode: mode, - initialDateTime: initialValue, - itemExtent: 32, - onDateTimeChanged: (DateTime value) { - dateTime = value; - }, - ), - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: MainAxisSize.max, - children: [ - Expanded( - flex: 1, - child: ElevatedButton( - onPressed: () { - //calling order matters - Navigator.pop(context); - }, - style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll( - Color.fromARGB(255, 220, 227, 255), - ), - foregroundColor: WidgetStatePropertyAll( - Color.fromARGB(255, 92, 121, 251), - ), - ), - child: Text(AppLocalizations.of(this)!.cancel), - ), - ), - SizedBox(width: 20.0), - Expanded( - flex: 1, - child: ElevatedButton( - onPressed: () { - Navigator.pop(context); - onSaved(dateTime); - }, - child: Text(AppLocalizations.of(this)!.ok), - ), - ), - ], + isScrollControlled: true, + barrierColor: const Color(0x6b000000), + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => _PickerSheet( + title: title, + picker: picker, + onSaved: () { + Navigator.pop(context); + onSaved(); + }, + ), + ).whenComplete(() => onDisposed?.call()); + } +} + +class _PickerSheet extends StatelessWidget { + const _PickerSheet({ + required this.title, + required this.picker, + required this.onSaved, + }); + final String title; + final Widget picker; + final VoidCallback onSaved; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + final bottom = MediaQuery.paddingOf(context).bottom; + return Theme( + data: theme.copyWith( + colorScheme: theme.colorScheme.copyWith( + primary: const Color(0xff4f69df), + primaryContainer: const Color(0xffdce3ff), + onPrimaryContainer: const Color(0xff23346b), + ), + ), + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.fromLTRB(16, 24, 16, bottom < 21 ? 21 : bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + fontSize: 20, + height: 1.4, + fontWeight: FontWeight.w600, ), - ], - ), + ), + const SizedBox(height: 24), + SizedBox(height: 167, child: Center(child: picker)), + const SizedBox(height: 24), + Row( + children: [ + ModalWideButton( + layout: ModalWideButtonLayout.flex, + text: l10n.cancel, + variant: ModalWideButtonVariant.subtle, + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 16), + ModalWideButton( + layout: ModalWideButtonLayout.flex, + text: l10n.localeName.startsWith('ko') ? '입력' : l10n.ok, + variant: ModalWideButtonVariant.primary, + onPressed: onSaved, + ), + ], + ), + ], ), - ); - }, - ).whenComplete(() { - onDisposed?.call(); - }); + ), + ), + ); } } diff --git a/lib/presentation/shared/components/custom_alert_dialog.dart b/lib/presentation/shared/components/custom_alert_dialog.dart index abed29bc..2bbd7fd0 100644 --- a/lib/presentation/shared/components/custom_alert_dialog.dart +++ b/lib/presentation/shared/components/custom_alert_dialog.dart @@ -30,6 +30,7 @@ class CustomAlertDialog extends StatelessWidget { this.contentActionsSpacing, this.titleTextAlign = TextAlign.start, this.contentTextAlign = TextAlign.start, + this.constraints, }); final Widget? title; @@ -78,6 +79,7 @@ class CustomAlertDialog extends StatelessWidget { final double? contentActionsSpacing; final TextAlign titleTextAlign; final TextAlign contentTextAlign; + final BoxConstraints? constraints; @override Widget build(BuildContext context) { @@ -207,6 +209,7 @@ class CustomAlertDialog extends StatelessWidget { clipBehavior: clipBehavior, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), alignment: alignment, + constraints: constraints, child: dialogChild, ); } diff --git a/lib/presentation/shared/components/modal_wide_button.dart b/lib/presentation/shared/components/modal_wide_button.dart index 562b3b1b..271ba66b 100644 --- a/lib/presentation/shared/components/modal_wide_button.dart +++ b/lib/presentation/shared/components/modal_wide_button.dart @@ -30,7 +30,6 @@ class ModalWideButton extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final (backgroundColor, foregroundColor) = switch (variant) { ModalWideButtonVariant.neutral => ( colorScheme.surfaceContainerLow, @@ -45,8 +44,8 @@ class ModalWideButton extends StatelessWidget { colorScheme.onPrimary, ), ModalWideButtonVariant.destructive => ( - colorScheme.error, - colorScheme.onError, + const Color(0xFFBF2E22), + Colors.white, ), }; diff --git a/lib/presentation/shared/components/time_stepper.dart b/lib/presentation/shared/components/time_stepper.dart index ca2382a7..5abe8186 100644 --- a/lib/presentation/shared/components/time_stepper.dart +++ b/lib/presentation/shared/components/time_stepper.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; class TimeStepper extends StatelessWidget { const TimeStepper({ @@ -16,31 +17,51 @@ class TimeStepper extends StatelessWidget { @override Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - final ColorScheme colorScheme = Theme.of(context).colorScheme; - final iconButtonStyle = ButtonStyle( - backgroundColor: WidgetStatePropertyAll(Color(0xffe6e9f9)), - foregroundColor: WidgetStatePropertyAll(colorScheme.primary), - shape: WidgetStatePropertyAll( - CircleBorder(side: BorderSide(color: colorScheme.primary, width: 1.0)), + final decreaseEnabled = value > lowerBound; + final buttonStyle = ButtonStyle( + fixedSize: const WidgetStatePropertyAll(Size(46, 46)), + padding: const WidgetStatePropertyAll(EdgeInsets.zero), + backgroundColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.disabled) + ? AppColors.grey.shade200 + : const Color(0xffdce3ff), ), + foregroundColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.disabled) + ? AppColors.grey.shade400 + : const Color(0xff3d54bc), + ), + side: WidgetStateProperty.resolveWith( + (states) => BorderSide( + color: states.contains(WidgetState.disabled) + ? AppColors.grey.shade300 + : const Color(0xff3d54bc), + ), + ), + shape: const WidgetStatePropertyAll(CircleBorder()), ); return Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ IconButton( - icon: const Icon(Icons.remove), - style: iconButtonStyle, - onPressed: value > lowerBound ? onSpareTimeDecreased : null, + icon: const Icon(Icons.remove, size: 24), + style: buttonStyle, + onPressed: decreaseEnabled ? onSpareTimeDecreased : null, ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 35.0), - child: Text('${value.inMinutes}분', style: textTheme.titleSmall), + const SizedBox(width: 34.5), + Text( + '${value.inMinutes}분', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontSize: 24.2, + fontWeight: FontWeight.w400, + height: 1.8, + ), ), + const SizedBox(width: 34.5), IconButton( - icon: const Icon(Icons.add), - style: iconButtonStyle, + icon: const Icon(Icons.add, size: 24), + style: buttonStyle, onPressed: onSpareTimeIncreased, ), ], diff --git a/lib/presentation/shared/components/two_action_dialog.dart b/lib/presentation/shared/components/two_action_dialog.dart index 9fd098fb..f04bf4f5 100644 --- a/lib/presentation/shared/components/two_action_dialog.dart +++ b/lib/presentation/shared/components/two_action_dialog.dart @@ -23,10 +23,14 @@ class TwoActionDialogConfig { required this.primaryAction, this.secondaryAction, this.barrierDismissible = true, + this.useSafeArea = true, this.maxWidth = TwoActionDialogTokens.maxDialogWidth, this.innerPadding = TwoActionDialogTokens.innerPadding, this.titleContentSpacing = TwoActionDialogTokens.titleContentSpacing, this.contentActionsSpacing = TwoActionDialogTokens.contentActionsSpacing, + this.barrierColor, + this.alignment = Alignment.center, + this.insetPadding, }); final String title; @@ -34,10 +38,14 @@ class TwoActionDialogConfig { final DialogActionConfig primaryAction; final DialogActionConfig? secondaryAction; final bool barrierDismissible; + final bool useSafeArea; final double maxWidth; final EdgeInsets innerPadding; final double titleContentSpacing; final double contentActionsSpacing; + final Color? barrierColor; + final AlignmentGeometry alignment; + final EdgeInsets? insetPadding; } abstract final class TwoActionDialogTokens { @@ -45,7 +53,7 @@ abstract final class TwoActionDialogTokens { static const EdgeInsets innerPadding = EdgeInsets.fromLTRB(16, 18, 16, 18); static const double titleContentSpacing = 8; static const double contentActionsSpacing = 16; - static const double actionButtonHeight = 43; + static const double actionButtonHeight = 44; static const double actionSpacing = 8; const TwoActionDialogTokens._(); @@ -59,6 +67,8 @@ Future showTwoActionDialog( final result = await showDialog( context: context, barrierDismissible: config.barrierDismissible, + useSafeArea: config.useSafeArea, + barrierColor: config.barrierColor, builder: (dialogContext) { return TwoActionDialog( config: config, @@ -98,15 +108,23 @@ class TwoActionDialog extends StatelessWidget { final screenWidth = MediaQuery.sizeOf(context).width; final dialogWidth = (screenWidth - 32).clamp(0.0, config.maxWidth); + final actionsWidth = (dialogWidth - config.innerPadding.horizontal).clamp( + 0.0, + dialogWidth, + ); - final titleText = Text( - config.title, - style: textTheme.titleMedium?.copyWith( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w600, - fontSize: 18, - height: 1.4, - color: colorScheme.onSurface, + final titleText = SizedBox( + width: actionsWidth, + child: Text( + config.title, + style: textTheme.titleMedium?.copyWith( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 18, + height: 1.4, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, ), ); @@ -114,14 +132,19 @@ class TwoActionDialog extends StatelessWidget { customContent ?? (config.description == null ? null - : Text( - config.description!, - style: textTheme.bodyMedium?.copyWith( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w400, - fontSize: 14, - height: 1.4, - color: colorScheme.outline, + : SizedBox( + width: actionsWidth, + child: Text( + config.description!, + style: textTheme.bodyMedium?.copyWith( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 13, + height: 1.4, + letterSpacing: -0.4, + color: colorScheme.outline, + ), + textAlign: TextAlign.center, ), )); @@ -130,7 +153,7 @@ class TwoActionDialog extends StatelessWidget { content: bodyContent, actions: [ SizedBox( - width: dialogWidth, + width: actionsWidth, child: Row( children: [ if (config.secondaryAction != null) ...[ @@ -158,11 +181,13 @@ class TwoActionDialog extends StatelessWidget { ], actionsAlignment: MainAxisAlignment.center, innerPadding: config.innerPadding, - alignment: Alignment.center, + alignment: config.alignment, + insetPadding: config.insetPadding, titleTextAlign: TextAlign.center, contentTextAlign: TextAlign.center, titleContentSpacing: config.titleContentSpacing, contentActionsSpacing: config.contentActionsSpacing, + constraints: BoxConstraints.tightFor(width: dialogWidth), ); } } diff --git a/lib/presentation/shared/components/two_button_delete_dialog.dart b/lib/presentation/shared/components/two_button_delete_dialog.dart index f2477633..e4756121 100644 --- a/lib/presentation/shared/components/two_button_delete_dialog.dart +++ b/lib/presentation/shared/components/two_button_delete_dialog.dart @@ -9,6 +9,7 @@ Future showTwoButtonDeleteDialog( required String cancelText, required String confirmText, bool barrierDismissible = true, + bool sourceCalendarLayout = false, }) async { final result = await showTwoActionDialog( context, @@ -16,6 +17,17 @@ Future showTwoButtonDeleteDialog( title: title, description: description, barrierDismissible: barrierDismissible, + useSafeArea: !sourceCalendarLayout, + barrierColor: sourceCalendarLayout ? const Color(0x6b000000) : null, + innerPadding: sourceCalendarLayout + ? const EdgeInsets.all(16) + : TwoActionDialogTokens.innerPadding, + titleContentSpacing: sourceCalendarLayout + ? 12 + : TwoActionDialogTokens.titleContentSpacing, + contentActionsSpacing: sourceCalendarLayout + ? 12 + : TwoActionDialogTokens.contentActionsSpacing, secondaryAction: DialogActionConfig( label: cancelText, variant: ModalWideButtonVariant.neutral, diff --git a/lib/presentation/shared/theme/calendar_theme.dart b/lib/presentation/shared/theme/calendar_theme.dart index fda41a7d..812be3dc 100644 --- a/lib/presentation/shared/theme/calendar_theme.dart +++ b/lib/presentation/shared/theme/calendar_theme.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:intl/intl.dart'; import 'package:table_calendar/table_calendar.dart'; @@ -66,13 +67,16 @@ class CalendarTheme extends ThemeExtension { leftChevronVisible: false, rightChevronVisible: false, headerPadding: EdgeInsets.zero, - leftChevronIcon: Icon( - Icons.chevron_left, - color: colorScheme.outlineVariant, + leftChevronIcon: RotatedBox( + quarterTurns: 2, + child: SvgPicture.asset( + 'calendar_month_arrow.svg', + package: 'assets', + ), ), - rightChevronIcon: Icon( - Icons.chevron_right, - color: colorScheme.outlineVariant, + rightChevronIcon: SvgPicture.asset( + 'calendar_month_arrow.svg', + package: 'assets', ), titleTextStyle: textTheme.titleMedium!, ), @@ -86,8 +90,9 @@ class CalendarTheme extends ThemeExtension { weekendTextStyle: textTheme.bodyLarge!, defaultTextStyle: textTheme.bodyLarge!, cellMargin: const EdgeInsets.all(2.0), - markerDecoration: BoxDecoration( - color: colorScheme.primary, + markerSize: 6, + markerDecoration: const BoxDecoration( + color: Color(0xff4f69df), shape: BoxShape.circle, ), markerMargin: const EdgeInsets.symmetric(horizontal: 1.0), diff --git a/lib/presentation/startup/screens/local_data_recovery_screen.dart b/lib/presentation/startup/screens/local_data_recovery_screen.dart index 8d024321..2f1a5380 100644 --- a/lib/presentation/startup/screens/local_data_recovery_screen.dart +++ b/lib/presentation/startup/screens/local_data_recovery_screen.dart @@ -5,7 +5,8 @@ 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/my_page/data_state_page.dart'; +import 'package:on_time_front/presentation/shared/components/app_spinner.dart'; import 'package:on_time_front/presentation/shared/components/modal_wide_button.dart'; class LocalDataRecoveryScreen extends StatefulWidget { @@ -29,128 +30,188 @@ class _LocalDataRecoveryScreenState extends State { @override Widget build(BuildContext context) => PopScope( canPop: !_busy, - child: Scaffold( - body: RecurrenceSheet( - title: '데이터 복구', - showBack: false, - footer: _busy - ? const Column( + child: DataStatePage( + title: '데이터 복구', + showBack: false, + footerPadding: const EdgeInsets.fromLTRB(17, 16, 17, 23), + footer: _busy + ? Theme( + data: Theme.of(context).copyWith( + colorScheme: Theme.of( + context, + ).colorScheme.copyWith(surfaceDim: const Color(0xFF949494)), + ), + child: const Column( + mainAxisSize: MainAxisSize.min, children: [ ModalWideButton( onPressed: null, text: '다시 시도', + variant: ModalWideButtonVariant.primary, layout: ModalWideButtonLayout.full, height: 44, ), - SizedBox(height: 8), + SizedBox(height: 10), ModalWideButton( onPressed: null, text: '초기화 실행', + variant: ModalWideButtonVariant.primary, layout: ModalWideButtonLayout.full, height: 44, ), ], - ) - : null, - children: _busy - ? [ - SizedBox(height: MediaQuery.sizeOf(context).height * .30), - const Center( - child: SizedBox( - width: 28, - height: 28, - child: CircularProgressIndicator(strokeWidth: 3), + ), + ) + : null, + children: _busy + ? [ + SizedBox( + height: (MediaQuery.sizeOf(context).height * .287).clamp( + 100, + 242, + ), + ), + const Center( + child: SizedBox( + height: 47, + width: 47, + child: Center( + child: AppSpinner( + assetPath: 'assets/design/recovery_spinner.svg', + assetPackage: null, + ), ), ), - const SizedBox(height: 8), - const Text( - '로컬 데이터 초기화 중', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 20), + const Text( + '로컬 데이터 초기화 중', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 20, + height: 1.45, + fontWeight: FontWeight.w700, ), - const Text( - '처리가 끝날 때까지 잠시 기다려 주세요.', - textAlign: TextAlign.center, + ), + const SizedBox(height: 9), + const Text( + '처리가 끝날 때까지 잠시 기다려 주세요.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + height: 20 / 14, + color: Color(0xFF545454), ), - ] - : [ - const SizedBox(height: 36), - Center( - child: SvgPicture.asset( - 'assets/design/recovery_warning.svg', - width: 68, - height: 68, - ), + ), + ] + : [ + const SizedBox(height: 52), + Center( + child: SvgPicture.asset( + 'assets/design/recovery_warning.svg', + width: 68, + height: 68, ), - const Text( - '기기에서 로컬 데이터를\n열 수 없습니다', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 24, - height: 1.3, - fontWeight: FontWeight.w700, - ), + ), + const SizedBox(height: 16), + const Text( + '기기에서 로컬 데이터를\n열 수 없습니다', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 23, + height: 30 / 23, + fontWeight: FontWeight.w700, ), - const Text( - '기기에서 데이터를 열거나 마이그레이션할 수 없는 상태입니다.', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 14, height: 1.5), + ), + const SizedBox(height: 7), + const Text( + '기기에서 데이터를 열거나 마이그레이션할 수 없는\n상태입니다.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + height: 1.5, + color: Color(0xFF545454), ), - 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), + ), + const SizedBox(height: 27), + const DataNotice( + key: Key('recoveryPreservedNotice'), + highlighted: true, + padding: EdgeInsets.fromLTRB(15, 15, 15, 12), + gap: 13, + minHeight: 69, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '데이터는 자동으로 삭제되지 않았습니다.', + style: TextStyle( + fontSize: 12.5, + height: 1.6, + fontWeight: FontWeight.w700, + color: Color(0xFF212F6F), ), - ], - ), - ), - const SizedBox(height: 4), - ModalWideButton( - text: '다시 시도하기', - layout: ModalWideButtonLayout.full, - variant: ModalWideButtonVariant.primary, - height: 44, - onPressed: - widget.onRetry ?? - () => context.read().add( - const AuthUserSubscriptionRequested(), + ), + SizedBox(height: 4), + Text( + '기기에 그대로 보존되어 있으니, 아래 방법을 시도해 주세요.', + style: TextStyle( + fontSize: 11.2, + height: 18 / 11.2, + color: Color(0xFF545454), ), + ), + ], ), - ModalWideButton( - text: '모든 로컬 데이터 초기화', - layout: ModalWideButtonLayout.full, - variant: ModalWideButtonVariant.subtle, - height: 44, - onPressed: _confirmReset, + ), + const SizedBox(height: 28), + ModalWideButton( + text: '다시 시도하기', + layout: ModalWideButtonLayout.full, + variant: ModalWideButtonVariant.primary, + height: 44, + onPressed: + widget.onRetry ?? + () => context.read().add( + const AuthUserSubscriptionRequested(), + ), + ), + const SizedBox(height: 8), + ModalWideButton( + text: '모든 로컬 데이터 초기화', + layout: ModalWideButtonLayout.full, + variant: ModalWideButtonVariant.subtle, + height: 44, + onPressed: _confirmReset, + ), + const SizedBox(height: 25), + Container( + padding: const EdgeInsets.fromLTRB(19, 16, 19, 17), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(9), ), - const RecurrencePanel( - child: Text( - '위 방법으로도 복구할 수 없는 경우,\n기기의 모든 로컬 데이터를 초기화할 수 있습니다.\n\n원격 복구나 로그인 기능은 제공하지 않습니다.', - style: TextStyle(fontSize: 12, height: 1.5), + child: const Text( + '위 방법으로도 복구할 수 없는 경우,\n기기의 모든 로컬 데이터를 초기화할 수 있습니다.\n\n원격 복구나 로그인 기능은 제공하지 않습니다.', + style: TextStyle( + fontSize: 11.3, + height: 15.5 / 11.3, + color: Color(0xFF545454), ), ), - if (_error != null) - Text( + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Text( _error!, style: TextStyle( color: Theme.of(context).colorScheme.error, ), ), - ], - ), + ), + const SizedBox(height: 24), + ], ), ); @@ -188,51 +249,107 @@ class _LocalDataRecoveryScreenState extends State { 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), - ), + Widget build(BuildContext context) => DataStatePage( + title: '데이터 초기화', + footer: Row( 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), + Expanded( + child: ModalWideButton( + text: '취소', + variant: ModalWideButtonVariant.subtle, + layout: ModalWideButtonLayout.full, + height: 48, + onPressed: () => Navigator.of(context).pop(false), ), ), - 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), - ), - ], + const SizedBox(width: 11.5), + Expanded( + child: ModalWideButton( + text: '모두 삭제', + variant: ModalWideButtonVariant.destructive, + layout: ModalWideButtonLayout.full, + height: 48, + onPressed: () => Navigator.of(context).pop(true), ), ), ], ), + children: [ + const SizedBox(height: 36), + Row( + children: [ + SvgPicture.asset( + 'assets/design/destructive_warning.svg', + width: 47, + height: 47, + excludeFromSemantics: true, + ), + const SizedBox(width: 14), + const Expanded( + child: Text( + '복구하지 않고 삭제하시겠습니까?', + style: TextStyle( + fontSize: 19, + height: 27 / 19, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 14), + const Text( + '이 설치의 모든 로컬 데이터와 암호화 키가 삭제됩니다.\n삭제된 데이터는 되돌릴 수 없습니다.', + style: TextStyle( + fontSize: 14.8, + height: 23 / 14.8, + color: Color(0xFF545454), + ), + ), + const SizedBox(height: 23), + Container( + key: const Key('resetConsequences'), + padding: const EdgeInsets.fromLTRB(18, 11, 18, 12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(9), + ), + child: const Text( + '• 이 기기에 저장된 모든 데이터가 삭제됩니다.\n• 암호화 키도 함께 삭제되어 복구할 수 없습니다.\n• 이 작업은 되돌릴 수 없습니다.', + style: TextStyle(fontSize: 13.5, height: 29 / 13.5), + ), + ), + const SizedBox(height: 22), + const DataNotice( + key: Key('resetExternalBackupNotice'), + highlighted: true, + padding: EdgeInsets.fromLTRB(17, 17, 17, 14), + minHeight: 93, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '백업 파일은 직접 삭제해야 합니다.', + style: TextStyle( + fontSize: 13.7, + height: 21 / 13.7, + fontWeight: FontWeight.w700, + color: Color(0xFF212F6F), + ), + ), + SizedBox(height: 5), + Text( + '다른 기기나 클라우드 등 외부에 저장한 백업 파일은\n이 작업으로 삭제되지 않습니다. 필요 시 직접 삭제해 주세요.', + style: TextStyle( + fontSize: 11.2, + height: 18 / 11.2, + color: Color(0xFF545454), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + ], ); } diff --git a/plans/ios-ui-quality-figma-parity.md b/plans/ios-ui-quality-figma-parity.md new file mode 100644 index 00000000..6e470875 --- /dev/null +++ b/plans/ios-ui-quality-figma-parity.md @@ -0,0 +1,16 @@ +# iOS UI quality and Figma parity + +Updated 2026-09-25. Implementation follows the refreshed `main` screen set at `c7184b2a`, merged into the isolated QA branch at `64bc111d`, with subsequent QA fixes layered on that base. Canonical Figma source: [OnTime Design System](https://www.figma.com/design/FIdR6bUMHScn9FWbwqrBsA/OnTime-Design-System), including Screen Shells, Form Flows, Preparation, and Permission Onboarding pages. The reference viewport is Korean iOS 390×844; Calendar uses its 390×852 source where specified. + +## Execution status + +1. **Complete — inventory and source audit.** [The manifest](../docs/design/figma_code_map.yaml) records 21 routes (16 reviewed, five excluded) and 58 variants (57 reviewed, one excluded): 73 reviewed app entries, zero pending/in-progress entries. Strict manifest validation passes. Exclusions have explicit source/platform reasons. Eleven standalone Schedule Detail designs without an app implementation remain in `design_only_nodes` as unmapped; they are not counted as reviewed app entries. +2. **Complete — source comparison and deterministic fixtures.** Source exports, hashes, Flutter goldens, and runtime adaptations are recorded in six dated reports: [Home/Calendar](../docs/design/review/calendar-parity-20260925.md), [original forms/preparation edit](../docs/design/review/original-form-parity-20260925.md), [recurring flows](../docs/design/review/recurring-parity-20260925.md), [onboarding/permissions](../docs/design/review/onboarding-parity-20260925.md), [recovery/local data/privacy](../docs/design/review/recovery-parity-20260925.md), and [preparation runtime](../docs/design/review/runtime-parity-20260925.md). Each report distinguishes source review from pixel identity and keeps real date arithmetic, persisted values, validation, and native chrome intact. +3. **Complete — UI repairs and targeted behavior/accessibility checks.** Source assets and component geometry are applied, accessible text can grow/scroll, and the four-step creation/edit flow, recurring scope/conflicts/owned preparation, timer completion, recovery, secure backup input, and closed-storage reset lifecycle are retained. Focused verification results and deliberate source exceptions are in the reports above. +4. **Complete for the executed simulator scope — native setup and durable-data flow.** [The native evidence](../docs/design/review/native-20260925/README.md) records real notification/AlarmKit denial/defer, numeric preparation entry, Files export/import, restore, reset, and process relaunch. The exported 811-byte encrypted backup restored buffer 35→30 minutes, and the external file survived reset unchanged. Native testing also exposed and led to repairs for the iOS export API/UTI boundary and duration-row focus. +5. **Final combined verification — recorded centrally.** The sole final full-suite and coverage result is maintained in [the completion report](../docs/design/review/completion-20260925.md#final-combined-validation). Focused passes and the previous checkpoint's totals must not be substituted for that final run. +6. **Physical-device release gate — unavailable.** Paired iPhones/iPad were unavailable. Physical font/layout inspection, VoiceOver operation, background/locked-screen alarm delivery, and frame-time/scroll performance are not certified by simulator or widget results. + +## Delivery boundary + +[Draft PR #596](https://github.com/DevKor-github/OnTime-front/pull/596) remains the review artifact. Source review of the implemented app is complete with documented adaptations; it does not mean all Figma-only screens were implemented or every pixel matches Figma. The physical-device gate and final combined verification remain separate from the completed visual inventory. See [handoff](../handoff/ios-ui-quality-figma-parity.md) for the remaining release acceptance work. diff --git a/pubspec.lock b/pubspec.lock index a235286a..cc0aedce 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -217,7 +217,7 @@ packages: source: hosted version: "0.3.5+5" crypto: - dependency: transitive + dependency: "direct dev" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -1338,7 +1338,7 @@ packages: source: hosted version: "6.6.1" yaml: - dependency: transitive + dependency: "direct dev" description: name: yaml sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce diff --git a/pubspec.yaml b/pubspec.yaml index 3730c517..a1adb9cf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -79,6 +79,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + yaml: ^3.1.3 + crypto: ^3.0.6 drift_dev: ^2.31.0 diff --git a/test/core/backup/backup_file_picker_test.dart b/test/core/backup/backup_file_picker_test.dart new file mode 100644 index 00000000..37edc3c6 --- /dev/null +++ b/test/core/backup/backup_file_picker_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_file_picker.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('on_time_front/backup_files'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final bytes = Uint8List.fromList([10, 20, 30]); + + tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + test( + 'iOS exports encrypted bytes through document UI and returns completion', + () async { + MethodCall? observed; + messenger.setMockMethodCallHandler(channel, (call) async { + observed = call; + return true; + }); + expect( + await BackupFilePicker(isIOS: true).save(bytes, 'OnTime.ontimebackup'), + isTrue, + ); + expect(observed!.method, 'exportBackup'); + expect(observed!.arguments, { + 'bytes': bytes, + 'suggestedName': 'OnTime.ontimebackup', + }); + }, + ); + + test('iOS cancellation is not successful export', () async { + messenger.setMockMethodCallHandler(channel, (_) async => false); + expect( + await BackupFilePicker(isIOS: true).save(bytes, 'OnTime.ontimebackup'), + isFalse, + ); + }); + + test('iOS storage errors propagate to the existing recovery UI', () async { + messenger.setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'exportFailed'); + }); + await expectLater( + BackupFilePicker(isIOS: true).save(bytes, 'OnTime.ontimebackup'), + throwsA( + isA().having((e) => e.code, 'code', 'exportFailed'), + ), + ); + }); +} diff --git a/test/core/backup/backup_service_test.dart b/test/core/backup/backup_service_test.dart index fbb4703c..b19bf021 100644 --- a/test/core/backup/backup_service_test.dart +++ b/test/core/backup/backup_service_test.dart @@ -6,6 +6,7 @@ import 'package:on_time_front/domain/recurrence/recurring_schedule.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/core/backup/backup_crypto.dart'; +import 'package:on_time_front/core/backup/backup_file_picker.dart'; import 'package:on_time_front/core/backup/backup_service.dart'; import 'package:on_time_front/core/database/database.dart'; import 'package:on_time_front/core/services/app_metadata_service.dart'; @@ -22,13 +23,16 @@ void main() { const password = 'portable backup password'; late AppDatabase database; late BackupService service; + late _FilePicker filePicker; setUp(() async { database = AppDatabase.forTesting(NativeDatabase.memory()); + filePicker = _FilePicker(); service = BackupService( database, _MetadataProvider(), crypto: BackupCrypto(sodiumLoader: loadSodiumForTest), + filePicker: filePicker, ); await database.userDao.putUser( const UserEntity( @@ -59,6 +63,73 @@ void main() { tearDown(() => database.close()); + test( + 'only completed OS export marks the encrypted snapshot as backed up', + () async { + expect( + (await service.getFreshness()).freshness, + BackupFreshness.neverExported, + ); + filePicker.saved = false; + expect(await service.exportToUserSelectedFile(password), isFalse); + expect( + (await service.getFreshness()).freshness, + BackupFreshness.neverExported, + ); + filePicker.saved = true; + expect(await service.exportToUserSelectedFile(password), isTrue); + expect( + (await service.getFreshness()).freshness, + BackupFreshness.noChanges, + ); + expect(filePicker.name, matches(RegExp(r'^OnTime-.*\.ontimebackup$'))); + final preview = await service.previewEncryptedBackup( + filePicker.bytes!, + password, + ); + expect(preview.preview.scheduleCount, 1); + expect( + utf8.decode(filePicker.bytes!, allowMalformed: true), + isNot(contains('local note')), + ); + }, + ); + + test('failed OS export leaves backup freshness unchanged', () async { + filePicker.error = StateError('storage unavailable'); + await expectLater( + service.exportToUserSelectedFile(password), + throwsStateError, + ); + expect( + (await service.getFreshness()).freshness, + BackupFreshness.neverExported, + ); + }); + + test( + 'restore selection cancellation and preview never replace live data', + () async { + expect(await service.selectAndPreviewRestore(password), isNull); + filePicker.bytes = await service.createEncryptedBackup(password); + final candidate = await service.selectAndPreviewRestore(password); + expect(candidate!.preview.scheduleCount, 1); + expect( + (await database.scheduleDao.getScheduleList()).single.schedule.id, + 'schedule-1', + ); + filePicker.bytes = Uint8List.fromList([1, 2, 3]); + await expectLater( + service.selectAndPreviewRestore(password), + throwsA(isA()), + ); + expect( + (await database.scheduleDao.getScheduleList()).single.schedule.id, + 'schedule-1', + ); + }, + ); + test( 'preview authenticates backup and restore replaces active data', () async { @@ -243,3 +314,22 @@ class _MetadataProvider implements AppMetadataProvider { Future getMetadata() async => const AppMetadata(version: '1.0.1', buildNumber: '1'); } + +class _FilePicker extends BackupFilePicker { + _FilePicker() : super(isIOS: true); + bool saved = true; + Object? error; + Uint8List? bytes; + String? name; + + @override + Future save(Uint8List encrypted, String suggestedName) async { + if (error != null) throw error!; + bytes = encrypted; + name = suggestedName; + return saved; + } + + @override + Future select() async => bytes; +} diff --git a/test/goldens/goldens/calendar_delete_confirm_390x852.png b/test/goldens/goldens/calendar_delete_confirm_390x852.png new file mode 100644 index 00000000..1eef56da Binary files /dev/null and b/test/goldens/goldens/calendar_delete_confirm_390x852.png differ diff --git a/test/goldens/goldens/calendar_empty_390x852.png b/test/goldens/goldens/calendar_empty_390x852.png new file mode 100644 index 00000000..9463323e Binary files /dev/null and b/test/goldens/goldens/calendar_empty_390x852.png differ diff --git a/test/goldens/goldens/calendar_error_390x852.png b/test/goldens/goldens/calendar_error_390x852.png new file mode 100644 index 00000000..2e3d221a Binary files /dev/null and b/test/goldens/goldens/calendar_error_390x852.png differ diff --git a/test/goldens/goldens/calendar_expanded_390x852.png b/test/goldens/goldens/calendar_expanded_390x852.png new file mode 100644 index 00000000..50acca0d Binary files /dev/null and b/test/goldens/goldens/calendar_expanded_390x852.png differ diff --git a/test/goldens/goldens/calendar_expanded_actions_390x852.png b/test/goldens/goldens/calendar_expanded_actions_390x852.png new file mode 100644 index 00000000..8bf94626 Binary files /dev/null and b/test/goldens/goldens/calendar_expanded_actions_390x852.png differ diff --git a/test/goldens/goldens/calendar_list_390x852.png b/test/goldens/goldens/calendar_list_390x852.png new file mode 100644 index 00000000..dc35d2f0 Binary files /dev/null and b/test/goldens/goldens/calendar_list_390x852.png differ diff --git a/test/goldens/goldens/calendar_loading_390x852.png b/test/goldens/goldens/calendar_loading_390x852.png new file mode 100644 index 00000000..385c8f10 Binary files /dev/null and b/test/goldens/goldens/calendar_loading_390x852.png differ diff --git a/test/goldens/goldens/calendar_swipe_actions_390x852.png b/test/goldens/goldens/calendar_swipe_actions_390x852.png new file mode 100644 index 00000000..0e107aeb Binary files /dev/null and b/test/goldens/goldens/calendar_swipe_actions_390x852.png differ diff --git a/test/goldens/goldens/form_date_picker_390x844.png b/test/goldens/goldens/form_date_picker_390x844.png new file mode 100644 index 00000000..015ecb9c Binary files /dev/null and b/test/goldens/goldens/form_date_picker_390x844.png differ diff --git a/test/goldens/goldens/form_name_390x844.png b/test/goldens/goldens/form_name_390x844.png new file mode 100644 index 00000000..fec6be81 Binary files /dev/null and b/test/goldens/goldens/form_name_390x844.png differ diff --git a/test/goldens/goldens/form_place_390x844.png b/test/goldens/goldens/form_place_390x844.png new file mode 100644 index 00000000..152c756a Binary files /dev/null and b/test/goldens/goldens/form_place_390x844.png differ diff --git a/test/goldens/goldens/form_time_picker_390x844.png b/test/goldens/goldens/form_time_picker_390x844.png new file mode 100644 index 00000000..d2609f66 Binary files /dev/null and b/test/goldens/goldens/form_time_picker_390x844.png differ diff --git a/test/goldens/goldens/home_default_390x844.png b/test/goldens/goldens/home_default_390x844.png new file mode 100644 index 00000000..437a8c31 Binary files /dev/null and b/test/goldens/goldens/home_default_390x844.png differ diff --git a/test/goldens/goldens/home_error_390x844.png b/test/goldens/goldens/home_error_390x844.png new file mode 100644 index 00000000..1a1172df Binary files /dev/null and b/test/goldens/goldens/home_error_390x844.png differ diff --git a/test/goldens/goldens/home_loading_390x844.png b/test/goldens/goldens/home_loading_390x844.png new file mode 100644 index 00000000..1c0ac839 Binary files /dev/null and b/test/goldens/goldens/home_loading_390x844.png differ diff --git a/test/goldens/goldens/my_data_busy_390x844.png b/test/goldens/goldens/my_data_busy_390x844.png new file mode 100644 index 00000000..4719981f Binary files /dev/null and b/test/goldens/goldens/my_data_busy_390x844.png differ diff --git a/test/goldens/goldens/my_data_default_390x844.png b/test/goldens/goldens/my_data_default_390x844.png new file mode 100644 index 00000000..9afec4d4 Binary files /dev/null and b/test/goldens/goldens/my_data_default_390x844.png differ diff --git a/test/goldens/goldens/my_data_export_success_390x844.png b/test/goldens/goldens/my_data_export_success_390x844.png new file mode 100644 index 00000000..76802efc Binary files /dev/null and b/test/goldens/goldens/my_data_export_success_390x844.png differ diff --git a/test/goldens/goldens/my_data_freshness_checking_390x844.png b/test/goldens/goldens/my_data_freshness_checking_390x844.png new file mode 100644 index 00000000..33b34970 Binary files /dev/null and b/test/goldens/goldens/my_data_freshness_checking_390x844.png differ diff --git a/test/goldens/goldens/my_data_freshness_no_changes_390x844.png b/test/goldens/goldens/my_data_freshness_no_changes_390x844.png new file mode 100644 index 00000000..8d53c91a Binary files /dev/null and b/test/goldens/goldens/my_data_freshness_no_changes_390x844.png differ diff --git a/test/goldens/goldens/my_data_freshness_reminder_390x844.png b/test/goldens/goldens/my_data_freshness_reminder_390x844.png new file mode 100644 index 00000000..f6352766 Binary files /dev/null and b/test/goldens/goldens/my_data_freshness_reminder_390x844.png differ diff --git a/test/goldens/goldens/my_data_freshness_unexported_390x844.png b/test/goldens/goldens/my_data_freshness_unexported_390x844.png new file mode 100644 index 00000000..0a62a0be Binary files /dev/null and b/test/goldens/goldens/my_data_freshness_unexported_390x844.png differ diff --git a/test/goldens/goldens/my_data_operation_failure_390x844.png b/test/goldens/goldens/my_data_operation_failure_390x844.png new file mode 100644 index 00000000..931c399e Binary files /dev/null and b/test/goldens/goldens/my_data_operation_failure_390x844.png differ diff --git a/test/goldens/goldens/my_data_password_create_390x844.png b/test/goldens/goldens/my_data_password_create_390x844.png new file mode 100644 index 00000000..a62bce4c Binary files /dev/null and b/test/goldens/goldens/my_data_password_create_390x844.png differ diff --git a/test/goldens/goldens/my_data_password_input_390x844.png b/test/goldens/goldens/my_data_password_input_390x844.png new file mode 100644 index 00000000..8b219f60 Binary files /dev/null and b/test/goldens/goldens/my_data_password_input_390x844.png differ diff --git a/test/goldens/goldens/my_data_password_invalid_390x844.png b/test/goldens/goldens/my_data_password_invalid_390x844.png new file mode 100644 index 00000000..b5fdb721 Binary files /dev/null and b/test/goldens/goldens/my_data_password_invalid_390x844.png differ diff --git a/test/goldens/goldens/my_data_password_mismatch_390x844.png b/test/goldens/goldens/my_data_password_mismatch_390x844.png new file mode 100644 index 00000000..37b5b03d Binary files /dev/null and b/test/goldens/goldens/my_data_password_mismatch_390x844.png differ diff --git a/test/goldens/goldens/my_data_reset_confirmation_390x844.png b/test/goldens/goldens/my_data_reset_confirmation_390x844.png new file mode 100644 index 00000000..68d891fd Binary files /dev/null and b/test/goldens/goldens/my_data_reset_confirmation_390x844.png differ diff --git a/test/goldens/goldens/my_data_restore_preview_390x844.png b/test/goldens/goldens/my_data_restore_preview_390x844.png new file mode 100644 index 00000000..98aef04f Binary files /dev/null and b/test/goldens/goldens/my_data_restore_preview_390x844.png differ diff --git a/test/goldens/goldens/my_data_restore_success_390x844.png b/test/goldens/goldens/my_data_restore_success_390x844.png new file mode 100644 index 00000000..4f7048d2 Binary files /dev/null and b/test/goldens/goldens/my_data_restore_success_390x844.png differ diff --git a/test/goldens/goldens/my_page_default_390x844.png b/test/goldens/goldens/my_page_default_390x844.png new file mode 100644 index 00000000..33fdddbc Binary files /dev/null and b/test/goldens/goldens/my_page_default_390x844.png differ diff --git a/test/goldens/goldens/notification_permission_390x844.png b/test/goldens/goldens/notification_permission_390x844.png new file mode 100644 index 00000000..460be150 Binary files /dev/null and b/test/goldens/goldens/notification_permission_390x844.png differ diff --git a/test/goldens/goldens/onboarding_add_390x844.png b/test/goldens/goldens/onboarding_add_390x844.png new file mode 100644 index 00000000..398fa038 Binary files /dev/null and b/test/goldens/goldens/onboarding_add_390x844.png differ diff --git a/test/goldens/goldens/onboarding_buffer_390x844.png b/test/goldens/goldens/onboarding_buffer_390x844.png new file mode 100644 index 00000000..52ac277b Binary files /dev/null and b/test/goldens/goldens/onboarding_buffer_390x844.png differ diff --git a/test/goldens/goldens/onboarding_order_390x844.png b/test/goldens/goldens/onboarding_order_390x844.png new file mode 100644 index 00000000..0b92dc29 Binary files /dev/null and b/test/goldens/goldens/onboarding_order_390x844.png differ diff --git a/test/goldens/goldens/onboarding_select_390x844.png b/test/goldens/goldens/onboarding_select_390x844.png new file mode 100644 index 00000000..ae946f7b Binary files /dev/null and b/test/goldens/goldens/onboarding_select_390x844.png differ diff --git a/test/goldens/goldens/onboarding_selected_390x844.png b/test/goldens/goldens/onboarding_selected_390x844.png new file mode 100644 index 00000000..df4eef2a Binary files /dev/null and b/test/goldens/goldens/onboarding_selected_390x844.png differ diff --git a/test/goldens/goldens/onboarding_time_390x844.png b/test/goldens/goldens/onboarding_time_390x844.png new file mode 100644 index 00000000..92bf07d9 Binary files /dev/null and b/test/goldens/goldens/onboarding_time_390x844.png differ diff --git a/test/goldens/goldens/onboarding_time_focused_390x844.png b/test/goldens/goldens/onboarding_time_focused_390x844.png new file mode 100644 index 00000000..77092f83 Binary files /dev/null and b/test/goldens/goldens/onboarding_time_focused_390x844.png differ diff --git a/test/goldens/goldens/onboarding_time_long_390x844.png b/test/goldens/goldens/onboarding_time_long_390x844.png new file mode 100644 index 00000000..1b49e1cb Binary files /dev/null and b/test/goldens/goldens/onboarding_time_long_390x844.png differ diff --git a/test/goldens/goldens/onboarding_time_short_390x844.png b/test/goldens/goldens/onboarding_time_short_390x844.png new file mode 100644 index 00000000..6cfaa66d Binary files /dev/null and b/test/goldens/goldens/onboarding_time_short_390x844.png differ diff --git a/test/goldens/goldens/onboarding_welcome_390x844.png b/test/goldens/goldens/onboarding_welcome_390x844.png new file mode 100644 index 00000000..0216d219 Binary files /dev/null and b/test/goldens/goldens/onboarding_welcome_390x844.png differ diff --git a/test/goldens/goldens/preparation_edit_390x844.png b/test/goldens/goldens/preparation_edit_390x844.png new file mode 100644 index 00000000..60bf5311 Binary files /dev/null and b/test/goldens/goldens/preparation_edit_390x844.png differ diff --git a/test/goldens/goldens/preparation_spare_time_edit_390x844.png b/test/goldens/goldens/preparation_spare_time_edit_390x844.png new file mode 100644 index 00000000..dc686e78 Binary files /dev/null and b/test/goldens/goldens/preparation_spare_time_edit_390x844.png differ diff --git a/test/goldens/goldens/privacy_policy_390x844.png b/test/goldens/goldens/privacy_policy_390x844.png new file mode 100644 index 00000000..ec0da708 Binary files /dev/null and b/test/goldens/goldens/privacy_policy_390x844.png differ diff --git a/test/goldens/goldens/recovery_busy_390x844.png b/test/goldens/goldens/recovery_busy_390x844.png new file mode 100644 index 00000000..65f66df9 Binary files /dev/null and b/test/goldens/goldens/recovery_busy_390x844.png differ diff --git a/test/goldens/goldens/recovery_default_390x844.png b/test/goldens/goldens/recovery_default_390x844.png new file mode 100644 index 00000000..79fa972e Binary files /dev/null and b/test/goldens/goldens/recovery_default_390x844.png differ diff --git a/test/goldens/goldens/recovery_reset_confirmation_390x844.png b/test/goldens/goldens/recovery_reset_confirmation_390x844.png new file mode 100644 index 00000000..68d891fd Binary files /dev/null and b/test/goldens/goldens/recovery_reset_confirmation_390x844.png differ diff --git a/test/goldens/goldens/recurring_conflicts_390x844.png b/test/goldens/goldens/recurring_conflicts_390x844.png new file mode 100644 index 00000000..d9b09de7 Binary files /dev/null and b/test/goldens/goldens/recurring_conflicts_390x844.png differ diff --git a/test/goldens/goldens/recurring_date_time_390x844.png b/test/goldens/goldens/recurring_date_time_390x844.png new file mode 100644 index 00000000..4f1086a0 Binary files /dev/null and b/test/goldens/goldens/recurring_date_time_390x844.png differ diff --git a/test/goldens/goldens/recurring_detached_390x844.png b/test/goldens/goldens/recurring_detached_390x844.png new file mode 100644 index 00000000..a636a71f Binary files /dev/null and b/test/goldens/goldens/recurring_detached_390x844.png differ diff --git a/test/goldens/goldens/recurring_detail_390x844.png b/test/goldens/goldens/recurring_detail_390x844.png new file mode 100644 index 00000000..342404f2 Binary files /dev/null and b/test/goldens/goldens/recurring_detail_390x844.png differ diff --git a/test/goldens/goldens/recurring_empty_390x844.png b/test/goldens/goldens/recurring_empty_390x844.png new file mode 100644 index 00000000..c98c9e0c Binary files /dev/null and b/test/goldens/goldens/recurring_empty_390x844.png differ diff --git a/test/goldens/goldens/recurring_end_confirm_390x844.png b/test/goldens/goldens/recurring_end_confirm_390x844.png new file mode 100644 index 00000000..87963752 Binary files /dev/null and b/test/goldens/goldens/recurring_end_confirm_390x844.png differ diff --git a/test/goldens/goldens/recurring_end_date_390x844.png b/test/goldens/goldens/recurring_end_date_390x844.png new file mode 100644 index 00000000..840b8788 Binary files /dev/null and b/test/goldens/goldens/recurring_end_date_390x844.png differ diff --git a/test/goldens/goldens/recurring_ending_390x844.png b/test/goldens/goldens/recurring_ending_390x844.png new file mode 100644 index 00000000..b5a071e4 Binary files /dev/null and b/test/goldens/goldens/recurring_ending_390x844.png differ diff --git a/test/goldens/goldens/recurring_frequency_390x844.png b/test/goldens/goldens/recurring_frequency_390x844.png new file mode 100644 index 00000000..981d75c6 Binary files /dev/null and b/test/goldens/goldens/recurring_frequency_390x844.png differ diff --git a/test/goldens/goldens/recurring_management_390x844.png b/test/goldens/goldens/recurring_management_390x844.png new file mode 100644 index 00000000..5e4da279 Binary files /dev/null and b/test/goldens/goldens/recurring_management_390x844.png differ diff --git a/test/goldens/goldens/recurring_monthly_390x844.png b/test/goldens/goldens/recurring_monthly_390x844.png new file mode 100644 index 00000000..d5833135 Binary files /dev/null and b/test/goldens/goldens/recurring_monthly_390x844.png differ diff --git a/test/goldens/goldens/recurring_monthly_fields_390x844.png b/test/goldens/goldens/recurring_monthly_fields_390x844.png new file mode 100644 index 00000000..bc3dc18f Binary files /dev/null and b/test/goldens/goldens/recurring_monthly_fields_390x844.png differ diff --git a/test/goldens/goldens/recurring_occurrence_390x844.png b/test/goldens/goldens/recurring_occurrence_390x844.png new file mode 100644 index 00000000..f8101598 Binary files /dev/null and b/test/goldens/goldens/recurring_occurrence_390x844.png differ diff --git a/test/goldens/goldens/recurring_persistent_conflict_390x844.png b/test/goldens/goldens/recurring_persistent_conflict_390x844.png new file mode 100644 index 00000000..e355e6c3 Binary files /dev/null and b/test/goldens/goldens/recurring_persistent_conflict_390x844.png differ diff --git a/test/goldens/goldens/recurring_preparation_390x844.png b/test/goldens/goldens/recurring_preparation_390x844.png new file mode 100644 index 00000000..3238d8c7 Binary files /dev/null and b/test/goldens/goldens/recurring_preparation_390x844.png differ diff --git a/test/goldens/goldens/recurring_review_390x844.png b/test/goldens/goldens/recurring_review_390x844.png new file mode 100644 index 00000000..17a2750e Binary files /dev/null and b/test/goldens/goldens/recurring_review_390x844.png differ diff --git a/test/goldens/goldens/recurring_save_error_390x844.png b/test/goldens/goldens/recurring_save_error_390x844.png new file mode 100644 index 00000000..209eb277 Binary files /dev/null and b/test/goldens/goldens/recurring_save_error_390x844.png differ diff --git a/test/goldens/goldens/recurring_scope_390x844.png b/test/goldens/goldens/recurring_scope_390x844.png new file mode 100644 index 00000000..8046aded Binary files /dev/null and b/test/goldens/goldens/recurring_scope_390x844.png differ diff --git a/test/goldens/goldens/recurring_time_exceptions_390x844.png b/test/goldens/goldens/recurring_time_exceptions_390x844.png new file mode 100644 index 00000000..e9f4f2d0 Binary files /dev/null and b/test/goldens/goldens/recurring_time_exceptions_390x844.png differ diff --git a/test/goldens/goldens/recurring_weekly_390x844.png b/test/goldens/goldens/recurring_weekly_390x844.png new file mode 100644 index 00000000..d7727878 Binary files /dev/null and b/test/goldens/goldens/recurring_weekly_390x844.png differ diff --git a/test/goldens/goldens/reset_complete_390x844.png b/test/goldens/goldens/reset_complete_390x844.png new file mode 100644 index 00000000..fdea0698 Binary files /dev/null and b/test/goldens/goldens/reset_complete_390x844.png differ diff --git a/test/goldens/goldens/runtime_finish_confirm_390x844.png b/test/goldens/goldens/runtime_finish_confirm_390x844.png new file mode 100644 index 00000000..56648fcf Binary files /dev/null and b/test/goldens/goldens/runtime_finish_confirm_390x844.png differ diff --git a/test/goldens/goldens/runtime_late_390x844.png b/test/goldens/goldens/runtime_late_390x844.png new file mode 100644 index 00000000..46c37a74 Binary files /dev/null and b/test/goldens/goldens/runtime_late_390x844.png differ diff --git a/test/goldens/goldens/runtime_on_time_390x844.png b/test/goldens/goldens/runtime_on_time_390x844.png new file mode 100644 index 00000000..3c624fad Binary files /dev/null and b/test/goldens/goldens/runtime_on_time_390x844.png differ diff --git a/test/goldens/goldens/today_status_scheduled_318x54.png b/test/goldens/goldens/today_status_scheduled_318x54.png new file mode 100644 index 00000000..cf302de3 Binary files /dev/null and b/test/goldens/goldens/today_status_scheduled_318x54.png differ diff --git a/test/helpers/visual_test_fonts.dart b/test/helpers/visual_test_fonts.dart new file mode 100644 index 00000000..c31a89a3 --- /dev/null +++ b/test/helpers/visual_test_fonts.dart @@ -0,0 +1,28 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future loadVisualTestFonts() async { + final regular = await rootBundle.load('assets/fonts/Pretendard-Regular.ttf'); + final semiBold = await rootBundle.load( + 'assets/fonts/Pretendard-SemiBold.ttf', + ); + final bold = await rootBundle.load('assets/fonts/Pretendard-Bold.ttf'); + await (FontLoader('Pretendard') + ..addFont(Future.value(regular)) + ..addFont(Future.value(semiBold)) + ..addFont(Future.value(bold))) + .load(); + + final flutterRoot = + Platform.environment['FLUTTER_ROOT'] ?? + File(Platform.resolvedExecutable).parent.parent.parent.parent.parent.path; + final iconBytes = await File( + '$flutterRoot/bin/cache/artifacts/material_fonts/MaterialIcons-Regular.otf', + ).readAsBytes(); + await (FontLoader('MaterialIcons')..addFont( + Future.value(ByteData.sublistView(Uint8List.fromList(iconBytes))), + )) + .load(); +} diff --git a/test/presentation/alarm/components/preparation_step_list_widget_test.dart b/test/presentation/alarm/components/preparation_step_list_widget_test.dart index fb62c548..fd45403c 100644 --- a/test/presentation/alarm/components/preparation_step_list_widget_test.dart +++ b/test/presentation/alarm/components/preparation_step_list_widget_test.dart @@ -10,6 +10,7 @@ void main() { tester, ) async { var skipCount = 0; + final semantics = tester.ensureSemantics(); await tester.pumpWidget( MaterialApp( @@ -29,18 +30,20 @@ void main() { ), ), ); + await tester.pumpAndSettle(); expect(find.text('Wake up'), findsOneWidget); expect(find.text('Shower'), findsOneWidget); expect(find.text('Leave home'), findsOneWidget); - expect(find.byIcon(Icons.check), findsOneWidget); - expect(find.text('1분 30초'), findsOneWidget); + expect(find.bySemanticsLabel('완료'), findsOneWidget); + expect(find.text('8분 30초'), findsOneWidget); expect(find.text('이 단계 건너 뛰기'), findsOneWidget); await tester.tap(find.text('이 단계 건너 뛰기')); await tester.pump(); expect(skipCount, 1); + semantics.dispose(); }); testWidgets('scrolls toward the previous step when current step advances', ( diff --git a/test/presentation/alarm/screens/alarm_screen_visual_test.dart b/test/presentation/alarm/screens/alarm_screen_visual_test.dart new file mode 100644 index 00000000..2ff2e3e9 --- /dev/null +++ b/test/presentation/alarm/screens/alarm_screen_visual_test.dart @@ -0,0 +1,209 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.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_step_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/alarm/components/alarm_graph_animator.dart'; +import 'package:on_time_front/presentation/alarm/components/alarm_graph_component.dart'; +import 'package:on_time_front/presentation/alarm/screens/alarm_screen.dart'; +import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; +import 'package:on_time_front/presentation/shared/components/two_action_dialog.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; + +import '../../../helpers/visual_test_fonts.dart'; + +class _StaticScheduleBloc implements ScheduleBloc { + _StaticScheduleBloc(this.state); + @override + final ScheduleState state; + final events = []; + @override + Stream get stream => const Stream.empty(); + @override + void add(ScheduleEvent event) => events.add(event); + @override + bool get isClosed => false; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final _now = DateTime(2024, 12, 21, 10); + +ScheduleWithPreparationEntity _schedule({bool late = false}) => + ScheduleWithPreparationEntity( + id: 'runtime-visual', + scheduleName: '친구와 약속', + place: PlaceEntity(id: 'place', placeName: '강남역'), + scheduleTime: _now.add(Duration(minutes: late ? 10 : 90)), + moveTime: const Duration(minutes: 20), + scheduleSpareTime: Duration.zero, + isChanged: false, + isStarted: true, + scheduleNote: '', + preparation: PreparationWithTimeEntity( + preparationStepList: [ + PreparationStepWithTimeEntity( + id: '1', + preparationName: '샤워하기', + preparationTime: const Duration(minutes: 10), + elapsedTime: const Duration(minutes: 9, seconds: 50), + nextPreparationId: '2', + ), + PreparationStepWithTimeEntity( + id: '2', + preparationName: '메이크업', + preparationTime: const Duration(minutes: 20), + nextPreparationId: '3', + ), + PreparationStepWithTimeEntity( + id: '3', + preparationName: '옷 고르기/입기', + preparationTime: const Duration(minutes: 20), + nextPreparationId: '4', + ), + PreparationStepWithTimeEntity( + id: '4', + preparationName: '헤어', + preparationTime: const Duration(minutes: 20), + nextPreparationId: null, + ), + ], + ), + ); + +Future<_StaticScheduleBloc> _pump( + WidgetTester tester, { + bool late = false, + Size size = const Size(390, 844), + double scale = 1, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + final bloc = _StaticScheduleBloc( + ScheduleState.started(_schedule(late: late)), + ); + await tester.pumpWidget( + BlocProvider.value( + value: bloc, + child: MaterialApp( + 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), + textScaler: TextScaler.linear(scale), + ), + child: child!, + ), + home: AlarmScreen(nowProvider: () => _now), + ), + ), + ); + await tester.pumpAndSettle(); + return bloc; +} + +void main() { + setUpAll(loadVisualTestFonts); + + for (final late in [false, true]) { + testWidgets( + 'runtime ${late ? 'late' : 'on time'} matches its visual reference', + (tester) async { + await _pump(tester, late: late); + expect(tester.takeException(), isNull); + expect(find.text('0:10'), findsOneWidget); + expect(find.text('0분 10초'), findsOneWidget); + expect( + tester.getSize(find.byType(AlarmGraphAnimator)), + const Size.square(268), + ); + expect( + tester.getTopLeft(find.byType(AlarmGraphAnimator)).dy, + closeTo(121, 2), + ); + await expectLater( + find.byType(Scaffold), + matchesGoldenFile( + '../../../goldens/goldens/runtime_${late ? 'late' : 'on_time'}_390x844.png', + ), + ); + }, + ); + } + + testWidgets( + 'manual finish can be cancelled and persists only after confirmation', + (tester) async { + final bloc = await _pump(tester); + await tester.tap(find.text('준비 종료')); + await tester.pumpAndSettle(); + expect(find.text('준비가 모두 끝나셨나요?'), findsOneWidget); + expect(bloc.events.whereType(), isEmpty); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile( + '../../../goldens/goldens/runtime_finish_confirm_390x844.png', + ), + ); + await tester.tap(find.text('계속 준비')); + await tester.pumpAndSettle(); + expect(find.byType(TwoActionDialog), findsNothing); + expect(bloc.events.whereType(), isEmpty); + await tester.tap(find.text('준비 종료')); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant( + of: find.byType(TwoActionDialog), + matching: find.text('준비 종료'), + ), + ); + await tester.pumpAndSettle(); + expect(bloc.events.whereType(), hasLength(1)); + }, + ); + + testWidgets('runtime keeps skip and finish reachable with enlarged text', ( + tester, + ) async { + final bloc = await _pump(tester, size: const Size(320, 640), scale: 2); + expect(tester.takeException(), isNull); + await tester.ensureVisible(find.text('이 단계 건너 뛰기')); + await tester.pumpAndSettle(); + await tester.tap(find.text('이 단계 건너 뛰기')); + await tester.pump(); + expect(bloc.events.whereType(), hasLength(1)); + expect(find.text('준비 종료').hitTestable(), findsOneWidget); + }); + + testWidgets('timer ring immediately restores saved progress', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: AlarmGraphAnimator( + progress: .7, + backgroundColor: Colors.blue, + progressColor: Colors.white, + ), + ), + ), + ); + final paint = tester.widget( + find.descendant( + of: find.byType(AlarmGraphAnimator), + matching: find.byType(CustomPaint), + ), + ); + expect((paint.painter! as AlarmGraphComponent).progress, .7); + }); +} diff --git a/test/presentation/alarm/screens/preparation_flow_widget_test.dart b/test/presentation/alarm/screens/preparation_flow_widget_test.dart index 7f7e08a6..58093386 100644 --- a/test/presentation/alarm/screens/preparation_flow_widget_test.dart +++ b/test/presentation/alarm/screens/preparation_flow_widget_test.dart @@ -1315,6 +1315,15 @@ void main() { await pumpUntilFound(tester, find.byType(ElevatedButton)); await tapAndPump(tester, find.byType(ElevatedButton).first); + await pumpUntilFound(tester, find.byType(TwoActionDialog)); + expect(finishUseCase.calls, isEmpty); + await tapAndPump( + tester, + find.descendant( + of: find.byType(TwoActionDialog), + matching: find.text('Finish Preparation'), + ), + ); await pumpUntilFound(tester, find.textContaining('EARLYLATE:false')); expect(finishUseCase.calls.single.$2, 0); @@ -1392,6 +1401,103 @@ void main() { timeout: const Timeout(Duration(seconds: 15)), ); + for (final completesWhileOpen in [true, false]) { + testWidgets( + completesWhileOpen + ? 'last timer tick does not stack an automatic prompt over manual finish' + : 'manual continue before the last tick preserves the automatic prompt once', + (tester) async { + await setLargeTestViewport(tester); + now = DateTime(2026, 3, 20, 9, 29, 55); + final schedule = buildSchedule( + id: 'manual-finish-race', + scheduleTime: DateTime(2026, 3, 20, 10), + steps: const [ + PreparationStepWithTimeEntity( + id: 'last-step', + preparationName: 'Last step', + preparationTime: Duration(seconds: 5), + nextPreparationId: null, + elapsedTime: Duration.zero, + isDone: false, + ), + ], + ); + final router = GoRouter( + initialLocation: '/alarmScreen', + routes: [ + GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => AlarmScreen(nowProvider: () => now), + ), + GoRoute( + path: '/earlyLate', + builder: (_, __) => const Text('EARLYLATE'), + ), + ], + ); + addTearDown(router.dispose); + final alarmBloc = ScheduleBloc.test( + StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), + navigationService, + createSessionUseCase( + saveUseCase: NoopSaveTimedPreparationUseCase(), + getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase({}), + clearTimedUseCase: NoopClearTimedPreparationUseCase(), + startUseCase: startUseCase, + finishUseCase: finishUseCase, + earlyBundle: createEarlyStartUseCaseBundle(), + ), + nowProvider: () => now, + ); + addTearDown(alarmBloc.close); + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await pumpUntilFound(tester, find.text('Last step')); + expect(alarmBloc.state.schedule!.preparation.isAllStepsDone, isFalse); + await tapAndPump(tester, find.text('Finish Preparation')); + expect(find.byType(TwoActionDialog), findsOneWidget); + + if (!completesWhileOpen) { + await tapAndPump(tester, find.byType(ModalWideButton).first); + await tester.pump(const Duration(milliseconds: 250)); + expect(find.byType(TwoActionDialog), findsNothing); + } + + // Advance the real ScheduleBloc timer while preserving the live route. + now = now.add(const Duration(seconds: 6)); + await tester.pump(const Duration(seconds: 6)); + await tester.pump(const Duration(milliseconds: 400)); + expect(alarmBloc.state.schedule!.preparation.isAllStepsDone, isTrue); + expect( + find.byType(TwoActionDialog, skipOffstage: false), + findsOneWidget, + ); + expect(finishUseCase.calls, isEmpty); + + await tapAndPump(tester, find.byType(ModalWideButton).first); + await tester.pump(const Duration(milliseconds: 250)); + expect( + find.byType(TwoActionDialog, skipOffstage: false), + findsNothing, + ); + now = now.add(const Duration(seconds: 2)); + await tester.pump(const Duration(seconds: 2)); + expect(find.byType(TwoActionDialog), findsNothing); + expect(find.text('지각이에요'), findsOneWidget); + expect(finishUseCase.calls, isEmpty); + + await tapAndPump(tester, find.text('Finish Preparation')); + await tapAndPump(tester, find.byType(ModalWideButton).last); + await pumpUntilRouteText(tester, 'EARLYLATE'); + expect(finishUseCase.calls, hasLength(1)); + expect(find.text('EARLYLATE'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); + } + testWidgets( 'completion dialog continue shows live leave countdown for ongoing flow', (tester) async { @@ -1461,7 +1567,7 @@ void main() { expect( continuingTheme.colorScheme.primary.toARGB32(), - const Color(0xFF5C79FB).toARGB32(), + const Color(0xFF4F69DF).toARGB32(), ); expect( continuingTheme.colorScheme.primaryContainer.toARGB32(), @@ -1477,16 +1583,16 @@ void main() { ); expect(find.text('EARLYLATE'), findsNothing); expect(find.text('Ready to go'), findsOneWidget); - expect(find.text('5분 뒤에 나가야 해요'), findsOneWidget); - expect(find.text('05 : 00'), findsOneWidget); + expect(find.text('5분 뒤에 나가야 돼요'), findsOneWidget); + expect(find.text('5:00'), findsOneWidget); expect(finishUseCase.calls, isEmpty); now = now.add(const Duration(minutes: 1)); await tester.pump(const Duration(seconds: 1)); - expect(find.text('4분 뒤에 나가야 해요'), findsOneWidget); + expect(find.text('4분 뒤에 나가야 돼요'), findsOneWidget); expect(find.text('Ready to go'), findsOneWidget); - expect(find.text('04 : 00'), findsOneWidget); + expect(find.text('4:00'), findsOneWidget); alarmBloc.add(const ScheduleFinished(0)); await tester.pump(); @@ -1556,14 +1662,14 @@ void main() { await tapAndPump(tester, find.byType(ModalWideButton).first); - expect(find.text('1시간 뒤에 나가야 해요'), findsOneWidget); - expect(find.text('01 : 00 : 00'), findsOneWidget); + expect(find.text('1시간 뒤에 나가야 돼요'), findsOneWidget); + expect(find.text('1:00:00'), findsOneWidget); now = now.add(const Duration(minutes: 5)); await tester.pump(const Duration(seconds: 1)); - expect(find.text('55분 뒤에 나가야 해요'), findsOneWidget); - expect(find.text('55 : 00'), findsOneWidget); + expect(find.text('55분 뒤에 나가야 돼요'), findsOneWidget); + expect(find.text('55:00'), findsOneWidget); alarmBloc.add(const ScheduleFinished(0)); await tester.pump(); @@ -1628,8 +1734,8 @@ void main() { await tapAndPump(tester, find.byType(ModalWideButton).first); - expect(find.text('1분 뒤에 나가야 해요'), findsOneWidget); - expect(find.text('01 : 00'), findsOneWidget); + expect(find.text('1분 뒤에 나가야 돼요'), findsOneWidget); + expect(find.text('1:00'), findsOneWidget); now = now.add(const Duration(minutes: 2)); await tester.pump(const Duration(seconds: 1)); @@ -1646,7 +1752,7 @@ void main() { expect( lateTheme.colorScheme.primary.toARGB32(), - const Color(0xFFFF6953).toARGB32(), + const Color(0xFFBF2E22).toARGB32(), ); expect( lateTheme.colorScheme.primaryContainer.toARGB32(), @@ -1654,11 +1760,11 @@ void main() { ); expect( lateTheme.colorScheme.onPrimaryContainer.toARGB32(), - const Color(0xFFFF6953).toARGB32(), + const Color(0xFFBF2E22).toARGB32(), ); expect( lateScaffold.backgroundColor!.toARGB32(), - const Color(0xFFFF6953).toARGB32(), + const Color(0xFFFFEAE7).toARGB32(), ); expect( tester @@ -1671,19 +1777,19 @@ void main() { .widget(find.byType(AlarmGraphAnimator)) .backgroundColor .toARGB32(), - const Color(0xFFFFEAE7).toARGB32(), + const Color(0xFFFECBC0).toARGB32(), ); expect( tester .widget(find.byType(AlarmGraphAnimator)) .progressColor .toARGB32(), - const Color(0xFFFFEAE7).toARGB32(), + const Color(0xFFBF2E22).toARGB32(), ); - expect(find.text('준비시간을 1분 초과했어요'), findsOneWidget); + expect(find.text('준비 시간을 1분 초과했어요'), findsOneWidget); expect(find.text('지각이에요'), findsOneWidget); expect(find.text('Ready to go'), findsNothing); - expect(find.text('01 : 00'), findsOneWidget); + expect(find.text('1:00'), findsOneWidget); expect(find.text('Prep'), findsOneWidget); alarmBloc.add(const ScheduleFinished(0)); diff --git a/test/presentation/calendar/component/schedule_detail_test.dart b/test/presentation/calendar/component/schedule_detail_test.dart index b793f25d..7fd2cd68 100644 --- a/test/presentation/calendar/component/schedule_detail_test.dart +++ b/test/presentation/calendar/component/schedule_detail_test.dart @@ -38,6 +38,7 @@ void main() { ScheduleEntity? customSchedule, Duration? preparationTime, bool isEarlyStarted = false, + double textScale = 1, }) async { final targetSchedule = customSchedule ?? schedule; await tester.pumpWidget( @@ -45,13 +46,21 @@ void main() { bundle: _FakeSvgAssetBundle(), child: MaterialApp( locale: const Locale('en'), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( - body: ScheduleDetail( - schedule: targetSchedule, - preparationTime: preparationTime, - isEarlyStarted: isEarlyStarted, + body: SingleChildScrollView( + child: ScheduleDetail( + schedule: targetSchedule, + preparationTime: preparationTime, + isEarlyStarted: isEarlyStarted, + ), ), ), ), @@ -79,7 +88,7 @@ void main() { if (radius is! BorderRadius) { return false; } - return radius.topLeft.x == 12 && decoration.color != null; + return radius.topLeft.x == 8 && decoration.color != null; }); expect(actionContainers, findsWidgets); @@ -102,7 +111,7 @@ void main() { if (radius is! BorderRadius) { return false; } - return radius.topLeft.x == 12 && decoration.color != null; + return radius.topLeft.x == 8 && decoration.color != null; }); return actionContainers.evaluate().length; @@ -135,6 +144,27 @@ void main() { expect(preparationY, lessThan(spareY)); }); + testWidgets( + 'expanded schedule remains readable on narrow screen at 2x text', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 640); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + await pumpScheduleDetail( + tester, + textScale: 2, + preparationTime: const Duration(minutes: 20), + ); + await tester.tap(find.text('Design Review')); + await tester.pumpAndSettle(); + expect(find.text('Travel Time'), findsOneWidget); + expect(find.text('Preparation Time'), findsOneWidget); + expect(find.text('20 minutes'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('preparation fallback is shown as dash when unavailable', ( tester, ) async { @@ -164,7 +194,7 @@ void main() { tester, preparationTime: const Duration(minutes: 20), ); - await tester.tap(find.byType(ListTile)); + await tester.tap(find.text('Design Review')); await tester.pumpAndSettle(); expect(find.text('Travel Time'), findsOneWidget); await openTrailingActions(tester); @@ -181,7 +211,7 @@ void main() { expect(find.text('Design Review'), findsOneWidget); expect(find.text('Office'), findsOneWidget); - expect(find.text('09:00'), findsOneWidget); + expect(find.text('9:00'), findsOneWidget); final updatedSchedule = ScheduleEntity( id: 'schedule-1', @@ -202,7 +232,7 @@ void main() { expect(find.text('10:30'), findsOneWidget); expect(find.text('Design Review'), findsNothing); expect(find.text('Office'), findsNothing); - expect(find.text('09:00'), findsNothing); + expect(find.text('9:00'), findsNothing); }); testWidgets('edit action is available before preparation starts', ( diff --git a/test/presentation/calendar/screens/calendar_screen_test.dart b/test/presentation/calendar/screens/calendar_screen_test.dart index 63bf08c0..14731762 100644 --- a/test/presentation/calendar/screens/calendar_screen_test.dart +++ b/test/presentation/calendar/screens/calendar_screen_test.dart @@ -26,6 +26,8 @@ import 'package:on_time_front/presentation/shared/components/calendar/centered_c import 'package:on_time_front/presentation/shared/theme/theme.dart'; import 'package:table_calendar/table_calendar.dart'; +import '../../../helpers/visual_test_fonts.dart'; + class _FakeSvgAssetBundle extends CachingAssetBundle { static const _svg = ''; @@ -39,15 +41,17 @@ class _FakeSvgAssetBundle extends CachingAssetBundle { class _StubLoadSchedulesForMonthUseCase implements LoadSchedulesForMonthUseCase { - _StubLoadSchedulesForMonthUseCase({this.throwOnCall = false}); + _StubLoadSchedulesForMonthUseCase({this.failuresRemaining = 0, this.hold}); - final bool throwOnCall; + int failuresRemaining; + final Completer? hold; final calls = []; @override Future call(DateTime date) async { calls.add(date); - if (throwOnCall) { + await hold?.future; + if (failuresRemaining-- > 0) { throw Exception('month unavailable'); } } @@ -114,6 +118,7 @@ class _StubScheduleBloc extends Mock implements ScheduleBloc { void main() { TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(loadVisualTestFonts); setUp(() async { await getIt.reset(); @@ -127,11 +132,15 @@ void main() { WidgetTester tester, { required Size size, required DateTime initialDate, + DateTime? referenceDate, List schedules = const [], double textScale = 1.0, + Locale locale = const Locale('en'), + bool visual = false, _StubLoadSchedulesForMonthUseCase? loadSchedulesForMonthUseCase, _StubDeleteScheduleUseCase? deleteScheduleUseCase, CalendarCreateSheetBuilder? createSheetBuilder, + bool settle = true, }) async { final loadUseCase = loadSchedulesForMonthUseCase ?? _StubLoadSchedulesForMonthUseCase(); @@ -148,34 +157,159 @@ void main() { tester.view.devicePixelRatio = 1; tester.view.physicalSize = size; + if (visual) { + tester.view.padding = FakeViewPadding(top: 44, bottom: 21); + addTearDown(tester.view.resetPadding); + } addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget( - DefaultAssetBundle( - bundle: _FakeSvgAssetBundle(), - child: MaterialApp( - theme: themeData, - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: MediaQuery( - data: MediaQueryData( - size: size, - textScaler: TextScaler.linear(textScale), - ), - child: BlocProvider.value( - value: _StubScheduleBloc(), - child: CalendarScreen( - initialDate: initialDate, - createSheetBuilder: createSheetBuilder, - ), - ), + final app = MaterialApp( + theme: themeData, + locale: locale, + debugShowCheckedModeBanner: false, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: MediaQuery( + data: MediaQueryData( + size: size, + textScaler: TextScaler.linear(textScale), + padding: visual + ? const EdgeInsets.only(top: 44, bottom: 21) + : EdgeInsets.zero, + ), + child: BlocProvider.value( + value: _StubScheduleBloc(), + child: CalendarScreen( + initialDate: initialDate, + referenceDate: referenceDate, + createSheetBuilder: createSheetBuilder, ), ), ), ); - await tester.pumpAndSettle(); + await tester.pumpWidget( + visual + ? app + : DefaultAssetBundle(bundle: _FakeSvgAssetBundle(), child: app), + ); + if (settle) { + await tester.pumpAndSettle(); + } else { + await tester.pump(); + } + } + + testWidgets('calendar empty shell can be compared with Figma', ( + tester, + ) async { + await pumpCalendarScreen( + tester, + size: const Size(390, 852), + initialDate: DateTime(2024, 12, 21), + referenceDate: DateTime(2024, 12, 1), + schedules: _calendarMarkerSchedules(), + locale: const Locale('ko'), + visual: true, + ); + + expect(find.text('2024년 12월'), findsOneWidget); + expect(find.text('약속이 없어요'), findsOneWidget); + expect(find.text('약속 추가하기'), findsOneWidget); + expect( + tester.getRect(find.byKey(const Key('calendar_card'))), + const Rect.fromLTWH(18, 110, 354, 390), + ); + expect( + tester.getSize(find.widgetWithText(ElevatedButton, '약속 추가하기')).height, + greaterThanOrEqualTo(44), + ); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('../../../goldens/goldens/calendar_empty_390x852.png'), + ); + }); + + for (final variant in [ + 'list', + 'expanded', + 'swipe_actions', + 'expanded_actions', + 'delete_confirm', + ]) { + testWidgets('calendar $variant matches source card geometry and actions', ( + tester, + ) async { + final deletion = _StubDeleteScheduleUseCase(); + await pumpCalendarScreen( + tester, + size: const Size(390, 852), + initialDate: DateTime(2024, 12, 21), + referenceDate: DateTime(2024, 12, 1), + schedules: [ + ..._calendarMarkerSchedules(), + for (var index = 0; index < 2; index++) + ScheduleEntity( + id: 'figma-$index', + place: const PlaceEntity(id: 'place', placeName: '약속 장소'), + scheduleName: '약속 이름', + scheduleTime: DateTime(2024, 12, 21, 18 + index), + moveTime: const Duration(minutes: 80), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 10), + scheduleNote: '', + ), + ], + deleteScheduleUseCase: deletion, + locale: const Locale('ko'), + visual: true, + ); + final expanded = + variant == 'expanded' || + variant == 'expanded_actions' || + variant == 'delete_confirm'; + if (expanded) { + await tester.tap(find.text('약속 이름').first); + await tester.pumpAndSettle(); + } + expect( + tester.getSize(find.byKey(const ValueKey('schedule_card_figma-0'))), + Size(354, expanded ? 192 : 82), + ); + if (variant.contains('actions') || variant == 'delete_confirm') { + await tester.drag( + find.byType(SwipeActionCell).first, + const Offset(-210, 0), + ); + await tester.pumpAndSettle(); + } + if (variant == 'delete_confirm') { + final deleteAction = find.byWidgetPredicate( + (widget) => + widget is Container && + widget.decoration is BoxDecoration && + (widget.decoration as BoxDecoration).color == + const Color(0xffbf2e22), + ); + await tester.tap(deleteAction.first); + await tester.pumpAndSettle(); + expect(deletion.deletedSchedules, isEmpty); + } + expect(tester.takeException(), isNull); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile( + '../../../goldens/goldens/calendar_${variant}_390x852.png', + ), + ); + if (variant == 'delete_confirm') { + await tester.tap(find.text('취소')); + await tester.pumpAndSettle(); + expect(deletion.deletedSchedules, isEmpty); + expect(find.text('약속 이름'), findsNWidgets(2)); + } + }); } testWidgets('compact future empty state fits with add button', ( @@ -358,17 +492,77 @@ void main() { }); testWidgets('month load failures show calendar error state', (tester) async { + final load = _StubLoadSchedulesForMonthUseCase(failuresRemaining: 1); await pumpCalendarScreen( tester, - size: const Size(390, 844), - initialDate: DateTime(2026, 1, 15), + size: const Size(390, 852), + initialDate: DateTime(2024, 12, 21), + referenceDate: DateTime(2024, 12, 1), + locale: const Locale('ko'), + visual: true, + loadSchedulesForMonthUseCase: load, + ); + + expect(find.text('오류'), findsOneWidget); + expect(find.byType(TableCalendar), findsNothing); + expect(find.byKey(const Key('calendar_month_retry')), findsOneWidget); + expect( + tester.getRect(find.byKey(const Key('calendar_card'))), + const Rect.fromLTWH(18, 110, 354, 390), + ); + expect(tester.getTopLeft(find.text('오류')).dy, inInclusiveRange(250, 265)); + expect( + tester.getTopLeft(find.byKey(const Key('calendar_month_retry'))).dy, + inInclusiveRange(290, 305), + ); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('../../../goldens/goldens/calendar_error_390x852.png'), + ); + + await tester.tap(find.byKey(const Key('calendar_month_retry'))); + await tester.pumpAndSettle(); + expect(load.calls, hasLength(2)); + expect(find.byType(TableCalendar), findsOneWidget); + expect(find.byKey(const Key('calendar_month_retry')), findsNothing); + }); + + testWidgets('month loading keeps its fixed surface and blocks date actions', ( + tester, + ) async { + final pending = Completer(); + addTearDown(() { + if (!pending.isCompleted) pending.complete(); + }); + await pumpCalendarScreen( + tester, + size: const Size(390, 852), + initialDate: DateTime(2024, 12, 21), + referenceDate: DateTime(2024, 12, 1), + locale: const Locale('ko'), + visual: true, loadSchedulesForMonthUseCase: _StubLoadSchedulesForMonthUseCase( - throwOnCall: true, + hold: pending, ), + settle: false, ); - - expect(find.text('Error'), findsOneWidget); expect(find.byType(TableCalendar), findsNothing); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('약속 추가하기'), findsNothing); + expect( + tester.getRect(find.byKey(const Key('calendar_card'))), + const Rect.fromLTWH(18, 110, 354, 390), + ); + await tester.pump(const Duration(milliseconds: 250)); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile( + '../../../goldens/goldens/calendar_loading_390x852.png', + ), + ); + pending.complete(); + await tester.pumpAndSettle(); + expect(find.byType(TableCalendar), findsOneWidget); }); testWidgets('delete failure keeps calendar visible and shows error dialog', ( @@ -410,7 +604,7 @@ void main() { if (widget is! Container) return false; final decoration = widget.decoration; return decoration is BoxDecoration && - decoration.color == themeData.colorScheme.error; + decoration.color == const Color(0xffbf2e22); }); expect(deleteAction, findsOneWidget); @@ -526,7 +720,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byIcon(Icons.arrow_back)); + await tester.tap(find.text('Home')); await tester.pumpAndSettle(); expect(find.byType(CalendarScreen), findsNothing); @@ -551,3 +745,13 @@ ScheduleEntity _schedule({ scheduleNote: '', ); } + +List _calendarMarkerSchedules() => [ + for (final entry in {4: 3, 8: 2, 14: 1, 20: 3}.entries) + for (var index = 0; index < entry.value; index++) + _schedule( + id: 'marker-${entry.key}-$index', + name: '달력 일정', + date: DateTime(2024, 12, entry.key), + ), +]; diff --git a/test/presentation/home/components/month_calendar_test.dart b/test/presentation/home/components/month_calendar_test.dart index ddfa371f..c877898e 100644 --- a/test/presentation/home/components/month_calendar_test.dart +++ b/test/presentation/home/components/month_calendar_test.dart @@ -165,6 +165,77 @@ void main() { expect(find.byType(MonthCalendar), findsOneWidget); expect(tester.takeException(), isNull); }); + + testWidgets('loading masks only the month calendar and blocks date taps', ( + tester, + ) async { + DateTime? selected; + await tester.pumpWidget( + _TestApp( + child: SizedBox( + width: 360, + height: 391, + child: MonthCalendar( + initialDate: DateTime(2024, 12, 21), + dispatchBlocEvents: false, + monthlySchedulesState: const MonthlySchedulesState( + status: MonthlySchedulesStatus.loading, + ), + onDateSelected: (date) => selected = date, + ), + ), + ), + ); + await tester.pump(); + + expect( + tester.getSize(find.byKey(const Key('home_month_loading_overlay'))), + const Size(360, 391), + ); + await tester.tapAt(tester.getCenter(find.text('21').last)); + await tester.pump(); + expect(selected, isNull); + expect(find.byKey(const Key('home_month_retry')), findsNothing); + }); + + testWidgets('error retry reloads the displayed month', (tester) async { + final bloc = _RecordingMonthlySchedulesBloc(); + await tester.pumpWidget( + _TestApp( + bloc: bloc, + child: SizedBox( + width: 360, + height: 391, + child: MonthCalendar( + initialDate: DateTime(2024, 12, 21), + monthlySchedulesState: const MonthlySchedulesState( + status: MonthlySchedulesStatus.error, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('오류'), findsOneWidget); + expect(find.text('다시 시도'), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('home_month_error_overlay'))), + const Size(360, 391), + ); + await tester.tap(find.byKey(const Key('home_month_retry'))); + await tester.pump(); + expect( + bloc.addedEvents, + contains( + isA().having( + (event) => event.date, + 'date', + DateTime(2024, 12, 21), + ), + ), + ); + }); } class _TestApp extends StatelessWidget { @@ -177,6 +248,7 @@ class _TestApp extends StatelessWidget { Widget build(BuildContext context) { final app = MaterialApp( theme: themeData, + locale: const Locale('ko'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold(body: child), @@ -189,6 +261,7 @@ class _TestApp extends StatelessWidget { return MaterialApp( theme: themeData, + locale: const Locale('ko'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: BlocProvider.value( diff --git a/test/presentation/home/components/todays_schedule_tile_visual_test.dart b/test/presentation/home/components/todays_schedule_tile_visual_test.dart new file mode 100644 index 00000000..e8504dcb --- /dev/null +++ b/test/presentation/home/components/todays_schedule_tile_visual_test.dart @@ -0,0 +1,100 @@ +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/schedule_entity.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/home/components/todays_schedule_tile.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; + +import '../../../helpers/visual_test_fonts.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(loadVisualTestFonts); + + Future pumpTile( + WidgetTester tester, { + ScheduleEntity? schedule, + TodayScheduleTileState state = TodayScheduleTileState.scheduled, + VoidCallback? onTap, + }) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(318, 54); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + locale: const Locale('ko'), + debugShowCheckedModeBanner: false, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: TodaysScheduleTile( + schedule: schedule, + state: state, + onTap: onTap, + ), + ), + ), + ); + } + + testWidgets('scheduled status matches the Figma component', (tester) async { + var tapCount = 0; + await pumpTile(tester, schedule: _schedule(), onTap: () => tapCount++); + + expect(find.text('5월 12일 오후 6시'), findsOneWidget); + expect(find.text('OO과 데이트'), findsOneWidget); + expect( + tester.getSize(find.byType(TodaysScheduleTile)), + const Size(318, 54), + ); + await expectLater( + find.byType(Scaffold), + matchesGoldenFile( + '../../../goldens/goldens/today_status_scheduled_318x54.png', + ), + ); + await tester.tap(find.byType(TodaysScheduleTile)); + expect(tapCount, 1); + }); + + testWidgets('active and completed statuses keep their distinct labels', ( + tester, + ) async { + await pumpTile( + tester, + schedule: _schedule(), + state: TodayScheduleTileState.active, + ); + expect(find.text('준비 진행 중'), findsOneWidget); + expect(find.text('OO과 데이트'), findsOneWidget); + + await pumpTile( + tester, + schedule: _schedule(), + state: TodayScheduleTileState.completed, + ); + expect(find.text('완료'), findsOneWidget); + expect(find.text('OO과 데이트'), findsOneWidget); + }); + + testWidgets('empty status retains the source message', (tester) async { + await pumpTile(tester); + expect(find.text('약속이 없는 날이에요'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} + +ScheduleEntity _schedule() => ScheduleEntity( + id: 'figma-today', + place: const PlaceEntity(id: 'place-1', placeName: '카페'), + scheduleName: 'OO과 데이트', + scheduleTime: DateTime(2024, 5, 12, 18), + moveTime: const Duration(minutes: 20), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 10), + scheduleNote: '', +); diff --git a/test/presentation/home/screens/home_screen_tmp_test.dart b/test/presentation/home/screens/home_screen_tmp_test.dart index 4b56924d..ee4ca5a2 100644 --- a/test/presentation/home/screens/home_screen_tmp_test.dart +++ b/test/presentation/home/screens/home_screen_tmp_test.dart @@ -98,6 +98,9 @@ void main() { Widget buildSubject({ required Size size, required ScheduleState scheduleState, + MonthlySchedulesState monthState = const MonthlySchedulesState( + status: MonthlySchedulesStatus.success, + ), double textScale = 1.0, EdgeInsets padding = EdgeInsets.zero, }) { @@ -133,12 +136,7 @@ void main() { child: SizedBox( width: size.width, height: size.height, - child: HomeScreenContent( - state: const MonthlySchedulesState( - status: MonthlySchedulesStatus.success, - ), - userScore: 80, - ), + child: HomeScreenContent(state: monthState, userScore: 80), ), ), ), @@ -278,21 +276,62 @@ void main() { ); await tester.pump(); - expect(tester.getSize(find.byKey(const Key('home_banner'))).width, 390); + expect( + tester.getSize(find.byKey(const Key('home_hero'))), + const Size(390, 230), + ); expect( tester.getSize(find.byKey(const Key('today_schedule_card'))).width, - 358, + 360, ); expect( tester.getSize(find.byKey(const Key('today_schedule_card'))).height, - 137, + closeTo(137, 1), ); - expect(_top(tester, 'home_banner'), closeTo(51, 1)); + expect(_top(tester, 'home_hero'), 0); expect(_top(tester, 'today_schedule_card'), closeTo(177, 1)); expect(_top(tester, 'today_background_surface'), closeTo(230, 1)); expect(tester.takeException(), isNull); }); + testWidgets('monthly loading and error leave the home hero and today card', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + for (final status in [ + MonthlySchedulesStatus.loading, + MonthlySchedulesStatus.error, + ]) { + await tester.pumpWidget( + buildSubject( + size: const Size(390, 844), + scheduleState: const ScheduleState.notExists(), + monthState: MonthlySchedulesState(status: status), + ), + ); + await tester.pump(); + + expect(find.byKey(const Key('home_hero')), findsOneWidget); + expect(find.byKey(const Key('today_schedule_card')), findsOneWidget); + expect(find.byKey(const Key('home_month_calendar')), findsOneWidget); + expect( + find.byKey( + Key( + status == MonthlySchedulesStatus.loading + ? 'home_month_loading_overlay' + : 'home_month_error_overlay', + ), + ), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + } + }); + testWidgets('banner clears the device safe area before rendering', ( tester, ) async { @@ -310,11 +349,8 @@ void main() { ); await tester.pump(); - expect(_top(tester, 'home_banner'), greaterThanOrEqualTo(71)); - expect( - _bottomGap(tester, 'home_month_calendar', 844), - lessThanOrEqualTo(6), - ); + expect(_top(tester, 'home_hero_copy'), greaterThanOrEqualTo(71)); + expect(_top(tester, 'home_month_calendar'), 330); expect(tester.takeException(), isNull); }); diff --git a/test/presentation/home/screens/home_screen_visual_test.dart b/test/presentation/home/screens/home_screen_visual_test.dart new file mode 100644 index 00000000..19bd63f3 --- /dev/null +++ b/test/presentation/home/screens/home_screen_visual_test.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mockito/mockito.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; +import 'package:on_time_front/presentation/calendar/bloc/monthly_schedules_bloc.dart'; +import 'package:on_time_front/presentation/home/screens/home_screen_tmp.dart'; +import 'package:on_time_front/presentation/shared/components/bottom_nav_bar_scaffold.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; + +import '../../../helpers/visual_test_fonts.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(loadVisualTestFonts); + + testWidgets('Home shell matches Figma with a real December date grid', ( + tester, + ) async { + await _pumpHomeFixture(tester, MonthlySchedulesStatus.success); + + expect(find.text('5월 12일 오후 6시'), findsOneWidget); + expect(find.text('2024년 12월'), findsOneWidget); + expect( + tester.getRect(find.byKey(const Key('home_hero'))), + const Rect.fromLTWH(0, 0, 390, 230), + ); + expect( + tester.getRect(find.byKey(const Key('today_schedule_card'))), + const Rect.fromLTWH(15, 177, 360, 137), + ); + expect( + tester.getRect(find.byKey(const Key('today_background_surface'))), + const Rect.fromLTWH(0, 230, 390, 84), + ); + expect( + tester.getRect(find.byKey(const Key('home_month_calendar'))), + const Rect.fromLTWH(15, 330, 360, 391), + ); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('../../../goldens/goldens/home_default_390x844.png'), + ); + debugDisableShadows = true; + }); + + testWidgets('Home loading masks only the month content', (tester) async { + await _pumpHomeFixture(tester, MonthlySchedulesStatus.loading); + + expect(find.byKey(const Key('home_hero')), findsOneWidget); + expect(find.byKey(const Key('today_schedule_card')), findsOneWidget); + expect(find.byKey(const Key('home_month_loading_overlay')), findsOneWidget); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('../../../goldens/goldens/home_loading_390x844.png'), + ); + debugDisableShadows = true; + }); + + testWidgets('Home error keeps today visible and offers retry', ( + tester, + ) async { + await _pumpHomeFixture(tester, MonthlySchedulesStatus.error); + + expect(find.byKey(const Key('home_hero')), findsOneWidget); + expect(find.byKey(const Key('today_schedule_card')), findsOneWidget); + expect(find.byKey(const Key('home_month_error_overlay')), findsOneWidget); + expect(find.byKey(const Key('home_month_retry')), findsOneWidget); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('../../../goldens/goldens/home_error_390x844.png'), + ); + debugDisableShadows = true; + }); +} + +Future _pumpHomeFixture( + WidgetTester tester, + MonthlySchedulesStatus status, +) async { + debugDisableShadows = false; + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + tester.view.padding = FakeViewPadding(top: 44, bottom: 21); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetPadding); + + final router = GoRouter( + initialLocation: '/home', + routes: [ + GoRoute( + path: '/home', + builder: (_, _) => BottomNavBarScaffold( + child: HomeScreenContent( + state: MonthlySchedulesState( + status: status, + schedules: _calendarEvents(), + ), + userScore: 80, + referenceDate: DateTime(2024, 12, 21), + ), + ), + ), + GoRoute( + path: '/myPage', + builder: (_, _) => const Scaffold(body: Text('My Page')), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + BlocProvider.value( + value: _VisualScheduleBloc(ScheduleState.upcoming(_todaySchedule())), + child: MaterialApp.router( + theme: themeData, + locale: const Locale('ko'), + debugShowCheckedModeBanner: false, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + ), + ), + ); + if (status == MonthlySchedulesStatus.loading) { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + } else { + await tester.pumpAndSettle(); + } + await tester.runAsync(() async { + await precacheImage( + const AssetImage('home_mascot.png', package: 'assets'), + tester.element(find.byType(HomeScreenContent)), + ); + }); + await tester.pump(); +} + +class _VisualScheduleBloc extends Mock implements ScheduleBloc { + _VisualScheduleBloc(this._state); + + final ScheduleState _state; + + @override + ScheduleState get state => _state; + + @override + Stream get stream => const Stream.empty(); + + @override + bool get isClosed => false; +} + +ScheduleWithPreparationEntity _todaySchedule() => ScheduleWithPreparationEntity( + id: 'figma-today', + place: const PlaceEntity(id: 'place-1', placeName: '카페'), + scheduleName: 'OO과 데이트', + scheduleTime: DateTime(2024, 5, 12, 18), + moveTime: const Duration(minutes: 20), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 10), + scheduleNote: '', + preparation: const PreparationWithTimeEntity( + preparationStepList: [ + PreparationStepWithTimeEntity( + id: 'prep-1', + preparationName: '준비', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ), +); + +Map> _calendarEvents() => { + for (final count in {4: 3, 8: 2, 14: 1, 20: 3}.entries) + DateTime(2024, 12, count.key): [ + for (var index = 0; index < count.value; index++) + ScheduleEntity( + id: 'calendar-${count.key}-$index', + place: const PlaceEntity(id: 'place-2', placeName: '카페'), + scheduleName: '일정', + scheduleTime: DateTime(2024, 12, count.key, 12), + moveTime: const Duration(minutes: 20), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 10), + scheduleNote: '', + ), + ], +}; diff --git a/test/presentation/my_page/my_data_screen_visual_test.dart b/test/presentation/my_page/my_data_screen_visual_test.dart new file mode 100644 index 00000000..5409f2b9 --- /dev/null +++ b/test/presentation/my_page/my_data_screen_visual_test.dart @@ -0,0 +1,548 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_service.dart'; +import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/presentation/my_page/my_data_screen.dart'; +import 'package:on_time_front/presentation/shared/components/app_spinner.dart'; +import 'package:on_time_front/presentation/shared/components/two_action_dialog.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; + +import '../../helpers/visual_test_fonts.dart'; + +class _BackupServiceStub extends Fake implements BackupService { + String? exportedPassword; + String? restorePassword; + BackupFreshnessStatus freshness = const BackupFreshnessStatus( + freshness: BackupFreshness.neverExported, + ); + Completer? pendingFreshness; + Completer? pendingExport; + Object? exportError; + + @override + Future getFreshness() async => + pendingFreshness?.future ?? freshness; + + @override + Future exportToUserSelectedFile(String password) async { + exportedPassword = password; + if (exportError case final error?) throw error; + if (pendingExport case final operation?) return operation.future; + return true; + } + + @override + Future selectAndPreviewRestore( + String password, + ) async { + restorePassword = password; + return null; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(loadVisualTestFonts); + + late _BackupServiceStub backupService; + + setUp(() async { + await getIt.reset(); + backupService = _BackupServiceStub(); + getIt.registerSingleton(backupService); + }); + + tearDown(() async => getIt.reset()); + + Future pumpScreen(WidgetTester tester, {double textScale = 1}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + tester.view.padding = FakeViewPadding(top: 44, bottom: 21); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPadding); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + debugShowCheckedModeBanner: false, + home: MediaQuery( + data: MediaQueryData( + size: const Size(390, 844), + padding: const EdgeInsets.only(top: 44, bottom: 21), + textScaler: TextScaler.linear(textScale), + ), + child: const MyDataScreen(), + ), + ), + ); + await tester.pump(); + } + + testWidgets('my data uses the Figma 358px content grid', (tester) async { + await pumpScreen(tester); + + for (final key in [ + 'backupStatusCard', + 'backupExportRow', + 'backupRestoreRow', + 'localDataResetRow', + ]) { + final finder = find.byKey(Key(key)); + expect(finder, findsOneWidget); + expect(tester.getTopLeft(finder).dx, 16); + expect(tester.getSize(finder).width, 358); + expect(tester.getSize(finder).height, 68); + } + expect( + tester.getTopLeft(find.byKey(const Key('backupStatusCard'))).dy, + 122, + ); + expect(tester.getTopLeft(find.byKey(const Key('backupExportRow'))).dy, 208); + expect( + tester.getTopLeft(find.byKey(const Key('backupRestoreRow'))).dy, + 294, + ); + expect( + tester.getTopLeft(find.byKey(const Key('localDataResetRow'))).dy, + 380, + ); + expect(find.text('아직 내보낸 백업이 없습니다.'), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('backupExportRow'))).height, + greaterThanOrEqualTo(68), + ); + }); + + testWidgets('my data default visual remains reviewed', (tester) async { + await pumpScreen(tester); + await expectLater( + find.byType(Scaffold), + matchesGoldenFile('../../goldens/goldens/my_data_default_390x844.png'), + ); + }); + + testWidgets('freshness checking has the reviewed status card', ( + tester, + ) async { + backupService.pendingFreshness = Completer(); + await pumpScreen(tester); + expect(find.text('확인 중'), findsOneWidget); + await expectLater( + find.byType(Scaffold), + matchesGoldenFile( + '../../goldens/goldens/my_data_freshness_checking_390x844.png', + ), + ); + backupService.pendingFreshness!.complete(backupService.freshness); + await tester.pump(); + }); + + testWidgets('freshness no changes has the reviewed status card', ( + tester, + ) async { + backupService.freshness = const BackupFreshnessStatus( + freshness: BackupFreshness.noChanges, + ); + await pumpScreen(tester); + expect(find.text('마지막 백업 이후 변경 사항이 없습니다.'), findsOneWidget); + await expectLater( + find.byType(Scaffold), + matchesGoldenFile( + '../../goldens/goldens/my_data_freshness_no_changes_390x844.png', + ), + ); + }); + + testWidgets('freshness unexported changes has the reviewed status card', ( + tester, + ) async { + backupService.freshness = const BackupFreshnessStatus( + freshness: BackupFreshness.unexportedChanges, + ); + await pumpScreen(tester); + expect(find.text('백업되지 않은 변경 사항이 있습니다.'), findsOneWidget); + await expectLater( + find.byType(Scaffold), + matchesGoldenFile( + '../../goldens/goldens/my_data_freshness_unexported_390x844.png', + ), + ); + }); + + testWidgets('freshness reminder grows the card without hiding actions', ( + tester, + ) async { + backupService.freshness = const BackupFreshnessStatus( + freshness: BackupFreshness.unexportedChanges, + reminderDue: true, + ); + await pumpScreen(tester); + expect(find.text('30일 이상 백업되지 않은 변경 사항이 있습니다.'), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('backupStatusCard'))).height, + greaterThan(68), + ); + expect(find.byKey(const Key('localDataResetRow')), findsOneWidget); + await expectLater( + find.byType(Scaffold), + matchesGoldenFile( + '../../goldens/goldens/my_data_freshness_reminder_390x844.png', + ), + ); + }); + + testWidgets('large text keeps every data action available', (tester) async { + await pumpScreen(tester, textScale: 2); + + for (final key in [ + 'backupExportRow', + 'backupRestoreRow', + 'localDataResetRow', + ]) { + expect(find.byKey(Key(key)), findsOneWidget); + } + expect(tester.takeException(), isNull); + }); + + testWidgets('canceling reset leaves data untouched', (tester) async { + await pumpScreen(tester); + + await tester.tap(find.byKey(const Key('localDataResetRow'))); + await tester.pumpAndSettle(); + expect(find.text('복구하지 않고 삭제하시겠습니까?'), findsOneWidget); + await tester.tap(find.text('취소')); + await tester.pumpAndSettle(); + expect(find.text('복구하지 않고 삭제하시겠습니까?'), findsNothing); + }); + + testWidgets('reset confirmation shows the refreshed destructive screen', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await pumpScreen(tester); + await tester.tap(find.byKey(const Key('localDataResetRow'))); + await tester.pumpAndSettle(); + + expect(find.text('복구하지 않고 삭제하시겠습니까?'), findsOneWidget); + expect(find.text('백업 파일은 직접 삭제해야 합니다.'), findsOneWidget); + expect(find.byType(Dialog), findsNothing); + expect(find.text('모두 삭제'), findsOneWidget); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile( + '../../goldens/goldens/my_data_reset_confirmation_390x844.png', + ), + ); + await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); + semantics.dispose(); + }); + + testWidgets('data actions meet iOS tap and label guidelines', (tester) async { + final semantics = tester.ensureSemantics(); + await pumpScreen(tester); + + await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + semantics.dispose(); + }); + + testWidgets('backup creation rejects short and mismatched passwords', ( + tester, + ) async { + await pumpScreen(tester); + await tester.tap(find.byKey(const Key('backupExportRow'))); + await tester.pumpAndSettle(); + + expect(find.text('백업 비밀번호 만들기'), findsOneWidget); + expect( + tester + .getSize( + find + .descendant( + of: find.byType(Dialog), + matching: find.byType(Material), + ) + .first, + ) + .width, + 277, + ); + await tester.enterText(find.byType(TextField).first, '123456789'); + await tester.enterText(find.byType(TextField).last, '123456789'); + await tester.tap(find.text('계속')); + await tester.pumpAndSettle(); + expect(find.text('백업 비밀번호는 15~128자로 입력해주세요.'), findsOneWidget); + expect(backupService.exportedPassword, isNull); + tester.binding.focusManager.primaryFocus?.unfocus(); + await tester.pumpAndSettle(); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile( + '../../goldens/goldens/my_data_password_invalid_390x844.png', + ), + ); + + await tester.enterText(find.byType(TextField).first, '123456789012345'); + await tester.enterText(find.byType(TextField).last, '123456789012346'); + await tester.tap(find.text('계속')); + await tester.pumpAndSettle(); + expect(find.text('비밀번호가 서로 다릅니다.'), findsOneWidget); + expect(backupService.exportedPassword, isNull); + tester.binding.focusManager.primaryFocus?.unfocus(); + await tester.pumpAndSettle(); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile( + '../../goldens/goldens/my_data_password_mismatch_390x844.png', + ), + ); + + await tester.enterText(find.byType(TextField).last, '123456789012345'); + await tester.tap(find.text('계속')); + await tester.pumpAndSettle(); + expect(backupService.exportedPassword, '123456789012345'); + expect(find.text('암호화 백업을 저장했습니다.'), findsOneWidget); + }); + + testWidgets('canceling password dialog never starts backup or restore', ( + tester, + ) async { + await pumpScreen(tester); + await tester.tap(find.byKey(const Key('backupExportRow'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('취소')); + await tester.pumpAndSettle(); + expect(backupService.exportedPassword, isNull); + + await tester.tap(find.byKey(const Key('backupRestoreRow'))); + await tester.pumpAndSettle(); + expect(find.text('백업 비밀번호 입력'), findsOneWidget); + await tester.tap(find.text('취소')); + await tester.pumpAndSettle(); + expect(backupService.restorePassword, isNull); + }); + + testWidgets('restore uses the validated password and waits for a file', ( + tester, + ) async { + await pumpScreen(tester); + await tester.tap(find.byKey(const Key('backupRestoreRow'))); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsOneWidget); + await tester.enterText(find.byType(TextField), '123456789012345'); + tester.binding.focusManager.primaryFocus?.unfocus(); + await tester.pumpAndSettle(); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile( + '../../goldens/goldens/my_data_password_input_390x844.png', + ), + ); + await tester.tap(find.text('계속')); + await tester.pumpAndSettle(); + expect(backupService.restorePassword, '123456789012345'); + expect(find.text('복원 내용 확인'), findsNothing); + }); + + testWidgets('backup password creation matches the reviewed dialog', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await pumpScreen(tester); + await tester.tap(find.byKey(const Key('backupExportRow'))); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).first, '123456789012345'); + await tester.enterText(find.byType(TextField).last, '123456789012345'); + tester.binding.focusManager.primaryFocus?.unfocus(); + await tester.pumpAndSettle(); + final card = find + .descendant(of: find.byType(Dialog), matching: find.byType(Material)) + .first; + expect(tester.getTopLeft(card).dy, 352); + await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile( + '../../goldens/goldens/my_data_password_create_390x844.png', + ), + ); + semantics.dispose(); + }); + + testWidgets('busy export disables actions and shows the reviewed spinner', ( + tester, + ) async { + backupService.pendingExport = Completer(); + await pumpScreen(tester); + await tester.tap(find.byKey(const Key('backupExportRow'))); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).first, '123456789012345'); + await tester.enterText(find.byType(TextField).last, '123456789012345'); + await tester.tap(find.text('계속')); + await tester.pump(const Duration(milliseconds: 900)); + + expect(find.byType(AppSpinner), findsOneWidget); + expect(find.byType(SnackBar), findsNothing); + expect(tester.getTopLeft(find.byType(AppSpinner)).dy, 466); + expect(tester.getSize(find.byType(AppSpinner)), const Size(40, 40)); + await tester.tap(find.byKey(const Key('backupRestoreRow'))); + await tester.pump(); + expect(find.text('백업 비밀번호 입력'), findsNothing); + await expectLater( + find.byType(Scaffold), + matchesGoldenFile('../../goldens/goldens/my_data_busy_390x844.png'), + ); + + backupService.pendingExport!.complete(false); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 900)); + expect(find.byType(AppSpinner), findsNothing); + }); + + testWidgets('export success uses the reviewed result snackbar', ( + tester, + ) async { + await pumpScreen(tester); + await tester.tap(find.byKey(const Key('backupExportRow'))); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).first, '123456789012345'); + await tester.enterText(find.byType(TextField).last, '123456789012345'); + await tester.tap(find.text('계속')); + await tester.pumpAndSettle(); + expect(find.text('암호화 백업을 저장했습니다.'), findsOneWidget); + final bar = find + .descendant(of: find.byType(SnackBar), matching: find.byType(Material)) + .first; + expect(tester.getTopLeft(bar).dy, closeTo(752, 1)); + expect(tester.getSize(bar), const Size(358, 44)); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile( + '../../goldens/goldens/my_data_export_success_390x844.png', + ), + ); + }); + + testWidgets('failed export clears busy state and presents an error', ( + tester, + ) async { + backupService.exportError = StateError('disk full'); + await pumpScreen(tester); + await tester.tap(find.byKey(const Key('backupExportRow'))); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).first, '123456789012345'); + await tester.enterText(find.byType(TextField).last, '123456789012345'); + await tester.tap(find.text('계속')); + await tester.pumpAndSettle(); + expect(find.byType(AppSpinner), findsNothing); + expect(find.textContaining('disk full'), findsOneWidget); + expect(find.byKey(const Key('backupExportRow')), findsOneWidget); + }); + + testWidgets('restore success and operation failure share result placement', ( + tester, + ) async { + await pumpScreen(tester); + final context = tester.element(find.byType(MyDataScreen)); + showMyDataResultSnackBar(context, '백업을 복원했습니다.'); + await tester.pumpAndSettle(); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile( + '../../goldens/goldens/my_data_restore_success_390x844.png', + ), + ); + ScaffoldMessenger.of(context).removeCurrentSnackBar(); + await tester.pumpAndSettle(); + + showMyDataResultSnackBar(context, '작업을 완료하지 못했습니다: {error}'); + await tester.pumpAndSettle(); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile( + '../../goldens/goldens/my_data_operation_failure_390x844.png', + ), + ); + }); + + testWidgets('password dialog remains usable with keyboard and large text', ( + tester, + ) async { + tester.view.viewInsets = FakeViewPadding(bottom: 300); + addTearDown(tester.view.resetViewInsets); + await pumpScreen(tester, textScale: 2); + await tester.tap(find.byKey(const Key('backupExportRow'))); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsNWidgets(2)); + expect(find.text('계속'), findsOneWidget); + expect(tester.getBottomLeft(find.text('계속')).dy, lessThan(544)); + expect(tester.takeException(), isNull); + }); + + testWidgets('restore preview matches Figma and cancel preserves data', ( + tester, + ) async { + await pumpScreen(tester); + final context = tester.element(find.byType(MyDataScreen)); + final result = showRestorePreviewDialog( + context, + BackupRestorePreview( + cutoff: DateTime(2026, 8, 29, 21, 41), + sourceAppVersion: '1.0.0+1', + sourcePlatform: 'iOS', + scheduleCount: 3, + templateCount: 1, + defaultPreparationStepCount: 6, + ), + ); + await tester.pumpAndSettle(); + + final card = find + .descendant(of: find.byType(Dialog), matching: find.byType(Material)) + .first; + expect(tester.getSize(card).width, 277); + expect(tester.getTopLeft(card).dy, 352); + expect(find.textContaining('일정 3개'), findsOneWidget); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile( + '../../goldens/goldens/my_data_restore_preview_390x844.png', + ), + ); + + await tester.tap(find.text('취소')); + await tester.pumpAndSettle(); + expect(await result, DialogActionResult.secondary); + }); + + testWidgets('restore preview keeps both actions at large text', ( + tester, + ) async { + await pumpScreen(tester, textScale: 2); + final result = showRestorePreviewDialog( + tester.element(find.byType(MyDataScreen)), + BackupRestorePreview( + cutoff: DateTime(2026, 8, 29, 21, 41), + sourceAppVersion: '1.0.0+1', + sourcePlatform: 'iOS', + scheduleCount: 3, + templateCount: 1, + defaultPreparationStepCount: 6, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('취소'), findsOneWidget); + expect(find.text('복원'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.tap(find.text('취소')); + await tester.pumpAndSettle(); + expect(await result, DialogActionResult.secondary); + }); +} diff --git a/test/presentation/my_page/my_page_screen_test.dart b/test/presentation/my_page/my_page_screen_test.dart index 24157103..819471f6 100644 --- a/test/presentation/my_page/my_page_screen_test.dart +++ b/test/presentation/my_page/my_page_screen_test.dart @@ -1,4 +1,3 @@ -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'; @@ -20,6 +19,7 @@ import 'package:on_time_front/domain/use-cases/cancel_all_alarms_use_case.dart'; import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/my_page/my_page_screen.dart'; +import 'package:on_time_front/presentation/shared/components/bottom_nav_bar_scaffold.dart'; import 'package:on_time_front/presentation/shared/theme/theme.dart'; void main() { @@ -87,10 +87,10 @@ void main() { expect(find.text('My Page'), findsOneWidget); expect(find.text('Back up my data'), findsOneWidget); - expect(find.text('알림에 일정 이름 표시'), findsOneWidget); + expect(find.text('Show schedule name in notifications'), findsOneWidget); expect(find.text('Sign in'), findsNothing); expect(find.textContaining('email'), findsNothing); - expect(find.text('No scheduled notifications'), findsOneWidget); + expect(find.text('Notification status'), findsOneWidget); }); testWidgets('detailed notification opt-in persists locally and reconciles', ( @@ -98,8 +98,14 @@ void main() { ) async { await _pumpMyPage(tester); - final detailSwitch = find.widgetWithText(SwitchListTile, '알림에 일정 이름 표시'); - expect(tester.widget(detailSwitch).value, isFalse); + final detailSwitch = find.byKey(const Key('detailedNotificationSwitch')); + expect(detailSwitch, findsOneWidget); + expect( + (await database.userDao.getAlarmSettings( + localProfileId, + )).detailedNotificationContent, + isFalse, + ); await tester.tap(detailSwitch); await tester.pumpAndSettle(); @@ -111,6 +117,16 @@ void main() { isTrue, ); expect(reconcile.callCount, 1); + + await tester.tap(find.text('Show schedule name in notifications')); + await tester.pumpAndSettle(); + expect( + (await database.userDao.getAlarmSettings( + localProfileId, + )).detailedNotificationContent, + isFalse, + ); + expect(reconcile.callCount, 2); }); testWidgets('disabling schedule delivery cancels every local registration', ( @@ -124,7 +140,7 @@ void main() { expect(alarmRepository.updatedSettings, [false]); expect(cancelAll.callCount, 1); expect(reconcile.callCount, 0); - expect(find.text('꺼짐'), findsOneWidget); + expect(find.text('Off'), findsOneWidget); }); testWidgets('fallback permission enables local schedule notifications', ( @@ -161,7 +177,7 @@ void main() { await _pumpMyPage(tester); - expect(find.text('Notification'), findsOneWidget); + expect(find.text('On'), findsOneWidget); }); testWidgets('authorized notification permission reports it is already on', ( @@ -237,6 +253,98 @@ void main() { await tester.pumpAndSettle(); expect(find.text('bundled privacy destination'), findsOneWidget); }); + + testWidgets('my page 390x844 current visual is tracked', (tester) async { + await getIt().setEnabled(true); + final semantics = tester.ensureSemantics(); + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + tester.view.padding = FakeViewPadding(top: 44, bottom: 21); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetPadding); + + final router = GoRouter( + initialLocation: '/myPage', + routes: [ + ShellRoute( + builder: (context, state, child) => + BottomNavBarScaffold(child: child), + routes: [ + GoRoute( + path: '/myPage', + builder: (context, state) => MyPageScreen( + notificationService: _FakeNotificationService( + currentStatus: AuthorizationStatus.authorized, + ), + referencePreparationMinutes: 10, + referenceSpareMinutes: 5, + ), + ), + ], + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router( + theme: themeData, + debugShowCheckedModeBanner: false, + locale: const Locale('ko'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + ), + ); + await tester.pumpAndSettle(); + expect( + tester.getTopLeft(find.byKey(const Key('myPageAlarmSection'))).dy, + 91, + ); + expect( + tester.getSize(find.byKey(const Key('myPageAlarmSection'))).height, + 209, + ); + expect( + tester.getTopLeft(find.byKey(const Key('myPageDataSection'))).dy, + 542, + ); + expect( + tester.getSize(find.byKey(const Key('myPageDataSection'))).height, + 163, + ); + expect( + tester.getTopLeft(find.byKey(const Key('myPageAppSection'))).dy, + 453, + ); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('../../goldens/goldens/my_page_default_390x844.png'), + ); + await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + semantics.dispose(); + + tester.view.physicalSize = const Size(375, 667); + await tester.pumpWidget( + MaterialApp.router( + theme: themeData, + locale: const Locale('ko'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2)), + child: child!, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('내 데이터 백업'), findsOneWidget); + expect(tester.takeException(), isNull); + }); } Future _pumpMyPage( diff --git a/test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart b/test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart index e20c81fd..687f3352 100644 --- a/test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart +++ b/test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart @@ -20,8 +20,11 @@ import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_pr import 'package:on_time_front/presentation/shared/components/two_action_dialog.dart'; import 'package:on_time_front/presentation/shared/theme/theme.dart'; +import '../../../helpers/visual_test_fonts.dart'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(loadVisualTestFonts); late _FakePreparationStore preparationStore; @@ -139,6 +142,79 @@ void main() { ); }); + testWidgets('six-row preparation editor matches the reviewed design', ( + tester, + ) async { + preparationStore.defaultPreparation = const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step-1', + preparationName: '샤워하기', + preparationTime: Duration(minutes: 20), + nextPreparationId: 'step-2', + ), + PreparationStepEntity( + id: 'step-2', + preparationName: '옷 갈아입기', + preparationTime: Duration(minutes: 5), + nextPreparationId: 'step-3', + ), + PreparationStepEntity( + id: 'step-3', + preparationName: '화장하기', + preparationTime: Duration(minutes: 1), + nextPreparationId: 'step-4', + ), + PreparationStepEntity( + id: 'step-4', + preparationName: '헤어 세팅하기', + preparationTime: Duration(minutes: 1), + nextPreparationId: 'step-5', + ), + PreparationStepEntity( + id: 'step-5', + preparationName: '짐 챙기기', + preparationTime: Duration(minutes: 1), + nextPreparationId: 'step-6', + ), + PreparationStepEntity( + id: 'step-6', + preparationName: '신발 신기', + preparationTime: Duration(minutes: 2), + ), + ], + ); + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + tester.view.padding = FakeViewPadding(top: 44, bottom: 21); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetPadding); + + await _pumpScreen(tester); + await tester.pumpAndSettle(); + + expect(find.text('총 시간: 30분'), findsOneWidget); + expect(find.byType(TextFormField), findsNWidgets(6)); + expect(tester.getTopLeft(find.text('여유시간 수정')), const Offset(16, 121)); + expect(tester.getTopLeft(find.text('총 시간: 30분')), const Offset(16, 309)); + expect( + tester.getRect(find.byKey(const ValueKey('step-1'))), + const Rect.fromLTWH(16, 344, 358, 62), + ); + expect(tester.getSize(find.byTooltip('준비 과정 추가')), const Size(44, 44)); + await expectLater( + find.byType(Scaffold), + matchesGoldenFile( + '../../../goldens/goldens/preparation_spare_time_edit_390x844.png', + ), + ); + + await tester.tap(find.byTooltip('준비 과정 추가')); + await tester.pumpAndSettle(); + expect(find.byType(TextFormField), findsNWidgets(7)); + }); + test( 'form bloc loads defaults and edits spare time in five-minute steps', () async { diff --git a/test/presentation/my_page/privacy_policy_screen_visual_test.dart b/test/presentation/my_page/privacy_policy_screen_visual_test.dart new file mode 100644 index 00000000..ab14648f --- /dev/null +++ b/test/presentation/my_page/privacy_policy_screen_visual_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.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/visual_test_fonts.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(loadVisualTestFonts); + + Future pumpScreen(WidgetTester tester, {double textScale = 1}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + tester.view.padding = FakeViewPadding(top: 44, bottom: 21); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetPadding); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + debugShowCheckedModeBanner: false, + home: MediaQuery( + data: MediaQueryData( + size: const Size(390, 844), + padding: const EdgeInsets.only(top: 44, bottom: 21), + textScaler: TextScaler.linear(textScale), + ), + child: const PrivacyPolicyScreen(), + ), + ), + ); + } + + testWidgets('privacy policy uses the Figma text grid', (tester) async { + await pumpScreen(tester); + final content = find.textContaining('OnTime 로컬 전용 개인정보 처리방침'); + expect(tester.getTopLeft(content), const Offset(22, 126)); + expect( + tester.getTopLeft(find.byKey(const Key('privacySection1'))), + const Offset(16, 169), + ); + expect(tester.getSize(find.byKey(const Key('privacySection1'))).width, 358); + expect(find.text('개인정보 처리방침'), findsOneWidget); + }); + + testWidgets('privacy policy first viewport matches the reviewed design', ( + tester, + ) async { + await pumpScreen(tester); + await expectLater( + find.byType(Scaffold), + matchesGoldenFile('../../goldens/goldens/privacy_policy_390x844.png'), + ); + }); + + testWidgets('privacy policy remains scrollable at large text', ( + tester, + ) async { + await pumpScreen(tester, textScale: 2); + await tester.drag(find.byType(ListView), const Offset(0, -600)); + await tester.pumpAndSettle(); + expect( + tester.state(find.byType(Scrollable)).position.pixels, + greaterThan(0), + ); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/presentation/notification_allow/notification_allow_screen_test.dart b/test/presentation/notification_allow/notification_allow_screen_test.dart index 2deb6989..49160191 100644 --- a/test/presentation/notification_allow/notification_allow_screen_test.dart +++ b/test/presentation/notification_allow/notification_allow_screen_test.dart @@ -9,13 +9,46 @@ import 'package:on_time_front/presentation/shared/theme/theme.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../helpers/visual_test_fonts.dart'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(loadVisualTestFonts); setUp(() { SharedPreferences.setMockInitialValues({}); }); + testWidgets( + 'notification rationale matches the app-owned Figma request surface', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + tester.view.padding = FakeViewPadding(top: 44, bottom: 21); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetPadding); + final harness = await _pumpNotificationAllowScreen( + tester, + locale: const Locale('ko'), + permissionGateway: _FakePermissionGateway( + currentStatus: AuthorizationStatus.notDetermined, + ), + ); + addTearDown(harness.dispose); + await expectLater( + find.byType(Scaffold), + matchesGoldenFile( + '../../goldens/goldens/notification_permission_390x844.png', + ), + ); + final semantics = tester.ensureSemantics(); + await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + semantics.dispose(); + }, + ); + testWidgets('shows English schedule preparation reminder rationale', ( tester, ) async { @@ -212,6 +245,7 @@ Future<_NotificationAllowHarness> _pumpNotificationAllowScreen( await tester.pumpWidget( MaterialApp.router( theme: themeData, + debugShowCheckedModeBanner: false, locale: locale, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, diff --git a/test/presentation/onboarding/screens/onboarding_visual_test.dart b/test/presentation/onboarding/screens/onboarding_visual_test.dart new file mode 100644 index 00000000..4541f80d --- /dev/null +++ b/test/presentation/onboarding/screens/onboarding_visual_test.dart @@ -0,0 +1,275 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/use-cases/onboard_use_case.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/onboarding/cubit/onboarding_cubit.dart'; +import 'package:on_time_front/presentation/onboarding/preparation_name_select/components/create_icon_button.dart'; +import 'package:on_time_front/presentation/onboarding/preparation_order/components/preparation_reorderable_list.dart'; +import 'package:on_time_front/presentation/onboarding/preparation_order/cubit/preparation_order_cubit.dart'; +import 'package:on_time_front/presentation/onboarding/preparation_time/components/preparation_time_tile.dart'; +import 'package:on_time_front/presentation/onboarding/screens/onboarding_screen.dart'; +import 'package:on_time_front/presentation/onboarding/screens/onboarding_start_screen.dart'; +import 'package:on_time_front/presentation/shared/components/check_button.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; + +import '../../../helpers/visual_test_fonts.dart'; + +class _OnboardStub extends Fake implements OnboardUseCase { + PreparationEntity? submitted; + @override + Future call({ + required PreparationEntity preparationEntity, + required Duration spareTime, + required String note, + }) async { + submitted = preparationEntity; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(loadVisualTestFonts); + late OnboardingCubit cubit; + late _OnboardStub useCase; + setUp(() async { + await getIt.reset(); + useCase = _OnboardStub(); + cubit = OnboardingCubit(useCase); + getIt.registerSingleton(cubit); + }); + tearDown(() async => getIt.reset()); + + Future pump( + WidgetTester tester, + Widget screen, { + Size size = const Size(390, 844), + double scale = 1, + }) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + tester.view.padding = FakeViewPadding(top: 44, bottom: 21); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetPadding); + addTearDown(tester.view.resetViewInsets); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + debugShowCheckedModeBanner: false, + locale: const Locale('ko'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: screen, + ), + ); + await tester.pumpAndSettle(); + if (screen is OnboardingStartScreen) { + await tester.runAsync( + () => precacheImage( + const AssetImage('assets/design/onboarding_greeting.png'), + tester.element(find.byType(OnboardingStartScreen)), + ), + ); + await tester.pump(); + } + } + + Future golden(WidgetTester tester, String state) => expectLater( + find.byType(Scaffold).first, + matchesGoldenFile( + '../../../goldens/goldens/onboarding_${state}_390x844.png', + ), + ); + Future next(WidgetTester tester) async { + await tester.tap(find.widgetWithText(ElevatedButton, '다음')); + await tester.pumpAndSettle(); + } + + void seed(List names, {Duration time = Duration.zero}) { + cubit.onboardingFormChanged( + preparationStepList: [ + for (var i = 0; i < names.length; i++) + OnboardingPreparationStepState( + id: 'step-$i', + preparationName: names[i], + preparationTime: time, + ), + ], + spareTime: const Duration(minutes: 10), + ); + cubit.onboardingFormValidated(isValid: true); + } + + testWidgets('welcome uses the source greeting image and 358 by 58 action', ( + tester, + ) async { + await pump(tester, const OnboardingStartScreen()); + final button = find.widgetWithText(ElevatedButton, '시작하기'); + expect(tester.getTopLeft(button), const Offset(16, 752)); + expect(tester.getSize(button), const Size(358, 58)); + await golden(tester, 'welcome'); + }); + + testWidgets( + 'preparation choices, custom entry, and selected state remain editable', + (tester) async { + await pump(tester, const OnboardingScreen()); + expect(find.byType(CheckButton), findsNWidgets(5)); + expect( + tester + .widget(find.widgetWithText(ElevatedButton, '다음')) + .onPressed, + isNull, + ); + await golden(tester, 'select'); + for (var i = 0; i < 5; i++) { + await tester.tap(find.byType(CheckButton).at(i)); + await tester.pump(); + } + await tester.ensureVisible(find.byType(CreateIconButton)); + await tester.tap(find.byType(CreateIconButton)); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField).last, '약 챙기기'); + tester.view.viewInsets = FakeViewPadding(bottom: 270); + await tester.pumpAndSettle(); + await golden(tester, 'add'); + expect(tester.takeException(), isNull); + await tester.testTextInput.receiveAction(TextInputAction.done); + tester.view.resetViewInsets(); + await tester.pumpAndSettle(); + await tester.drag( + find.byType(SingleChildScrollView).last, + const Offset(0, 700), + ); + await tester.pumpAndSettle(); + expect(find.byType(CheckButton), findsNWidgets(6)); + await golden(tester, 'selected'); + }, + ); + + testWidgets( + 'selected preparations can be reordered and persist into timing', + (tester) async { + seed(['샤워하기', '메이크업', '옷 고르기/입기', '약 챙기기']); + await pump(tester, const OnboardingScreen()); + await next(tester); + await golden(tester, 'order'); + final list = tester.widget( + find.byType(PreparationReorderableList), + ); + list.onReorder(0, 4); + await tester.pump(); + final order = tester + .element(find.byType(PreparationReorderableList)) + .read() + .state; + expect(order.preparationStepList.last.preparationName, '샤워하기'); + await next(tester); + expect(cubit.state.preparationStepList.last.preparationName, '샤워하기'); + }, + ); + + testWidgets( + 'numeric duration fields support focus, valid edits and oversized rejection', + (tester) async { + seed(['준비과정1', '준비과정2', '준비과정3', '준비과정4']); + await pump(tester, const OnboardingScreen()); + await next(tester); + await next(tester); + expect(find.text('총 시간: 0분', findRichText: true), findsOneWidget); + await golden(tester, 'time'); + final first = find.byKey(const ValueKey('onboardingMinutes0')); + final second = find.byKey(const ValueKey('onboardingMinutes1')); + await tester.tapAt( + tester.getCenter(find.byType(PreparationTimeTile).first), + ); + await tester.pump(); + final minutesField = tester.widget( + find.descendant(of: first, matching: find.byType(EditableText)), + ); + expect(minutesField.focusNode.hasFocus, isTrue); + expect( + minutesField.controller.selection, + TextSelection( + baseOffset: 0, + extentOffset: minutesField.controller.text.length, + ), + ); + await tester.enterText(first, '30'); + await tester.enterText(second, '30'); + tester.view.viewInsets = FakeViewPadding(bottom: 282); + await tester.pumpAndSettle(); + expect(find.text('총 시간: 60분', findRichText: true), findsOneWidget); + await golden(tester, 'time_focused'); + await tester.enterText(first, '10'); + await tester.pumpAndSettle(); + expect(find.text('총 시간: 40분', findRichText: true), findsOneWidget); + await golden(tester, 'time_short'); + await tester.enterText(first, '100000'); + await tester.pumpAndSettle(); + await golden(tester, 'time_long'); + expect(cubit.state.isValid, isFalse); + expect(useCase.submitted, isNull); + await tester.enterText(first, '10'); + await tester.enterText( + find.byKey(const ValueKey('onboardingMinutes2')), + '20', + ); + await tester.enterText( + find.byKey(const ValueKey('onboardingMinutes3')), + '15', + ); + FocusManager.instance.primaryFocus?.unfocus(); + tester.view.resetViewInsets(); + await tester.pumpAndSettle(); + expect(cubit.state.isValid, isTrue); + await next(tester); + await golden(tester, 'buffer'); + expect(find.text('10분'), findsOneWidget); + await next(tester); + expect( + useCase.submitted!.preparationStepList.map( + (e) => e.preparationTime.inMinutes, + ), + [10, 30, 20, 15], + ); + }, + ); + + testWidgets('welcome and setup remain usable at 320px with doubled text', ( + tester, + ) async { + await pump( + tester, + const OnboardingStartScreen(), + size: const Size(320, 667), + scale: 2, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await pump( + tester, + const OnboardingScreen(), + size: const Size(320, 667), + scale: 2, + ); + expect(tester.takeException(), isNull); + await tester.tap(find.byType(CheckButton).first); + await tester.pump(); + expect( + tester + .widget(find.widgetWithText(ElevatedButton, '다음')) + .onPressed, + isNotNull, + ); + }); +} diff --git a/test/presentation/recurring/recurring_form_integration_test.dart b/test/presentation/recurring/recurring_form_integration_test.dart index 8f7461f9..6245defc 100644 --- a/test/presentation/recurring/recurring_form_integration_test.dart +++ b/test/presentation/recurring/recurring_form_integration_test.dart @@ -236,8 +236,24 @@ void main() { ); await tester.tap(find.text('다음')); await tester.pumpAndSettle(); - if (step == 0) await captureRefresh(tester, 'date-time'); - if (step == 2) await captureRefresh(tester, 'preparation'); + if (step == 0) { + await captureRefresh(tester, 'date-time'); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile( + '../../goldens/goldens/recurring_date_time_390x844.png', + ), + ); + } + if (step == 2) { + await captureRefresh(tester, 'preparation'); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile( + '../../goldens/goldens/recurring_preparation_390x844.png', + ), + ); + } } expect(find.text('검토하기'), findsOneWidget); final reviewed = bloc.stream.firstWhere( diff --git a/test/presentation/recurring/recurring_parity_visual_test.dart b/test/presentation/recurring/recurring_parity_visual_test.dart new file mode 100644 index 00000000..617620f7 --- /dev/null +++ b/test/presentation/recurring/recurring_parity_visual_test.dart @@ -0,0 +1,451 @@ +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_engine.dart'; +import 'package:on_time_front/domain/recurrence/recurrence_rule.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/shared/theme/theme.dart'; +import '../../helpers/refresh_capture.dart'; + +void main() { + setUpAll(loadRefreshFonts); + final start = DateTime.utc(2026, 9, 23, 9); + final now = DateTime.utc(2026, 9, 22); + RecurrenceRule weekly({int? count, DateTime? until}) => RecurrenceRule( + frequency: RecurrenceFrequency.weekly, + start: start, + timeZoneId: 'UTC', + weekdays: {1, 3, 5}, + count: count, + until: until, + ); + ScheduleFormState form(RecurrenceRule rule) => ScheduleFormState( + scheduleName: '출근 준비', + scheduleTime: start, + recurrenceRule: rule, + preparation: _preparation, + moveTime: const Duration(minutes: 20), + scheduleSpareTime: const Duration(minutes: 10), + ); + + testWidgets('weekly settings visual reference preserves weekday controls', ( + tester, + ) async { + await _pump( + tester, + RecurrenceSettingsSheet( + start: start, + timeZoneId: 'UTC', + initial: weekly(), + now: () => now, + ), + ); + await _golden(tester, 'weekly'); + expect( + tester.getSize(find.widgetWithText(TextButton, '수')), + const Size(44, 44), + ); + }); + + testWidgets( + 'monthly overview and ordinal fields keep independent selections', + (tester) async { + await _pump( + tester, + RecurrenceSettingsSheet( + start: start, + timeZoneId: 'UTC', + initial: weekly(), + now: () => now, + ), + ); + await tester.tap(find.widgetWithText(TextButton, '월').first); + await tester.pumpAndSettle(); + await _golden(tester, 'monthly'); + await tester.tap(find.text('월 규칙')); + await tester.pumpAndSettle(); + await tester.tap(find.text('특정 순번의 요일에 반복')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(DropdownButtonFormField).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('2번째').last); + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pumpAndSettle(); + await _golden(tester, 'monthly_fields'); + expect(find.byType(DropdownButtonFormField), findsNWidgets(2)); + }, + ); + + testWidgets('ending count and inclusive end date visual references', ( + tester, + ) async { + await _pump( + tester, + RecurrenceSettingsSheet( + start: start, + timeZoneId: 'UTC', + initial: weekly(count: 10), + now: () => now, + ), + ); + await tester.tap(find.text('총 10회')); + await tester.pumpAndSettle(); + await _golden(tester, 'ending'); + await _pump( + tester, + RecurrenceSettingsSheet( + start: start, + timeZoneId: 'UTC', + initial: weekly(until: DateTime.utc(2026, 12, 31)), + now: () => now, + ), + ); + await tester.tap(find.text('2026년 12월 31일')); + await tester.pumpAndSettle(); + await _golden(tester, 'end_date'); + expect(find.text('마지막 날짜'), findsOneWidget); + }); + + testWidgets('frequency visual reference shows selected daily interval', ( + tester, + ) async { + await _pump( + tester, + RecurrenceSettingsSheet(start: start, timeZoneId: 'UTC', now: () => now), + ); + await tester.tap(find.text('매일')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField), '2'); + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pumpAndSettle(); + await _golden(tester, 'frequency'); + }); + + testWidgets( + 'review includes first actual date and excluded past preparation', + (tester) async { + final rule = weekly(); + final expanded = const RecurrenceEngine().expand( + rule, + through: DateTime.utc(2026, 10), + preparationNotBeforeUtc: DateTime.utc(2026, 9, 23, 8, 30), + leadTime: const Duration(minutes: 55), + limit: 3, + ); + final review = RecurrenceReview( + slots: expanded.slots, + skipped: expanded.skipped, + occurrences: { + for (final slot in expanded.slots) + slot.key: RecurrencePreviewOccurrence( + _schedule(slot.civilTime), + slot.instantUtc.subtract(const Duration(minutes: 55)), + ), + }, + ); + await _pump( + tester, + RecurrenceReviewSheet(review: review, form: form(rule)), + ); + expect(find.textContaining('9월 25일'), findsWidgets); + expect(find.textContaining('준비 시작 시각이 지남'), findsOneWidget); + await _golden(tester, 'review'); + }, + ); + + testWidgets( + 'conflict golden follows explicit exclusions and prevents empty save', + (tester) async { + final rule = weekly(count: 10); + final slots = const RecurrenceEngine() + .expand(rule, through: DateTime.utc(2027)) + .slots; + final review = RecurrenceReview( + slots: slots, + skipped: [], + conflicts: [ + RecurrenceConflict( + slot: slots[1], + other: _schedule(slots[1].civilTime, name: '병원 예약'), + ), + RecurrenceConflict( + slot: slots[2], + other: _schedule(slots[2].civilTime, name: '아침 모임'), + ), + ], + ); + await _pump( + tester, + RecurrenceReviewSheet(review: review, form: form(rule)), + ); + for (final index in [1, 2]) { + await tester.tap(find.byType(CheckboxListTile).at(index)); + await tester.pumpAndSettle(); + } + expect( + tester + .widget(find.widgetWithText(TextButton, '저장하기 (8개)')) + .onPressed, + isNotNull, + ); + await _golden(tester, 'conflicts'); + }, + ); + + testWidgets('persistent conflict cannot save and shows runtime time ranges', ( + tester, + ) async { + final rule = RecurrenceRule( + frequency: RecurrenceFrequency.weekly, + start: start, + timeZoneId: 'UTC', + weekdays: {3}, + ); + final slots = const RecurrenceEngine() + .expand(rule, through: DateTime.utc(2026, 10)) + .slots; + final other = _schedule( + DateTime.utc(2026, 9, 23, 9, 30), + name: '아침 운동', + ).copyWith(moveTime: const Duration(minutes: 90)); + await _pump( + tester, + RecurrenceReviewSheet( + review: RecurrenceReview( + slots: slots, + skipped: [], + persistentConflict: true, + conflicts: [RecurrenceConflict(slot: slots.first, other: other)], + ), + form: form(rule), + ), + ); + await _golden(tester, 'persistent_conflict'); + expect(find.text('저장하기'), findsNothing); + }); + + testWidgets('detached schedule preserves actual override time', ( + tester, + ) async { + final rule = weekly(); + await _pump( + tester, + RecurrenceReviewSheet( + review: RecurrenceReview( + slots: [], + skipped: [], + detached: [_schedule(DateTime.utc(2026, 9, 30, 10))], + ), + form: form(rule), + ), + ); + await _golden(tester, 'detached'); + expect(find.text('오전 10:00'), findsOneWidget); + }); + + testWidgets('scope choice and selected following scope are interactive', ( + tester, + ) async { + RecurringEditScope? result; + await _pump( + tester, + Builder( + builder: (context) => Center( + child: TextButton( + onPressed: () async { + result = await showRecurrenceScope(context); + }, + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + await _golden(tester, 'scope'); + await tester.tap(find.text('이번 및 이후 일정')); + await tester.tap(find.text('선택한 범위로 수정')); + await tester.pumpAndSettle(); + expect(result, RecurringEditScope.following); + }); + + testWidgets('repeated civil time exposes real UTC offsets', (tester) async { + await _pump( + tester, + RecurrenceTimeChoiceSheet( + date: DateTime(2026, 11, 1, 1, 30), + timeZoneId: 'America/New_York', + ), + ); + expect(find.textContaining('UTC-04:00'), findsOneWidget); + expect(find.textContaining('UTC-05:00'), findsOneWidget); + await _golden(tester, 'time_exceptions'); + await tester.tap(find.text('두 번째 1:30')); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'storage failure shows actual error without a fabricated empty result', + (tester) async { + await _pump( + tester, + const RecurrenceSaveErrorSheet(message: '기기에 저장할 수 없어요'), + ); + await _golden(tester, 'save_error'); + expect(find.text('조건에 맞는 일정이 없어요'), findsNothing); + }, + ); + + testWidgets('occurrence keeps edit delete and no-history information', ( + tester, + ) async { + await _pump( + tester, + RecurrenceOccurrenceSheet( + schedule: _schedule( + DateTime.utc(2026, 9, 23, 10), + ).copyWith(recurringOverrides: 'time'), + now: () => DateTime.utc(2026, 9, 25), + onEdit: () {}, + onDelete: () {}, + ), + ); + await _golden(tester, 'occurrence'); + expect(find.text('진행 기록이 없습니다.'), findsOneWidget); + expect(find.text('이 회차 삭제하기'), findsOneWidget); + await _pump( + tester, + RecurrenceOccurrenceSheet( + schedule: _schedule(DateTime.utc(2026, 9, 23, 10, 45)), + ), + ); + expect(find.text('9/23 수요일 10시 45분'), findsOneWidget); + }); + + testWidgets('detail and end confirmation preserve next occurrence boundary', ( + tester, + ) async { + final summary = RecurringScheduleSummary( + RecurringSegment( + id: 'segment', + seriesId: 'series', + rule: weekly(), + schedule: _schedule(start), + preparation: _preparation, + preparationId: 'own', + fromSlot: start, + createdAt: now, + ), + _schedule(DateTime.utc(2026, 9, 25, 9)), + ); + final useCase = _Management([summary]); + await _pump(tester, RecurringManagementScreen(useCase: useCase)); + await tester.tap(find.text('출근 준비')); + await tester.pumpAndSettle(); + await _golden(tester, 'detail'); + await tester.scrollUntilVisible( + find.text('반복 종료').hitTestable(), + 100, + scrollable: find.byType(Scrollable).last, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('반복 종료')); + await tester.pumpAndSettle(); + await _golden(tester, 'end_confirm'); + await tester.tap(find.text('취소')); + await tester.pumpAndSettle(); + expect(useCase.deletes, 0); + }); +} + +Future _golden(WidgetTester tester, String state) async { + expect(tester.takeException(), isNull, reason: state); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('../../goldens/goldens/recurring_${state}_390x844.png'), + ); +} + +Future _pump(WidgetTester tester, Widget child) async { + tester.view.physicalSize = const Size(390, 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)), + child: child!, + ), + home: Scaffold(body: child), + ), + ); + await tester.pumpAndSettle(); +} + +const _preparation = PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'wash', + preparationName: '씻기', + preparationTime: Duration(minutes: 15), + nextPreparationId: 'dress', + ), + PreparationStepEntity( + id: 'dress', + preparationName: '옷입기', + preparationTime: Duration(minutes: 10), + ), + ], +); +ScheduleEntity _schedule(DateTime time, {String name = '출근 준비'}) => + ScheduleEntity( + id: name, + place: const PlaceEntity(id: 'company', 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; + int deletes = 0; + @override + Future> list() async => summaries; + @override + Future delete(ScheduleEntity schedule, RecurringEditScope scope) async { + deletes++; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/presentation/recurring/recurring_screens_test.dart b/test/presentation/recurring/recurring_screens_test.dart index 15540a6a..5bb3ff6b 100644 --- a/test/presentation/recurring/recurring_screens_test.dart +++ b/test/presentation/recurring/recurring_screens_test.dart @@ -180,6 +180,13 @@ void main() { RecurringManagementScreen(useCase: useCase), boundary: boundary, ); + final card = tester.getRect( + find.byKey(const Key('recurring_card_segment')), + ); + expect(card.left, 16); + expect(card.top, inInclusiveRange(135, 139)); + expect(card.width, 358); + expect(card.height, greaterThanOrEqualTo(99)); await _capture(tester, boundary, 'management'); await tester.pumpAndSettle(); await tester.tap(find.text('출근 준비')); @@ -196,11 +203,92 @@ void main() { await tester.tap(find.text('반복 종료').last); await tester.pumpAndSettle(); expect(useCase.deleted, RecurringEditScope.following); - expect(find.text('예정된 회차 없음'), findsOneWidget); + expect(find.textContaining('종료됨'), findsOneWidget); expect(tester.takeException(), isNull); }, ); + testWidgets('management groups two active and one ended Figma fixture', ( + tester, + ) async { + RecurringScheduleSummary fixture( + String id, + RecurrenceRule rule, + DateTime? next, { + PreparationEntity preparation = _prep, + }) => RecurringScheduleSummary( + RecurringSegment( + id: id, + seriesId: id, + rule: rule, + schedule: _schedule(id, rule.start), + preparation: preparation, + preparationId: 'prep-$id', + fromSlot: rule.start, + createdAt: DateTime.utc(2024, 1), + ), + next == null ? null : _schedule(id, next), + ); + final summaries = [ + fixture( + '출근 준비', + RecurrenceRule( + frequency: RecurrenceFrequency.weekly, + start: DateTime.utc(2024, 9, 23, 9), + timeZoneId: 'UTC', + weekdays: {1, 3, 5}, + ), + DateTime.utc(2024, 9, 25, 9), + ), + fixture( + '독서 모임', + RecurrenceRule( + frequency: RecurrenceFrequency.monthly, + start: DateTime.utc(2024, 1, 27, 14), + timeZoneId: 'UTC', + monthly: MonthlyRecurrence.nthWeekday, + ordinal: -1, + monthWeekday: 6, + ), + DateTime.utc(2024, 9, 26, 14), + preparation: const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'book', + preparationName: '책 챙기기', + preparationTime: Duration(minutes: 15), + ), + ], + ), + ), + fixture( + '아침 운동', + RecurrenceRule( + frequency: RecurrenceFrequency.daily, + start: DateTime.utc(2024, 9, 1, 7), + timeZoneId: 'UTC', + until: DateTime.utc(2024, 9, 18), + ), + null, + ), + ]; + await _pump( + tester, + RecurringManagementScreen(useCase: _Management(summaries)), + ); + expect(find.text('출근 준비'), findsOneWidget); + expect(find.text('독서 모임'), findsOneWidget); + expect(find.text('아침 운동'), findsOneWidget); + expect(find.text('2개'), findsOneWidget); + expect(find.text('1개'), findsOneWidget); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile( + '../../goldens/goldens/recurring_management_390x844.png', + ), + ); + }); + testWidgets('settings and review render at phone width and large text', ( tester, ) async { diff --git a/test/presentation/recurring/refreshed_screens_test.dart b/test/presentation/recurring/refreshed_screens_test.dart index 89a74dfc..4b08e791 100644 --- a/test/presentation/recurring/refreshed_screens_test.dart +++ b/test/presentation/recurring/refreshed_screens_test.dart @@ -99,6 +99,32 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('recurring empty state keeps the Figma cards and add action', ( + tester, + ) async { + await _pump(tester, RecurringManagementScreen(useCase: _Management([]))); + + final info = tester.getRect(find.byKey(const Key('recurring_empty_info'))); + final card = tester.getRect(find.byKey(const Key('recurring_empty_card'))); + final add = tester.getRect(find.byKey(const Key('recurring_empty_add'))); + expect(info.left, 20); + expect(info.top, 98); + expect(info.width, 350); + expect(info.height, greaterThanOrEqualTo(115)); + expect(card.left, 20); + expect(card.top, inInclusiveRange(269, 273)); + expect(card.width, 350); + expect(card.height, greaterThanOrEqualTo(122)); + expect(add.left, 20); + expect(add.top, 749); + expect(add.width, 350); + expect(add.height, 47); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('../../goldens/goldens/recurring_empty_390x844.png'), + ); + }); + testWidgets('secondary recurrence states fit a small phone with large text', ( tester, ) async { @@ -267,8 +293,8 @@ void main() { await captureRefresh(tester, 'detail'); expect(find.text('씻기'), findsOneWidget); await tester.scrollUntilVisible( - find.text('반복 종료'), - 200, + find.text('반복 종료').hitTestable(), + 100, scrollable: find.byType(Scrollable).last, ); await tester.tap(find.text('반복 종료')); 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 17234f14..dbc6b439 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 @@ -545,12 +545,12 @@ void main() { for (var i = 0; i < 3; i++) { await tester.tap(find.byType(TextField).first); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(ElevatedButton, 'Cancel')); + await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); await tester.tap(find.byType(TextField).at(1)); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(ElevatedButton, 'Cancel')); + await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); } diff --git a/test/presentation/schedule_create/original_form_visual_test.dart b/test/presentation/schedule_create/original_form_visual_test.dart new file mode 100644 index 00000000..5b8c88cf --- /dev/null +++ b/test/presentation/schedule_create/original_form_visual_test.dart @@ -0,0 +1,330 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/domain/entities/adjacent_schedules_with_preparation_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/use-cases/get_adjacent_schedules_with_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_adjacent_schedule_with_preparation_use_case.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; +import 'package:on_time_front/presentation/schedule_create/components/keyboard_backed_bottom_sheet.dart'; +import 'package:on_time_front/presentation/schedule_create/components/schedule_multi_page_form.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/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_edit_draft_cubit.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/screens/preparation_edit_form.dart'; +import 'package:on_time_front/presentation/shared/components/cupertino_picker_modal.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; +import '../../helpers/visual_test_fonts.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(loadVisualTestFonts); + setUp(() => getIt.reset()); + tearDown(() => getIt.reset()); + + Future prepareViewport(WidgetTester tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + tester.view.padding = FakeViewPadding(top: 44, bottom: 21); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetPadding); + } + + for (final state in ['name', 'place']) { + testWidgets('original $state input uses current production form shell', ( + tester, + ) async { + await prepareViewport(tester); + getIt.registerFactoryParam( + (bloc, _) => + ScheduleDateTimeCubit(bloc, _LoadAdjacent(), _GetAdjacent()), + ); + final bloc = _FormBloc( + ScheduleFormState( + id: 'form', + status: ScheduleFormStatus.success, + isValid: true, + scheduleName: state == 'name' ? '' : '디자인 리뷰', + scheduleTime: DateTime(2027, 12, 21, 6), + moveTime: Duration.zero, + ), + ); + await tester.pumpWidget( + _App( + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showModalBottomSheet( + context: context, + isDismissible: false, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => BlocProvider.value( + value: bloc, + child: const KeyboardBackedBottomSheet( + child: ScheduleMultiPageForm(), + ), + ), + ), + child: const Text('열기'), + ), + ), + ), + ), + ); + await tester.tap(find.text('열기')); + await tester.pumpAndSettle(); + if (state == 'place') { + await tester.tap(find.text('다음')); + await tester.pumpAndSettle(); + await tester.tap(find.text('다음')); + await tester.pumpAndSettle(); + } + expect(tester.takeException(), isNull); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('../../goldens/goldens/form_${state}_390x844.png'), + ); + }); + } + + for (final mode in [ + CupertinoDatePickerMode.date, + CupertinoDatePickerMode.time, + ]) { + testWidgets( + 'native ${mode.name} picker matches source sheet and saves selected value', + (tester) async { + await prepareViewport(tester); + final initial = DateTime(2024, 12, 21, 6); + DateTime? saved; + await tester.pumpWidget( + _App( + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => context.showCupertinoDatePickerModal( + title: mode == CupertinoDatePickerMode.date + ? '날짜를 입력해주세요' + : '시간을 입력해주세요', + initialValue: initial, + mode: mode, + onSaved: (value) => saved = value, + ), + child: const Text('열기'), + ), + ), + ), + ), + ); + await tester.tap(find.text('열기')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile( + '../../goldens/goldens/form_${mode.name}_picker_390x844.png', + ), + ); + await tester.tap(find.text('입력')); + await tester.pumpAndSettle(); + expect(saved, initial); + }, + ); + } + + testWidgets('picker cancel remains reachable at 2x text and does not save', ( + tester, + ) async { + await prepareViewport(tester); + var saved = false; + await tester.pumpWidget( + _App( + scale: 2, + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => context.showCupertinoDatePickerModal( + title: '날짜를 입력해주세요', + initialValue: DateTime(2024, 12, 21), + mode: CupertinoDatePickerMode.date, + onSaved: (_) => saved = true, + ), + child: const Text('열기'), + ), + ), + ), + ), + ); + await tester.tap(find.text('열기')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('취소')); + await tester.tap(find.text('취소')); + await tester.pumpAndSettle(); + expect(saved, isFalse); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'preparation edit shows computed total and saves the edited draft', + (tester) async { + await prepareViewport(tester); + final draft = PreparationEditDraftCubit() + ..setDraft( + const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: '1', + preparationName: '샤워하기', + preparationTime: Duration(minutes: 10), + nextPreparationId: '2', + ), + PreparationStepEntity( + id: '2', + preparationName: '옷 고르기', + preparationTime: Duration(minutes: 10), + nextPreparationId: '3', + ), + PreparationStepEntity( + id: '3', + preparationName: '가방 챙기기', + preparationTime: Duration(minutes: 5), + nextPreparationId: '4', + ), + PreparationStepEntity( + id: '4', + preparationName: '마무리 확인', + preparationTime: Duration(minutes: 5), + ), + ], + ), + ); + getIt.registerSingleton(draft); + getIt.registerFactory(PreparationFormBloc.new); + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, _) => Scaffold( + body: TextButton( + onPressed: () => context.push('/preparationEdit'), + child: const Text('열기'), + ), + ), + ), + GoRoute( + path: '/preparationEdit', + builder: (_, _) => const PreparationEditForm(), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router( + theme: themeData, + locale: const Locale('ko'), + debugShowCheckedModeBanner: false, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + ), + ); + await tester.tap(find.text('열기')); + await tester.pumpAndSettle(); + expect(find.text('총 시간: 30분'), findsOneWidget); + expect(find.text('완료'), findsOneWidget); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('../../goldens/goldens/preparation_edit_390x844.png'), + ); + tester.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('완료').hitTestable(), findsOneWidget); + await tester.enterText(find.byType(TextFormField).first, '세수하기'); + await tester.pumpAndSettle(); + await tester.tap(find.text('완료')); + await tester.pumpAndSettle(); + expect(draft.state!.preparationStepList.first.preparationName, '세수하기'); + expect(tester.takeException(), isNull); + await draft.close(); + }, + ); +} + +class _App extends StatelessWidget { + const _App({required this.child, this.scale = 1}); + final Widget child; + final double scale; + @override + Widget build(BuildContext context) => MaterialApp( + // Cupertino's private SF font is unavailable in Flutter's test engine. + // Use the bundled app font for the native picker glyphs in these captures. + theme: themeData.copyWith( + cupertinoOverrideTheme: const CupertinoThemeData( + textTheme: CupertinoTextThemeData( + pickerTextStyle: TextStyle( + fontFamily: 'Pretendard', + fontSize: 21, + color: Colors.black, + ), + dateTimePickerTextStyle: TextStyle( + fontFamily: 'Pretendard', + fontSize: 21, + color: Colors.black, + ), + ), + ), + ), + locale: const Locale('ko'), + debugShowCheckedModeBanner: false, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: child, + ); +} + +class _FormBloc implements ScheduleFormBloc { + _FormBloc(this.state); + @override + final ScheduleFormState state; + @override + Stream get stream => const Stream.empty(); + @override + bool get isClosed => false; + @override + void add(ScheduleFormEvent event) {} + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _LoadAdjacent implements LoadAdjacentScheduleWithPreparationUseCase { + @override + Future call({ + required DateTime startDate, + required DateTime endDate, + }) async {} +} + +class _GetAdjacent implements GetAdjacentSchedulesWithPreparationUseCase { + @override + Future call({ + required DateTime selectedDateTime, + String? currentScheduleId, + required DateTime startDate, + required DateTime endDate, + }) async => const AdjacentSchedulesWithPreparationEntity(); +} diff --git a/test/presentation/schedule_create/preparation_form/preparation_form_list_field_test.dart b/test/presentation/schedule_create/preparation_form/preparation_form_list_field_test.dart index ac3fd619..b8c89018 100644 --- a/test/presentation/schedule_create/preparation_form/preparation_form_list_field_test.dart +++ b/test/presentation/schedule_create/preparation_form/preparation_form_list_field_test.dart @@ -65,7 +65,7 @@ void main() { ); await tester.enterText(find.byType(TextFormField), 'Coffee'); - await tester.tap(find.text('00')); + await tester.tap(find.text('00 min')); await tester.pumpAndSettle(); focusNode.unfocus(); await tester.pump(); @@ -170,7 +170,7 @@ void main() { await tester.enterText(find.byType(TextFormField), 'Shower and pack'); expect(removeCount, 0); - expect(find.text('10'), findsOneWidget); + expect(find.text('10 min'), findsOneWidget); }); } diff --git a/test/presentation/schedule_create/preparation_form/preparation_form_reorderable_list_test.dart b/test/presentation/schedule_create/preparation_form/preparation_form_reorderable_list_test.dart index 0f4f02c2..fb7dbd53 100644 --- a/test/presentation/schedule_create/preparation_form/preparation_form_reorderable_list_test.dart +++ b/test/presentation/schedule_create/preparation_form/preparation_form_reorderable_list_test.dart @@ -60,7 +60,7 @@ void main() { await tester.pumpAndSettle(); await tester.enterText(find.byType(TextFormField).first, 'Morning shower'); - await tester.tap(find.text('10')); + await tester.tap(find.text('10 min')); await tester.pumpAndSettle(); await tester.tap(find.text('OK')); await tester.pumpAndSettle(); diff --git a/test/presentation/schedule_create/schedule_date_time/schedule_date_time_form_test.dart b/test/presentation/schedule_create/schedule_date_time/schedule_date_time_form_test.dart index f69f1b37..bc0e96ae 100644 --- a/test/presentation/schedule_create/schedule_date_time/schedule_date_time_form_test.dart +++ b/test/presentation/schedule_create/schedule_date_time/schedule_date_time_form_test.dart @@ -55,7 +55,7 @@ void main() { await _pumpForm(tester, cubit: cubit, locale: const Locale('ko')); - expect(find.text('2026년 05월 15일'), findsWidgets); + expect(find.text('2026년 5월 15일 (금)'), findsWidgets); }); testWidgets('shows explicit choices for a repeated DST time', (tester) async { diff --git a/test/presentation/shared/components/cupertino_picker_modal_test.dart b/test/presentation/shared/components/cupertino_picker_modal_test.dart index 55c9d973..4d02cf14 100644 --- a/test/presentation/shared/components/cupertino_picker_modal_test.dart +++ b/test/presentation/shared/components/cupertino_picker_modal_test.dart @@ -32,11 +32,13 @@ void main() { expect(find.text('Preparation minutes'), findsOneWidget); expect(find.text('07'), findsOneWidget); + await tester.drag(find.byType(CupertinoPicker), const Offset(0, -32)); + await tester.pumpAndSettle(); await tester.tap(find.text('OK')); await tester.pumpAndSettle(); - expect(saved, const Duration(minutes: 7)); + expect(saved, const Duration(minutes: 8)); expect(disposedCount, 1); expect(find.text('Preparation minutes'), findsNothing); }); diff --git a/test/presentation/shared/theme/button_and_calendar_theme_test.dart b/test/presentation/shared/theme/button_and_calendar_theme_test.dart index 0bca9929..2daa27c6 100644 --- a/test/presentation/shared/theme/button_and_calendar_theme_test.dart +++ b/test/presentation/shared/theme/button_and_calendar_theme_test.dart @@ -66,7 +66,7 @@ void main() { expect(calendarTheme.calendarStyle.outsideDaysVisible, isFalse); expect( calendarTheme.calendarStyle.markerDecoration, - BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle), + const BoxDecoration(color: Color(0xff4f69df), shape: BoxShape.circle), ); expect( calendarTheme.daysOfWeekStyle.dowTextFormatter!.call( diff --git a/test/presentation/startup/local_data_recovery_visual_test.dart b/test/presentation/startup/local_data_recovery_visual_test.dart new file mode 100644 index 00000000..f146c572 --- /dev/null +++ b/test/presentation/startup/local_data_recovery_visual_test.dart @@ -0,0 +1,144 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.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/shared/theme/theme.dart'; +import 'package:on_time_front/presentation/shared/components/app_spinner.dart'; +import 'package:on_time_front/presentation/startup/screens/local_data_recovery_screen.dart'; + +import '../../helpers/visual_test_fonts.dart'; + +class _AuthBlocStub extends Fake implements AuthBloc { + int retryCount = 0; + + @override + AuthState get state => const AuthState.recovery(); + + @override + Stream get stream => const Stream.empty(); + + @override + void add(AuthEvent event) => retryCount++; +} + +class _ResetServiceStub extends Fake implements LocalDataResetService { + Completer? pending; + + @override + Future reset() => pending?.future ?? Future.value(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(loadVisualTestFonts); + + late _AuthBlocStub authBloc; + late _ResetServiceStub resetService; + setUp(() async { + await getIt.reset(); + authBloc = _AuthBlocStub(); + resetService = _ResetServiceStub(); + getIt.registerSingleton(resetService); + }); + tearDown(() async => getIt.reset()); + + Future pumpScreen(WidgetTester tester, Size size) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + tester.view.padding = FakeViewPadding(top: 44, bottom: 21); + addTearDown(tester.view.resetPadding); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + debugShowCheckedModeBanner: false, + home: BlocProvider.value( + value: authBloc, + child: const LocalDataRecoveryScreen(), + ), + ), + ); + } + + testWidgets('recovery default visual remains reviewed', (tester) async { + await pumpScreen(tester, const Size(390, 844)); + expect( + tester.getTopLeft(find.byKey(const Key('recoveryPreservedNotice'))), + const Offset(19, 360), + ); + await expectLater( + find.byType(Scaffold), + matchesGoldenFile('../../goldens/goldens/recovery_default_390x844.png'), + ); + }); + + testWidgets('recovery retry stays available without deleting data', ( + tester, + ) async { + await pumpScreen(tester, const Size(375, 667)); + await tester.tap(find.text('다시 시도하기')); + expect(authBloc.retryCount, 1); + expect(find.text('모든 로컬 데이터 초기화'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('recovery reset opens the refreshed confirmation screen', ( + tester, + ) async { + await pumpScreen(tester, const Size(390, 844)); + await tester.tap(find.text('모든 로컬 데이터 초기화')); + await tester.pumpAndSettle(); + + expect(find.text('복구하지 않고 삭제하시겠습니까?'), findsOneWidget); + expect(find.byType(Dialog), findsNothing); + expect( + tester.getTopLeft(find.byKey(const Key('resetConsequences'))), + const Offset(19, 254), + ); + expect( + tester.getTopLeft(find.byKey(const Key('resetExternalBackupNotice'))), + const Offset(19, 386), + ); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile( + '../../goldens/goldens/recovery_reset_confirmation_390x844.png', + ), + ); + await tester.tap(find.text('취소')); + await tester.pumpAndSettle(); + expect(find.text('복구하지 않고 삭제하시겠습니까?'), findsNothing); + }); + + testWidgets('recovery busy state keeps actions disabled and spinner placed', ( + tester, + ) async { + resetService.pending = Completer(); + await pumpScreen(tester, const Size(390, 844)); + await tester.tap(find.text('모든 로컬 데이터 초기화')); + await tester.pumpAndSettle(); + await tester.tap(find.text('모두 삭제')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 900)); + + expect(find.text('복구하지 않고 삭제하시겠습니까?'), findsNothing); + expect(find.byType(AppSpinner), findsOneWidget); + expect(find.text('로컬 데이터 초기화 중'), findsOneWidget); + expect(find.text('다시 시도'), findsOneWidget); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('../../goldens/goldens/recovery_busy_390x844.png'), + ); + + resetService.pending!.completeError(StateError('reset failed')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 900)); + expect(find.byType(AppSpinner), findsNothing); + expect(find.textContaining('초기화하지 못했습니다'), findsOneWidget); + }); +} diff --git a/test/presentation/startup/local_data_reset_complete_visual_test.dart b/test/presentation/startup/local_data_reset_complete_visual_test.dart new file mode 100644 index 00000000..0fd4b6ff --- /dev/null +++ b/test/presentation/startup/local_data_reset_complete_visual_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/presentation/my_page/my_data_screen.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; + +import '../../helpers/visual_test_fonts.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(loadVisualTestFonts); + + testWidgets('reset complete default visual remains reviewed', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + tester.view.padding = FakeViewPadding(top: 44, bottom: 21); + addTearDown(tester.view.resetPadding); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget( + MaterialApp(theme: themeData, home: const LocalDataResetCompleteScreen()), + ); + + expect(find.text('다시 시작 안내'), findsOneWidget); + await expectLater( + find.byType(Scaffold), + matchesGoldenFile('../../goldens/goldens/reset_complete_390x844.png'), + ); + }); + + testWidgets('reset complete stays readable at large text scale', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(375, 667); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: const MediaQuery( + data: MediaQueryData( + size: Size(375, 667), + textScaler: TextScaler.linear(2), + ), + child: LocalDataResetCompleteScreen(), + ), + ), + ); + + expect(find.text('로컬 데이터 초기화가\n완료되었습니다'), findsOneWidget); + expect(find.textContaining('새 로컬 프로필로'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets( + 'reset completion provides restart guidance without reopening closed storage', + (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: const LocalDataResetCompleteScreen(), + ), + ); + await tester.tap(find.text('다시 시작 안내')); + await tester.pumpAndSettle(); + expect(find.text('OnTime을 다시 열어 주세요'), findsOneWidget); + expect(find.text('앱을 완전히 종료한 뒤 다시 열면 새 로컬 프로필로 시작합니다.'), findsOneWidget); + await tester.tap(find.text('확인')); + await tester.pumpAndSettle(); + expect(find.text('초기화 완료'), findsOneWidget); + }, + ); +} diff --git a/tool/report_figma_ui_coverage.dart b/tool/report_figma_ui_coverage.dart new file mode 100644 index 00000000..03febed6 --- /dev/null +++ b/tool/report_figma_ui_coverage.dart @@ -0,0 +1,170 @@ +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:yaml/yaml.dart'; + +const manifestPath = 'docs/design/figma_code_map.yaml'; +const routerPath = 'lib/presentation/shared/router/go_router.dart'; +const statuses = {'pending', 'in_progress', 'reviewed', 'excluded'}; + +void main(List args) { + final strict = args.contains('--strict'); + final manifest = loadYaml(File(manifestPath).readAsStringSync()) as YamlMap; + final entries = manifest['screens'] as YamlList; + final failures = []; + final routes = {}; + final counts = {}; + final variantCounts = {}; + + for (final value in entries) { + final entry = value as YamlMap; + final route = entry['route'] as String?; + final status = entry['status'] as String?; + if (route == null || !routes.add(route)) { + failures.add('Missing or duplicate route: $route'); + continue; + } + if (!statuses.contains(status)) { + failures.add('$route has invalid status: $status'); + continue; + } + counts[status!] = (counts[status] ?? 0) + 1; + + final nodeId = entry['figma_node'] as String?; + if (nodeId != null && !RegExp(r'^\d+:\d+$').hasMatch(nodeId)) { + failures.add('$route has malformed Figma node: $nodeId'); + } + if (status == 'reviewed' && nodeId == null) { + failures.add('$route is reviewed without a Figma node'); + } + if (status == 'excluded' && entry['reason'] == null) { + failures.add('$route is excluded without a reason'); + } + + final imagePath = entry['figma_image'] as String?; + final expectedHash = entry['figma_sha256'] as String?; + final goldenPath = entry['flutter_golden'] as String?; + if ((imagePath == null) != (expectedHash == null)) { + failures.add('$route must pair Figma image with its SHA-256'); + } + if (imagePath != null) { + final image = File(imagePath); + if (!image.existsSync()) { + failures.add('$route is missing Figma image: $imagePath'); + } else if (sha256.convert(image.readAsBytesSync()).toString() != + expectedHash) { + failures.add('$route Figma image hash changed: $imagePath'); + } + } + if (goldenPath != null && !File(goldenPath).existsSync()) { + failures.add('$route is missing Flutter golden: $goldenPath'); + } + if (status == 'reviewed' && (imagePath == null || goldenPath == null)) { + failures.add('$route is reviewed without both comparison images'); + } + + final variants = entry['variants'] as YamlList?; + final states = {}; + for (final value in variants ?? []) { + final variant = value as YamlMap; + final state = variant['state'] as String?; + final label = '$route/$state'; + final variantStatus = variant['status'] as String?; + if (state == null || !states.add(state)) { + failures.add('$route has a missing or duplicate variant state: $state'); + } + if (!statuses.contains(variantStatus)) { + failures.add('$label has invalid status: $variantStatus'); + continue; + } + variantCounts[variantStatus!] = (variantCounts[variantStatus] ?? 0) + 1; + + final variantNode = variant['figma_node'] as String?; + if (variantNode == null || !RegExp(r'^\d+:\d+$').hasMatch(variantNode)) { + failures.add('$label has missing or malformed Figma node'); + } + if (variantStatus == 'excluded' && variant['reason'] == null) { + failures.add('$label is excluded without a reason'); + } + + final variantImagePath = variant['figma_image'] as String?; + final variantHash = variant['figma_sha256'] as String?; + final variantGoldenPath = variant['flutter_golden'] as String?; + if ((variantImagePath == null) != (variantHash == null)) { + failures.add('$label must pair Figma image with its SHA-256'); + } + if (variantImagePath != null) { + final image = File(variantImagePath); + if (!image.existsSync()) { + failures.add('$label is missing Figma image: $variantImagePath'); + } else if (sha256.convert(image.readAsBytesSync()).toString() != + variantHash) { + failures.add('$label Figma image hash changed: $variantImagePath'); + } + } + if (variantGoldenPath != null && !File(variantGoldenPath).existsSync()) { + failures.add('$label is missing Flutter golden: $variantGoldenPath'); + } + if (variantStatus == 'reviewed' && + (variantImagePath == null || variantGoldenPath == null)) { + failures.add('$label is reviewed without both comparison images'); + } + } + } + + final router = File(routerPath).readAsStringSync(); + final declaredRoutes = RegExp(r"path:\s*'(/[^']+)'", multiLine: true) + .allMatches(router) + .map((match) => match.group(1)!) + .map((route) => route == '/start' ? '/onboarding/start' : route) + .toSet(); + for (final route in declaredRoutes.difference(routes)) { + failures.add('Router path has no Figma coverage entry: $route'); + } + for (final route in routes.difference(declaredRoutes)) { + failures.add('Coverage entry has no router path: $route'); + } + + stdout.writeln( + 'Figma UI coverage: ${entries.length} routes; ' + 'reviewed ${counts['reviewed'] ?? 0}, ' + 'in progress ${counts['in_progress'] ?? 0}, ' + 'pending ${counts['pending'] ?? 0}, ' + 'excluded ${counts['excluded'] ?? 0}; ' + 'variants reviewed ${variantCounts['reviewed'] ?? 0}, ' + 'in progress ${variantCounts['in_progress'] ?? 0}, ' + 'pending ${variantCounts['pending'] ?? 0}, ' + 'excluded ${variantCounts['excluded'] ?? 0}.', + ); + final designOnlyNodes = manifest['design_only_nodes'] as YamlList?; + final unmappedIds = {}; + for (final value in designOnlyNodes ?? []) { + final node = value as YamlMap; + final id = node['figma_node'] as String?; + if (id == null || + !RegExp(r'^\d+:\d+$').hasMatch(id) || + !unmappedIds.add(id) || + node['status'] != 'unmapped' || + node['reason'] == null) { + failures.add('Invalid or unexplained design-only node: $id'); + } + } + stdout.writeln( + 'Design-only states without an app route: ${unmappedIds.length}. ' + 'These are not counted as reviewed app UI; strict mode checks mapped routes.', + ); + if (strict && + (counts['pending'] ?? 0) + + (counts['in_progress'] ?? 0) + + (variantCounts['pending'] ?? 0) + + (variantCounts['in_progress'] ?? 0) > + 0) { + failures.add( + 'Strict Figma UI coverage requires all routes and variants reviewed or excluded.', + ); + } + for (final failure in failures) { + stderr.writeln(failure); + } + if (failures.isNotEmpty) exitCode = 1; +} diff --git a/widgetbook/lib/alarm_graph_component.dart b/widgetbook/lib/alarm_graph_component.dart index b3e581cb..73575ccd 100644 --- a/widgetbook/lib/alarm_graph_component.dart +++ b/widgetbook/lib/alarm_graph_component.dart @@ -3,10 +3,7 @@ import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; import 'package:on_time_front/presentation/alarm/components/alarm_graph_component.dart'; -@widgetbook.UseCase( - name: 'Static Graph Painter', - type: AlarmGraphComponent, -) +@widgetbook.UseCase(name: 'Static Graph Painter', type: AlarmGraphComponent) Widget alarmGraphComponentUseCase(BuildContext context) { final progress = context.knobs.double.slider( label: 'Progress', @@ -21,7 +18,7 @@ Widget alarmGraphComponentUseCase(BuildContext context) { height: 700, child: Center( child: CustomPaint( - size: const Size(230, 115), + size: const Size.square(268), painter: AlarmGraphComponent( progress: progress, backgroundColor: const Color(0xFF3D54BC),