From ccc6272bde15937a87dd577157b3330593718759 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Sat, 29 Aug 2026 02:06:31 +0900 Subject: [PATCH 1/6] feat: make OnTime fully local-only --- .github/workflows/android-play-closed.yml | 23 +- .github/workflows/android-play-internal.yml | 23 +- .github/workflows/firebase-hosting-merge.yml | 54 - .../firebase-hosting-pull-request.yml | 57 -- .github/workflows/flutter_test.yml | 6 +- CONTEXT.md | 490 +++++++-- README.md | 19 +- android/app/build.gradle | 82 -- android/app/src/main/AndroidManifest.xml | 3 + .../kotlin/club/devkor/ontime/MainActivity.kt | 3 + android/app/src/main/res/xml/backup_rules.xml | 8 + .../main/res/xml/data_extraction_rules.xml | 17 + android/settings.gradle | 3 - docs/Release-Checklist.md | 24 +- ...base-analytics-for-product-usage-events.md | 4 + ...0002-track-analytics-from-feature-blocs.md | 4 + ...orical-analytics-after-account-deletion.md | 4 + ...analytics-outside-production-by-default.md | 4 + ...matic-screen-tracking-for-first-release.md | 4 + ...ics-preference-across-signed-in-devices.md | 4 + ...mote-config-until-a-concrete-experiment.md | 4 + docs/adr/0011-make-ontime-local-only.md | 7 + ...12-encrypt-backups-with-a-user-password.md | 7 + ...-replace-durable-data-on-backup-restore.md | 7 + .../adr/0014-migrate-older-backups-forward.md | 7 + ...-start-local-only-without-server-import.md | 7 + ...6-support-local-only-on-android-and-ios.md | 7 + ...-exclude-platform-managed-data-transfer.md | 7 + ...ypt-active-data-with-a-device-bound-key.md | 7 + ...ture-notifications-by-platform-capacity.md | 7 + .../0020-reset-all-installation-owned-data.md | 7 + ...1-anchor-schedules-to-a-named-time-zone.md | 7 + ...ncrypted-drift-as-the-local-data-source.md | 7 + ...reserve-local-data-when-migration-fails.md | 7 + ...24-enforce-no-network-in-product-builds.md | 7 + ...025-calculate-punctuality-score-locally.md | 7 + ...0026-use-a-cross-platform-backup-format.md | 7 + ...-export-a-point-in-time-backup-snapshot.md | 7 + ...0028-require-a-verified-restore-preview.md | 7 + ...ve-preparation-runs-from-action-events.md} | 0 ...default-to-private-notification-content.md | 7 + ...2id-and-authenticated-stream-encryption.md | 7 + ...32-normalize-and-bound-backup-passwords.md | 7 + ...033-make-the-local-only-cutover-one-way.md | 7 + ...in-schedule-history-until-user-deletion.md | 7 + ...ckup-freshness-without-automatic-backup.md | 7 + docs/iOS-Release-Configuration.md | 47 +- ios/Flutter/Debug.xcconfig | 2 +- ios/Flutter/Profile.xcconfig | 1 - ios/Flutter/Release.xcconfig | 2 +- ios/Podfile | 43 + ios/Runner.xcodeproj/project.pbxproj | 21 - .../xcshareddata/swiftpm/Package.resolved | 144 --- .../xcshareddata/xcschemes/Runner.xcscheme | 34 - .../xcshareddata/swiftpm/Package.resolved | 157 --- ios/Runner/AppDelegate.swift | 25 +- ios/Runner/Info-Debug.plist | 18 - ios/Runner/Info.plist | 13 - ios/Runner/Runner.entitlements | 6 - ios/Runner/RunnerDebug.entitlements | 6 - ios/scripts/extract_dart_defines.sh | 59 -- ios/scripts/validate_release_info_plist.sh | 35 - lib/core/backup/backup_crypto.dart | 205 ++++ lib/core/backup/backup_password.dart | 31 + lib/core/backup/backup_service.dart | 632 ++++++++++++ lib/core/constants/endpoint.dart | 89 -- lib/core/constants/environment_variable.dart | 5 - lib/core/constants/external_links.dart | 5 - lib/core/constants/local_profile.dart | 1 + lib/core/database/database.dart | 62 +- lib/core/database/installation_key_store.dart | 47 + lib/core/database/local_data_files.dart | 2 + .../database/local_data_files_native.dart | 36 + lib/core/database/local_data_files_web.dart | 14 + lib/core/database/local_data_lifecycle.dart | 86 ++ .../database/local_data_reset_service.dart | 28 + lib/core/database/open_database.dart | 3 + lib/core/database/open_database_native.dart | 25 + .../database/open_database_unsupported.dart | 6 + lib/core/database/open_database_web.dart | 13 + lib/core/dio/adapters/mobile_adapter.dart | 6 - lib/core/dio/adapters/shared.dart | 3 - lib/core/dio/adapters/unsupported.dart | 5 - lib/core/dio/adapters/web_adapter.dart | 6 - lib/core/dio/api_error_message.dart | 71 -- lib/core/dio/api_response.dart | 11 - lib/core/dio/app_dio.dart | 42 - .../dio/interceptors/logger_interceptor.dart | 59 -- .../dio/interceptors/token_interceptor.dart | 209 ---- .../token_session_invalidator.dart | 3 - .../dio/transformers/logging_transformer.dart | 28 - lib/core/logging/app_logger.dart | 14 +- .../services/alarm_scheduler_service.dart | 4 +- ...ailed_notification_preference_service.dart | 22 + .../device_info_service_mobile.dart | 3 +- .../device_info_service_unsupported.dart | 9 +- .../services/device_info_service/shared.dart | 15 +- .../fallback_alarm_notification_service.dart | 1 - .../google_authentication_service.dart | 101 -- .../services/local_time_zone_service.dart | 20 + lib/core/services/notification_content.dart | 36 - .../notification_request_mobile_service.dart | 14 - .../notification_request_web_service.dart | 5 - .../notification_request_service/shared.dart | 2 - lib/core/services/notification_routing.dart | 9 - lib/core/services/notification_service.dart | 635 +++--------- .../services/notification_tap_router.dart | 11 - .../notification_token_registrar.dart | 10 - .../services/product_analytics_service.dart | 101 -- lib/core/time/civil_time_resolver.dart | 105 ++ .../duration_json_converters.dart | 32 + lib/core/validation/backend_constraints.dart | 58 -- lib/core/validation/local_input_limits.dart | 4 + lib/data/daos/place_dao.dart | 4 +- lib/data/daos/preparation_schedule_dao.dart | 56 +- lib/data/daos/preparation_template_dao.dart | 97 ++ lib/data/daos/preparation_user_dao.dart | 41 +- lib/data/daos/schedule_dao.dart | 56 +- lib/data/daos/user_dao.dart | 92 +- .../alarm_remote_data_source.dart | 169 ---- ...nalytics_preference_local_data_source.dart | 27 - ...alytics_preference_remote_data_source.dart | 52 - .../authentication_remote_data_source.dart | 256 ----- ...early_start_session_local_data_source.dart | 4 +- .../notification_remote_data_source.dart | 31 - .../preparation_local_data_source.dart | 106 +- .../preparation_remote_data_source.dart | 176 ---- ...eparation_template_remote_data_source.dart | 112 --- .../schedule_remote_data_source.dart | 155 --- .../data_sources/token_local_data_source.dart | 119 --- .../mappers/domain_persistence_mappers.dart | 44 +- lib/data/models/alarm_device_model.dart | 45 - lib/data/models/alarm_settings_model.dart | 60 -- .../models/alarm_status_report_model.dart | 147 --- .../models/alarm_window_schedule_model.dart | 188 ---- ...ate_defualt_preparation_request_model.dart | 39 - ...te_preparation_schedule_request_model.dart | 61 -- ...create_preparation_step_request_model.dart | 46 - .../models/create_schedule_request_model.dart | 114 --- .../fcm_token_register_request_model.dart | 18 - lib/data/models/get_place_response_model.dart | 34 - .../get_preparation_step_response_model.dart | 54 - .../get_preparation_user_response_model.dart | 70 -- .../models/get_schedule_response_model.dart | 140 --- lib/data/models/get_user_response_model.dart | 42 - .../ordered_preparation_step_model.dart | 86 -- .../models/preparation_template_model.dart | 125 --- .../models/scheduled_alarm_record_model.dart | 19 +- .../models/sign_in_user_response_model.dart | 42 - .../sign_in_with_apple_request_model.dart | 27 - .../sign_in_with_google_request_model.dart | 19 - ...te_preparation_schedule_request_model.dart | 61 -- ...update_preparation_user_request_model.dart | 60 -- .../models/update_schedule_request_model.dart | 111 -- .../update_spare_time_request_model.dart | 13 - .../alarm_registry_repository_impl.dart | 9 +- .../repositories/alarm_repository_impl.dart | 160 ++- .../analytics_preference_repository_impl.dart | 36 - .../early_start_session_repository_impl.dart | 4 +- .../preparation_repository_impl.dart | 183 ++-- .../preparation_template_repository_impl.dart | 49 +- .../schedule_repository_impl.dart | 335 ++----- .../timed_preparation_repository_impl.dart | 7 +- .../repositories/user_repository_impl.dart | 225 +---- .../services/device_fcm_token_registrar.dart | 27 - .../token_local_session_invalidator.dart | 15 - .../preparation_template_step_table.dart | 18 + .../tables/preparation_template_table.dart | 12 + lib/data/tables/schedules_table.dart | 17 +- lib/data/tables/user_table.dart | 19 +- ...ent_schedules_with_preparation_entity.dart | 1 - lib/domain/entities/alarm_entities.dart | 105 +- lib/domain/entities/analytics_preference.dart | 28 - .../entities/google_auth_credential.dart | 6 - lib/domain/entities/preparation_entity.dart | 8 +- .../preparation_step_with_time_entity.dart | 19 +- lib/domain/entities/product_usage_event.dart | 348 ------- lib/domain/entities/schedule_entity.dart | 38 + .../schedule_with_preparation_entity.dart | 21 +- lib/domain/entities/token_entity.dart | 22 - lib/domain/entities/user_entity.dart | 54 +- lib/domain/repositories/alarm_repository.dart | 10 - .../analytics_preference_repository.dart | 11 - .../repositories/preparation_repository.dart | 17 +- lib/domain/repositories/user_repository.dart | 34 +- .../use-cases/cancel_all_alarms_use_case.dart | 15 +- .../create_custom_preparation_use_case.dart | 8 +- ...ate_schedule_form_submission_use_case.dart | 7 - .../use-cases/delete_user_use_case.dart | 24 - ...t_schedules_with_preparation_use_case.dart | 59 +- ...et_nearest_upcoming_schedule_use_case.dart | 30 +- ...nt_schedule_with_preparation_use_case.dart | 5 +- .../load_analytics_preference_use_case.dart | 25 - ...d_preparation_by_schedule_id_use_case.dart | 3 +- .../load_schedule_form_draft_use_case.dart | 19 +- .../load_schedules_by_date_use_case.dart | 3 +- .../mark_early_start_session_use_case.dart | 5 +- lib/domain/use-cases/onboard_use_case.dart | 15 +- .../use-cases/reconcile_alarms_use_case.dart | 112 +-- .../use-cases/schedule_analytics_tracker.dart | 9 - lib/domain/use-cases/sign_out_use_case.dart | 16 - .../track_product_usage_event_use_case.dart | 27 - .../track_schedule_analytics_use_case.dart | 36 - .../update_analytics_preference_use_case.dart | 26 - ...e_preparation_by_schedule_id_use_case.dart | 8 +- lib/firebase_options.dart | 76 -- lib/main.dart | 10 +- .../components/alarm_graph_animator.dart | 10 +- .../components/alarm_graph_component.dart | 16 +- .../components/alarm_screen_top_section.dart | 10 +- .../preparation_step_list_widget.dart | 8 +- .../components/preparation_step_tile.dart | 5 +- .../alarm/screens/alarm_screen.dart | 50 +- .../alarm/screens/schedule_start_screen.dart | 21 +- lib/presentation/app/bloc/auth/auth_bloc.dart | 24 +- .../app/bloc/auth/auth_event.dart | 4 - .../app/bloc/auth/auth_state.dart | 43 +- .../app/bloc/schedule/schedule_bloc.dart | 8 +- .../app/bloc/schedule/schedule_state.dart | 30 +- .../app/cubit/analytics_preference_cubit.dart | 76 -- .../app/cubit/analytics_preference_state.dart | 64 -- .../app/cubit/notification_gate_cubit.dart | 10 +- .../app/cubit/notification_gate_state.dart | 19 +- .../calendar/bloc/monthly_schedules_bloc.dart | 10 +- .../calendar/screens/calendar_screen.dart | 18 +- .../bloc/early_late_screen_bloc.dart | 54 +- .../bloc/early_late_screen_state.dart | 10 +- .../components/check_list_box_widget.dart | 5 +- .../components/check_list_item_widget.dart | 10 +- .../early_late/screens/early_late_screen.dart | 15 +- .../home/bloc/schedule_timer_bloc.dart | 59 +- .../home/bloc/weekly_schedules_bloc.dart | 39 +- .../home/bloc/weekly_schedules_state.dart | 13 +- .../home/components/home_app_bar.dart | 16 +- .../home/components/month_calendar.dart | 64 +- .../home/components/todays_schedule_tile.dart | 36 +- .../home/components/week_calendar.dart | 143 +-- .../apple_sign_in_button_mobile.dart | 29 - .../google_sign_in_button_mobile.dart | 62 -- .../google_sign_in_button_web.dart | 48 - .../google_sign_in_button/shared.dart | 3 - .../google_sign_in_button/unsupported.dart | 12 - .../login/screens/sign_in_main_screen.dart | 211 ---- lib/presentation/my_page/my_data_screen.dart | 329 ++++++ .../my_page_modal/delete_user_modal.dart | 288 ------ .../my_page/my_page_modal/logout_modal.dart | 29 - lib/presentation/my_page/my_page_screen.dart | 226 +---- ...ault_preparation_spare_time_form_bloc.dart | 3 +- .../my_page/privacy_policy_screen.dart | 34 + .../screens/notification_allow_screen.dart | 1 - .../onboarding_page_view_layout.dart | 10 +- .../components/onboarding_title.dart | 28 +- .../onboarding/cubit/onboarding_cubit.dart | 17 +- .../onboarding/cubit/onboarding_state.dart | 33 +- .../components/create_icon_button.dart | 10 +- .../components/preparation_create_list.dart | 74 +- .../preparation_name_select_field.dart | 3 +- .../components/preparation_select_list.dart | 1 - .../preparation_name_cubit.dart | 132 +-- .../preparation_step_name_cubit.dart | 12 +- .../preparation_step_name_state.dart | 8 +- .../screens/preparation_name_form.dart | 9 +- .../preparation_reorderable_list.dart | 23 +- .../cubit/preparation_order_cubit.dart | 10 +- .../cubit/preparation_order_state.dart | 34 +- .../screens/preparation_order_form.dart | 11 +- .../preparation_time_input_list.dart | 7 +- .../components/preparation_time_tile.dart | 6 +- .../cubit/preparation_time_cubit.dart | 30 +- .../cubit/preparation_time_state.dart | 26 +- .../preparation_time_input_model.dart | 4 +- .../screens/preparation_time_form.dart | 7 +- .../cubit/schedule_spare_time_cubit.dart | 5 +- .../cubit/schedule_spare_time_state.dart | 17 +- .../screens/schedule_spare_time_form.dart | 13 +- .../screens/onboarding_start_screen.dart | 17 +- .../bloc/schedule_form_bloc.dart | 8 +- .../bloc/schedule_form_event.dart | 3 + .../bloc/schedule_form_state.dart | 15 + .../components/message_bubble.dart | 26 +- ...eparation_reorderable_list_form_field.dart | 71 +- .../cubit/schedule_date_time_cubit.dart | 216 ++-- .../cubit/schedule_date_time_state.dart | 44 +- .../screens/schedule_date_time_form.dart | 70 ++ .../cubit/schedule_name_cubit.dart | 24 +- .../cubit/schedule_name_state.dart | 12 +- .../schedule_name_input_model.dart | 4 +- .../screens/schedule_name_form.dart | 31 +- .../schedule_place_moving_time_cubit.dart | 45 +- .../schedule_place_moving_time_state.dart | 28 +- .../schedule_moving_time_input_model.dart | 4 +- .../schedule_place_input_model.dart | 4 +- .../schedule_place_moving_time_form.dart | 101 +- .../cubit/schedule_form_spare_time_cubit.dart | 87 +- .../cubit/schedule_form_spare_time_state.dart | 28 +- .../schedule_spare_time_input_model.dart | 4 +- .../preparation_form_list_field.dart | 4 +- .../cubit/preparation_edit_draft_cubit.dart | 1 - .../cubit/preparation_step_form_cubit.dart | 22 +- .../shared/components/arc_indicator.dart | 13 +- .../calendar/centered_calendar_header.dart | 5 +- .../shared/components/check_button.dart | 28 +- .../components/custom_alert_dialog.dart | 49 +- .../components/error_message_bubble.dart | 23 +- .../shared/components/loading_screen.dart | 5 +- .../shared/components/step_progress.dart | 36 +- lib/presentation/shared/components/tile.dart | 20 +- .../shared/components/time_stepper.dart | 17 +- .../shared/components/two_action_dialog.dart | 11 +- .../shared/constants/app_colors.dart | 137 ++- .../shared/constants/constants.dart | 6 +- .../constants/early_late_text_images.dart | 13 +- lib/presentation/shared/router/go_router.dart | 40 +- .../shared/router/route_arguments.dart | 20 +- .../shared/theme/button_styles.dart | 95 +- .../shared/theme/calendar_theme.dart | 4 +- .../shared/theme/input_decoration_theme.dart | 39 +- lib/presentation/shared/theme/text_theme.dart | 42 +- lib/presentation/shared/theme/theme.dart | 17 +- lib/presentation/shared/theme/tile_style.dart | 15 +- .../shared/utils/login_platform.dart | 6 +- .../screens/local_data_recovery_screen.dart | 101 ++ linux/flutter/generated_plugin_registrant.cc | 16 +- linux/flutter/generated_plugins.cmake | 4 +- macos/Flutter/GeneratedPluginRegistrant.swift | 22 +- pubspec.lock | 454 ++------- pubspec.yaml | 25 +- test/core/backup/backup_crypto_test.dart | 60 ++ test/core/backup/backup_password_test.dart | 26 + test/core/backup/backup_service_test.dart | 120 +++ test/core/database/database_index_test.dart | 36 +- test/core/dio/api_error_message_test.dart | 34 - .../interceptors/logger_interceptor_test.dart | 84 -- .../interceptors/token_interceptor_test.dart | 413 -------- .../logging_transformer_test.dart | 76 -- ...lback_alarm_notification_service_test.dart | 3 +- .../services/notification_content_test.dart | 45 - .../services/notification_routing_test.dart | 29 +- .../services/notification_service_test.dart | 915 ----------------- .../notification_tap_router_test.dart | 16 - .../product_analytics_service_test.dart | 100 -- test/core/time/civil_time_resolver_test.dart | 41 + .../validation/backend_constraints_test.dart | 52 - .../daos/preparation_schedule_dao_test.dart | 5 +- test/data/daos/preparation_user_dao_test.dart | 5 +- test/data/daos/schedule_dao_test.dart | 5 + test/data/daos/user_dao_test.dart | 10 +- .../alarm_remote_data_source_test.dart | 445 -------- ...cs_preference_remote_data_source_test.dart | 72 -- ...uthentication_remote_data_source_test.dart | 387 ------- .../notification_remote_data_source_test.dart | 64 -- .../preparation_local_data_source_test.dart | 25 +- .../preparation_remote_data_source_test.dart | 190 ---- ...tion_template_remote_data_source_test.dart | 103 -- .../schedule_data_source_contract_test.dart | 11 - .../schedule_local_data_source_test.dart | 5 + .../schedule_remote_data_source_test.dart | 197 ---- .../token_local_data_source_test.dart | 230 ----- .../domain_persistence_mappers_test.dart | 24 +- test/data/models/alarm_models_test.dart | 648 ------------ ..._preparation_step_response_model_test.dart | 101 -- .../get_schedule_response_model_test.dart | 151 --- .../preparation_create_models_test.dart | 81 -- .../preparation_template_model_test.dart | 113 --- .../preparation_update_models_test.dart | 73 -- .../schedule_preparation_contract_test.dart | 122 --- .../alarm_repository_impl_test.dart | 197 ---- ...ytics_preference_repository_impl_test.dart | 60 -- .../local_schedule_score_test.dart | 80 ++ .../preparation_repository_impl_test.dart | 268 ----- .../schedule_repository_impl_stream_test.dart | 418 -------- .../schedule_repository_impl_test.dart | 425 -------- .../user_repository_impl_test.dart | 463 --------- .../device_fcm_token_registrar_test.dart | 43 - .../preparation_timing_entity_test.dart | 5 +- .../entities/product_usage_event_test.dart | 111 -- test/domain/entities/user_entity_test.dart | 13 +- .../user_repository_boundary_test.dart | 17 - .../analytics_preference_use_cases_test.dart | 68 -- .../cancel_alarms_use_cases_test.dart | 58 +- ...chedule_form_submission_use_case_test.dart | 21 +- .../use-cases/delete_user_use_case_test.dart | 107 -- .../reconcile_alarms_use_case_test.dart | 251 ++--- .../schedule_mutation_use_cases_test.dart | 87 -- ...rack_schedule_analytics_use_case_test.dart | 71 -- .../domain/use-cases/user_use_cases_test.dart | 188 ---- test/helpers/mock.dart | 14 - test/helpers/sodium_test_loader.dart | 14 + test/local_only_boundary_test.dart | 11 + .../alarm_allow/alarm_allow_screen_test.dart | 36 +- .../app/bloc/auth/auth_bloc_test.dart | 224 +---- .../app/bloc/schedule/schedule_bloc_test.dart | 6 +- .../app/cubit/alarm_gate_cubit_test.dart | 25 +- .../analytics_preference_cubit_test.dart | 117 --- .../cubit/notification_gate_cubit_test.dart | 1 - .../bloc/monthly_schedules_bloc_test.dart | 15 +- .../home/screens/home_screen_tmp_test.dart | 15 +- .../screens/sign_in_main_screen_test.dart | 176 ---- .../my_page/delete_user_modal_test.dart | 302 ------ .../my_page/logout_modal_test.dart | 91 -- .../my_page/my_page_screen_test.dart | 947 ------------------ ...eparation_spare_time_edit_screen_test.dart | 5 +- .../notification_allow_screen_test.dart | 1 - .../bloc/schedule_form_bloc_test.dart | 2 + .../bloc/schedule_form_state_event_test.dart | 3 +- .../schedule_multi_page_form_test.dart | 5 +- .../schedule_date_time_cubit_test.dart | 42 + .../schedule_date_time_form_test.dart | 26 + .../schedule_date_time_state_test.dart | 6 + ...le_spare_and_preparing_time_form_test.dart | 5 +- tool/check_local_only_boundary.dart | 82 ++ .../flutter/generated_plugin_registrant.cc | 15 +- windows/flutter/generated_plugins.cmake | 5 +- 413 files changed, 6137 insertions(+), 19108 deletions(-) delete mode 100644 .github/workflows/firebase-hosting-merge.yml delete mode 100644 .github/workflows/firebase-hosting-pull-request.yml create mode 100644 android/app/src/main/res/xml/backup_rules.xml create mode 100644 android/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 docs/adr/0011-make-ontime-local-only.md create mode 100644 docs/adr/0012-encrypt-backups-with-a-user-password.md create mode 100644 docs/adr/0013-replace-durable-data-on-backup-restore.md create mode 100644 docs/adr/0014-migrate-older-backups-forward.md create mode 100644 docs/adr/0015-start-local-only-without-server-import.md create mode 100644 docs/adr/0016-support-local-only-on-android-and-ios.md create mode 100644 docs/adr/0017-exclude-platform-managed-data-transfer.md create mode 100644 docs/adr/0018-encrypt-active-data-with-a-device-bound-key.md create mode 100644 docs/adr/0019-schedule-future-notifications-by-platform-capacity.md create mode 100644 docs/adr/0020-reset-all-installation-owned-data.md create mode 100644 docs/adr/0021-anchor-schedules-to-a-named-time-zone.md create mode 100644 docs/adr/0022-use-encrypted-drift-as-the-local-data-source.md create mode 100644 docs/adr/0023-preserve-local-data-when-migration-fails.md create mode 100644 docs/adr/0024-enforce-no-network-in-product-builds.md create mode 100644 docs/adr/0025-calculate-punctuality-score-locally.md create mode 100644 docs/adr/0026-use-a-cross-platform-backup-format.md create mode 100644 docs/adr/0027-export-a-point-in-time-backup-snapshot.md create mode 100644 docs/adr/0028-require-a-verified-restore-preview.md rename docs/adr/{0010-derive-preparation-runs-from-action-events.md => 0029-derive-preparation-runs-from-action-events.md} (100%) create mode 100644 docs/adr/0030-default-to-private-notification-content.md create mode 100644 docs/adr/0031-use-argon2id-and-authenticated-stream-encryption.md create mode 100644 docs/adr/0032-normalize-and-bound-backup-passwords.md create mode 100644 docs/adr/0033-make-the-local-only-cutover-one-way.md create mode 100644 docs/adr/0034-retain-schedule-history-until-user-deletion.md create mode 100644 docs/adr/0035-track-backup-freshness-without-automatic-backup.md create mode 100644 ios/Podfile delete mode 100644 ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved delete mode 100755 ios/scripts/extract_dart_defines.sh delete mode 100755 ios/scripts/validate_release_info_plist.sh create mode 100644 lib/core/backup/backup_crypto.dart create mode 100644 lib/core/backup/backup_password.dart create mode 100644 lib/core/backup/backup_service.dart delete mode 100644 lib/core/constants/endpoint.dart delete mode 100644 lib/core/constants/environment_variable.dart delete mode 100644 lib/core/constants/external_links.dart create mode 100644 lib/core/constants/local_profile.dart create mode 100644 lib/core/database/installation_key_store.dart create mode 100644 lib/core/database/local_data_files.dart create mode 100644 lib/core/database/local_data_files_native.dart create mode 100644 lib/core/database/local_data_files_web.dart create mode 100644 lib/core/database/local_data_lifecycle.dart create mode 100644 lib/core/database/local_data_reset_service.dart create mode 100644 lib/core/database/open_database.dart create mode 100644 lib/core/database/open_database_native.dart create mode 100644 lib/core/database/open_database_unsupported.dart create mode 100644 lib/core/database/open_database_web.dart delete mode 100644 lib/core/dio/adapters/mobile_adapter.dart delete mode 100644 lib/core/dio/adapters/shared.dart delete mode 100644 lib/core/dio/adapters/unsupported.dart delete mode 100644 lib/core/dio/adapters/web_adapter.dart delete mode 100644 lib/core/dio/api_error_message.dart delete mode 100644 lib/core/dio/api_response.dart delete mode 100644 lib/core/dio/app_dio.dart delete mode 100644 lib/core/dio/interceptors/logger_interceptor.dart delete mode 100644 lib/core/dio/interceptors/token_interceptor.dart delete mode 100644 lib/core/dio/interceptors/token_session_invalidator.dart delete mode 100644 lib/core/dio/transformers/logging_transformer.dart create mode 100644 lib/core/services/detailed_notification_preference_service.dart delete mode 100644 lib/core/services/google_authentication_service.dart create mode 100644 lib/core/services/local_time_zone_service.dart delete mode 100644 lib/core/services/notification_request_service/notification_request_mobile_service.dart delete mode 100644 lib/core/services/notification_request_service/notification_request_web_service.dart delete mode 100644 lib/core/services/notification_request_service/shared.dart delete mode 100644 lib/core/services/notification_token_registrar.dart delete mode 100644 lib/core/services/product_analytics_service.dart create mode 100644 lib/core/time/civil_time_resolver.dart delete mode 100644 lib/core/validation/backend_constraints.dart create mode 100644 lib/core/validation/local_input_limits.dart create mode 100644 lib/data/daos/preparation_template_dao.dart delete mode 100644 lib/data/data_sources/alarm_remote_data_source.dart delete mode 100644 lib/data/data_sources/analytics_preference_local_data_source.dart delete mode 100644 lib/data/data_sources/analytics_preference_remote_data_source.dart delete mode 100644 lib/data/data_sources/authentication_remote_data_source.dart delete mode 100644 lib/data/data_sources/notification_remote_data_source.dart delete mode 100644 lib/data/data_sources/preparation_remote_data_source.dart delete mode 100644 lib/data/data_sources/preparation_template_remote_data_source.dart delete mode 100644 lib/data/data_sources/schedule_remote_data_source.dart delete mode 100644 lib/data/data_sources/token_local_data_source.dart delete mode 100644 lib/data/models/alarm_device_model.dart delete mode 100644 lib/data/models/alarm_settings_model.dart delete mode 100644 lib/data/models/alarm_status_report_model.dart delete mode 100644 lib/data/models/alarm_window_schedule_model.dart delete mode 100644 lib/data/models/create_defualt_preparation_request_model.dart delete mode 100644 lib/data/models/create_preparation_schedule_request_model.dart delete mode 100644 lib/data/models/create_preparation_step_request_model.dart delete mode 100644 lib/data/models/create_schedule_request_model.dart delete mode 100644 lib/data/models/fcm_token_register_request_model.dart delete mode 100644 lib/data/models/get_place_response_model.dart delete mode 100644 lib/data/models/get_preparation_step_response_model.dart delete mode 100644 lib/data/models/get_preparation_user_response_model.dart delete mode 100644 lib/data/models/get_schedule_response_model.dart delete mode 100644 lib/data/models/get_user_response_model.dart delete mode 100644 lib/data/models/ordered_preparation_step_model.dart delete mode 100644 lib/data/models/preparation_template_model.dart delete mode 100644 lib/data/models/sign_in_user_response_model.dart delete mode 100644 lib/data/models/sign_in_with_apple_request_model.dart delete mode 100644 lib/data/models/sign_in_with_google_request_model.dart delete mode 100644 lib/data/models/update_preparation_schedule_request_model.dart delete mode 100644 lib/data/models/update_preparation_user_request_model.dart delete mode 100644 lib/data/models/update_schedule_request_model.dart delete mode 100644 lib/data/models/update_spare_time_request_model.dart delete mode 100644 lib/data/repositories/analytics_preference_repository_impl.dart delete mode 100644 lib/data/services/device_fcm_token_registrar.dart delete mode 100644 lib/data/services/token_local_session_invalidator.dart create mode 100644 lib/data/tables/preparation_template_step_table.dart create mode 100644 lib/data/tables/preparation_template_table.dart delete mode 100644 lib/domain/entities/analytics_preference.dart delete mode 100644 lib/domain/entities/google_auth_credential.dart delete mode 100644 lib/domain/entities/product_usage_event.dart delete mode 100644 lib/domain/entities/token_entity.dart delete mode 100644 lib/domain/repositories/analytics_preference_repository.dart delete mode 100644 lib/domain/use-cases/delete_user_use_case.dart delete mode 100644 lib/domain/use-cases/load_analytics_preference_use_case.dart delete mode 100644 lib/domain/use-cases/schedule_analytics_tracker.dart delete mode 100644 lib/domain/use-cases/sign_out_use_case.dart delete mode 100644 lib/domain/use-cases/track_product_usage_event_use_case.dart delete mode 100644 lib/domain/use-cases/track_schedule_analytics_use_case.dart delete mode 100644 lib/domain/use-cases/update_analytics_preference_use_case.dart delete mode 100644 lib/firebase_options.dart delete mode 100644 lib/presentation/app/cubit/analytics_preference_cubit.dart delete mode 100644 lib/presentation/app/cubit/analytics_preference_state.dart delete mode 100644 lib/presentation/login/components/google_sign_in_button/apple_sign_in_button_mobile.dart delete mode 100644 lib/presentation/login/components/google_sign_in_button/google_sign_in_button_mobile.dart delete mode 100644 lib/presentation/login/components/google_sign_in_button/google_sign_in_button_web.dart delete mode 100644 lib/presentation/login/components/google_sign_in_button/shared.dart delete mode 100644 lib/presentation/login/components/google_sign_in_button/unsupported.dart delete mode 100644 lib/presentation/login/screens/sign_in_main_screen.dart create mode 100644 lib/presentation/my_page/my_data_screen.dart delete mode 100644 lib/presentation/my_page/my_page_modal/delete_user_modal.dart delete mode 100644 lib/presentation/my_page/my_page_modal/logout_modal.dart create mode 100644 lib/presentation/my_page/privacy_policy_screen.dart create mode 100644 lib/presentation/startup/screens/local_data_recovery_screen.dart create mode 100644 test/core/backup/backup_crypto_test.dart create mode 100644 test/core/backup/backup_password_test.dart create mode 100644 test/core/backup/backup_service_test.dart delete mode 100644 test/core/dio/api_error_message_test.dart delete mode 100644 test/core/dio/interceptors/logger_interceptor_test.dart delete mode 100644 test/core/dio/interceptors/token_interceptor_test.dart delete mode 100644 test/core/dio/transformers/logging_transformer_test.dart delete mode 100644 test/core/services/notification_service_test.dart delete mode 100644 test/core/services/product_analytics_service_test.dart create mode 100644 test/core/time/civil_time_resolver_test.dart delete mode 100644 test/core/validation/backend_constraints_test.dart delete mode 100644 test/data/data_sources/alarm_remote_data_source_test.dart delete mode 100644 test/data/data_sources/analytics_preference_remote_data_source_test.dart delete mode 100644 test/data/data_sources/authentication_remote_data_source_test.dart delete mode 100644 test/data/data_sources/notification_remote_data_source_test.dart delete mode 100644 test/data/data_sources/preparation_remote_data_source_test.dart delete mode 100644 test/data/data_sources/preparation_template_remote_data_source_test.dart delete mode 100644 test/data/data_sources/schedule_remote_data_source_test.dart delete mode 100644 test/data/data_sources/token_local_data_source_test.dart delete mode 100644 test/data/models/alarm_models_test.dart delete mode 100644 test/data/models/get_preparation_step_response_model_test.dart delete mode 100644 test/data/models/get_schedule_response_model_test.dart delete mode 100644 test/data/models/preparation_create_models_test.dart delete mode 100644 test/data/models/preparation_template_model_test.dart delete mode 100644 test/data/models/preparation_update_models_test.dart delete mode 100644 test/data/models/schedule_preparation_contract_test.dart delete mode 100644 test/data/repositories/alarm_repository_impl_test.dart delete mode 100644 test/data/repositories/analytics_preference_repository_impl_test.dart create mode 100644 test/data/repositories/local_schedule_score_test.dart delete mode 100644 test/data/repositories/preparation_repository_impl_test.dart delete mode 100644 test/data/repositories/schedule_repository_impl_stream_test.dart delete mode 100644 test/data/repositories/schedule_repository_impl_test.dart delete mode 100644 test/data/repositories/user_repository_impl_test.dart delete mode 100644 test/data/services/device_fcm_token_registrar_test.dart delete mode 100644 test/domain/entities/product_usage_event_test.dart delete mode 100644 test/domain/repositories/user_repository_boundary_test.dart delete mode 100644 test/domain/use-cases/analytics_preference_use_cases_test.dart delete mode 100644 test/domain/use-cases/delete_user_use_case_test.dart delete mode 100644 test/domain/use-cases/track_schedule_analytics_use_case_test.dart delete mode 100644 test/domain/use-cases/user_use_cases_test.dart delete mode 100644 test/helpers/mock.dart create mode 100644 test/helpers/sodium_test_loader.dart create mode 100644 test/local_only_boundary_test.dart delete mode 100644 test/presentation/app/cubit/analytics_preference_cubit_test.dart delete mode 100644 test/presentation/login/screens/sign_in_main_screen_test.dart delete mode 100644 test/presentation/my_page/delete_user_modal_test.dart delete mode 100644 test/presentation/my_page/logout_modal_test.dart delete mode 100644 test/presentation/my_page/my_page_screen_test.dart create mode 100644 tool/check_local_only_boundary.dart diff --git a/.github/workflows/android-play-closed.yml b/.github/workflows/android-play-closed.yml index 90e6964a..43f80592 100644 --- a/.github/workflows/android-play-closed.yml +++ b/.github/workflows/android-play-closed.yml @@ -24,13 +24,11 @@ jobs: ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} - REST_API_URL: ${{ vars.REST_API_URL }} steps: - name: Validate workflow inputs env: ANDROID_VERSION_CODE: ${{ inputs.android_version_code }} - ANDROID_GOOGLE_SERVICES_JSON_B64: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON_B64 }} ANDROID_UPLOAD_KEYSTORE_B64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_B64 }} GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} run: | @@ -50,13 +48,11 @@ jobs: missing=0 for name in \ - ANDROID_GOOGLE_SERVICES_JSON_B64 \ ANDROID_UPLOAD_KEYSTORE_B64 \ ANDROID_KEYSTORE_PASSWORD \ ANDROID_KEY_ALIAS \ ANDROID_KEY_PASSWORD \ - GOOGLE_PLAY_SERVICE_ACCOUNT_JSON \ - REST_API_URL + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON do if [ -z "${!name}" ]; then echo "$name is required for Android Play closed testing deploy." >&2 @@ -82,15 +78,7 @@ jobs: run: echo "ANDROID_KEYSTORE_PATH=$RUNNER_TEMP/ontime-upload.jks" >> "$GITHUB_ENV" - name: Install native test dependencies - run: sudo apt-get update && sudo apt-get install -y sqlite3 libsqlite3-dev - - - name: Decode Android Firebase config - env: - ANDROID_GOOGLE_SERVICES_JSON_B64: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON_B64 }} - run: | - mkdir -p android/app/src/release - printf '%s' "$ANDROID_GOOGLE_SERVICES_JSON_B64" | base64 --decode > android/app/src/release/google-services.json - test -s android/app/src/release/google-services.json + run: sudo apt-get update && sudo apt-get install -y sqlite3 libsqlite3-dev libsodium-dev - name: Decode Android upload keystore env: @@ -109,6 +97,9 @@ jobs: - name: Check generated Dart policy run: dart run tool/check_generated_dart_policy.dart + - name: Check local-only product boundary + run: dart run tool/check_local_only_boundary.dart + - name: Verify generation left tracked files unchanged run: git diff --exit-code @@ -132,9 +123,7 @@ jobs: run: | flutter build appbundle --release \ --build-name="$ANDROID_BUILD_NAME" \ - --build-number="${{ inputs.android_version_code }}" \ - --dart-define=ENV=staging \ - --dart-define=REST_API_URL="$REST_API_URL" + --build-number="${{ inputs.android_version_code }}" - name: Prepare Play release notes if: ${{ inputs.release_notes != '' }} diff --git a/.github/workflows/android-play-internal.yml b/.github/workflows/android-play-internal.yml index c3d4b3bc..bdd75b90 100644 --- a/.github/workflows/android-play-internal.yml +++ b/.github/workflows/android-play-internal.yml @@ -24,13 +24,11 @@ jobs: ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} - REST_API_URL: ${{ vars.REST_API_URL }} steps: - name: Validate workflow inputs env: ANDROID_VERSION_CODE: ${{ inputs.android_version_code }} - ANDROID_GOOGLE_SERVICES_JSON_B64: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON_B64 }} ANDROID_UPLOAD_KEYSTORE_B64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_B64 }} GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} run: | @@ -46,13 +44,11 @@ jobs: missing=0 for name in \ - ANDROID_GOOGLE_SERVICES_JSON_B64 \ ANDROID_UPLOAD_KEYSTORE_B64 \ ANDROID_KEYSTORE_PASSWORD \ ANDROID_KEY_ALIAS \ ANDROID_KEY_PASSWORD \ - GOOGLE_PLAY_SERVICE_ACCOUNT_JSON \ - REST_API_URL + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON do if [ -z "${!name}" ]; then echo "$name is required for Android Play internal deploy." >&2 @@ -78,15 +74,7 @@ jobs: run: echo "ANDROID_KEYSTORE_PATH=$RUNNER_TEMP/ontime-upload.jks" >> "$GITHUB_ENV" - name: Install native test dependencies - run: sudo apt-get update && sudo apt-get install -y sqlite3 libsqlite3-dev - - - name: Decode Android Firebase config - env: - ANDROID_GOOGLE_SERVICES_JSON_B64: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON_B64 }} - run: | - mkdir -p android/app/src/release - printf '%s' "$ANDROID_GOOGLE_SERVICES_JSON_B64" | base64 --decode > android/app/src/release/google-services.json - test -s android/app/src/release/google-services.json + run: sudo apt-get update && sudo apt-get install -y sqlite3 libsqlite3-dev libsodium-dev - name: Decode Android upload keystore env: @@ -105,6 +93,9 @@ jobs: - name: Check generated Dart policy run: dart run tool/check_generated_dart_policy.dart + - name: Check local-only product boundary + run: dart run tool/check_local_only_boundary.dart + - name: Verify generation left tracked files unchanged run: git diff --exit-code @@ -128,9 +119,7 @@ jobs: run: | flutter build appbundle --release \ --build-name="$ANDROID_BUILD_NAME" \ - --build-number="${{ inputs.android_version_code }}" \ - --dart-define=ENV=staging \ - --dart-define=REST_API_URL="$REST_API_URL" + --build-number="${{ inputs.android_version_code }}" - name: Prepare Play release notes if: ${{ inputs.release_notes != '' }} diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml deleted file mode 100644 index 4e26dea6..00000000 --- a/.github/workflows/firebase-hosting-merge.yml +++ /dev/null @@ -1,54 +0,0 @@ -# This file was auto-generated by the Firebase CLI -# https://github.com/firebase/firebase-tools - -name: Deploy to Firebase Hosting on merge -on: - push: - branches: - - main -permissions: - checks: write - contents: read - pull-requests: write -jobs: - build_and_deploy: - runs-on: ubuntu-latest - environment: staging - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-java@v1 - with: - java-version: "12.x" - - uses: subosito/flutter-action@v2 - with: - flutter-version: "3.44.4" - channel: "stable" - - - name: Use Environment Variables - env: - REST_API_URL: ${{ vars.REST_API_URL }} - run: echo "Using REST_API_URL=$REST_API_URL" - - - run: flutter pub get - - - run: dart run build_runner build -d - - - name: Check generated Dart policy - run: dart run tool/check_generated_dart_policy.dart - - - name: Build Web - env: - REST_API_URL: ${{ vars.REST_API_URL }} - run: | - flutter build web --release \ - --dart-define=ENV=staging \ - --dart-define=REST_API_URL=$REST_API_URL - - - uses: FirebaseExtended/action-hosting-deploy@v0 - with: - repoToken: ${{ secrets.GITHUB_TOKEN }} - firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_ONTIME_C63F1 }} - channelId: live - projectId: ontime-c63f1 - env: - FIREBASE_CLI_EXPERIMENTS: webframeworks diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml deleted file mode 100644 index 806035a3..00000000 --- a/.github/workflows/firebase-hosting-pull-request.yml +++ /dev/null @@ -1,57 +0,0 @@ -# This file was auto-generated by the Firebase CLI -# https://github.com/firebase/firebase-tools - -name: Deploy to Firebase Hosting on PR -on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] -permissions: - checks: write - contents: read - pull-requests: write -jobs: - build_and_preview: - if: "${{ github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.draft == false }}" - runs-on: ubuntu-latest - environment: debug - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-java@v1 - with: - java-version: "12.x" - - uses: subosito/flutter-action@v2 - with: - flutter-version: "3.44.4" - channel: "stable" - - - name: Use Environment Variables - env: - REST_API_URL: ${{ vars.REST_API_URL }} - run: echo "Using REST_API_URL=$REST_API_URL" - - - run: flutter --version - - - run: flutter pub get - - - run: dart run build_runner build -d - - - name: Check generated Dart policy - run: dart run tool/check_generated_dart_policy.dart - - - name: Build Web - env: - REST_API_URL: ${{ vars.REST_API_URL }} - run: | - flutter build web --release \ - --dart-define=ENV=dev \ - --dart-define=REST_API_URL=$REST_API_URL - - - uses: FirebaseExtended/action-hosting-deploy@v0 - with: - repoToken: "${{ secrets.GITHUB_TOKEN }}" - firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT_ONTIME_C63F1 }}" - projectId: ontime-c63f1 - channelId: pr-${{ github.event.pull_request.number }} - expires: 3d - env: - FIREBASE_CLI_EXPERIMENTS: webframeworks diff --git a/.github/workflows/flutter_test.yml b/.github/workflows/flutter_test.yml index b18a13b2..c1ccf182 100644 --- a/.github/workflows/flutter_test.yml +++ b/.github/workflows/flutter_test.yml @@ -21,8 +21,8 @@ jobs: flutter-version: "3.44.4" channel: stable cache: true - - name: install sql - run: sudo apt-get install sqlite3 libsqlite3-dev + - name: install native test dependencies + run: sudo apt-get install sqlite3 libsqlite3-dev libsodium-dev - name: Install packages run: flutter pub get - name: Check Flutter version @@ -31,6 +31,8 @@ jobs: run: dart run build_runner build --delete-conflicting-outputs - name: Check generated Dart policy run: dart run tool/check_generated_dart_policy.dart + - name: Check local-only product boundary + run: dart run tool/check_local_only_boundary.dart - name: Analyze run: flutter analyze - name: Run test with coverage diff --git a/CONTEXT.md b/CONTEXT.md index 530e5f75..ffecea8d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,13 +1,145 @@ # OnTime Front -This context defines product language for the OnTime Flutter app so analytics, -release, and feature discussions use the same terms. +This context defines product language for the local-only OnTime app so release +and feature discussions use the same terms. ## Language -**OnTime User**: -A person's OnTime account profile that owns schedules, places, preparation defaults, and notification preferences. -_Avoid_: User identity, analytics subject, device +**Local-only OnTime**: +The OnTime product whose active user data exists only in the current app installation and whose normal behavior never depends on a remote service. +_Avoid_: Offline-first, local cache mode, standalone mode + +**Local Profile**: +The single installation-bound, non-identifying profile that owns Schedules, Places, Preparations, outcomes, and preferences. +_Avoid_: Account, login, member, OnTime User + +**Schedule Outcome**: +The final local result of completing one Schedule as On Time, Late, or Abnormal. +_Avoid_: Server result, analytics event, transient completion screen + +**Schedule History**: +Past and completed Schedule details retained locally until the user explicitly deletes the Schedule. +_Avoid_: Server archive, analytics history, automatic retention window, score aggregate + +**Local Punctuality Score**: +The percentage of eligible Schedule Outcomes completed On Time since the latest Punctuality Score Reset. +_Avoid_: Server score, lifetime score, reward points, zero before first result + +**Punctuality Score Reset**: +A user action that starts a new Local Punctuality Score aggregation period without deleting Schedules or their outcomes. +_Avoid_: Local Data Reset, schedule deletion, account reset + +**OnTime Backup**: +A platform-neutral encrypted copy of local OnTime data that can move between Android and iOS and is created or restored only by an explicit user action. +_Avoid_: Sync, cloud backup, server backup, replication + +**Backup Password**: +A secret chosen during export and required to unlock that specific OnTime Backup. +_Avoid_: Account password, login password, device passcode, recovery code + +**Backup Cryptographic Suite**: +The versioned password-key-derivation and authenticated-encryption rules used by one OnTime Backup. +_Avoid_: Installation Data Key, database cipher, custom encryption, unspecified password protection + +**Backup Restore**: +An explicit user action that atomically replaces active local data with the contents of a validated OnTime Backup. +_Avoid_: Import, merge, synchronization, partial restore + +**Restore Preview**: +The post-validation summary and final confirmation shown before Backup Restore may replace active data. +_Avoid_: File picker, unverified metadata, automatic restore, progress screen + +**Durable OnTime Data**: +User content, outcomes, and app preferences expected to survive app restarts and Backup Restore. +_Avoid_: Cache, active timer, device permission, alarm registration + +**Local Data Store**: +The authoritative encrypted installation database that contains every item of Durable OnTime Data. +_Avoid_: Local cache, remote replica, preferences file, synchronization store + +**Reconstructible App State**: +Temporary, derived, or device-specific state that OnTime can rebuild without losing Durable OnTime Data. +_Avoid_: User content, durable preference, backup source, second source of truth + +**Recovery Mode**: +A restricted startup state used when OnTime cannot safely open or migrate the Local Data Store without risking data loss. +_Avoid_: Automatic reset, empty profile, guest mode, migration fallback + +**Backup Format Version**: +The declared version that identifies how an OnTime Backup must be decoded, validated, and migrated. +_Avoid_: App version, database version, release number + +**Backup Cutoff**: +The single committed Local Data Store point in time represented by one OnTime Backup. +_Avoid_: Export completion time, moving synchronization window, mixed read times + +**Backup Freshness**: +The local status comparing current Durable OnTime Data with the Backup Cutoff of the most recent successful export on this installation. +_Avoid_: File-existence guarantee, automatic backup status, restore status, cloud sync state + +**Local-only Transition**: +The release boundary where OnTime stops using remote identity and data and begins with installation-owned Local Profiles. +_Avoid_: Server migration, account migration, synchronization rollout + +**Legacy Installation State**: +The incomplete caches, credentials, delivery records, and runtime state left by a server-backed OnTime installation before the Local-only Transition. +_Avoid_: Durable OnTime Data, migration source, OnTime Backup, recoverable account data + +**Local-only Cutover Marker**: +The installation-local record that the one-way cleanup from server-backed OnTime has completed. +_Avoid_: Feature flag, server migration token, account state, Backup Format Version + +**Supported Product Platform**: +A mobile platform on which Local-only OnTime promises its complete data, notification, alarm, backup, and restore behavior. +_Avoid_: Build target, development preview, Widgetbook host + +**User-Initiated External Navigation**: +An explicit user choice to leave OnTime and open a public legal, support, email, or store destination in an operating-system app. +_Avoid_: API request, embedded WebView, automatic redirect, background fetch + +**Product Network Boundary**: +The release guarantee that Local-only OnTime cannot initiate a network request from its product runtime. +_Avoid_: Offline mode, unused server client, best-effort network avoidance, debug transport + +**Offline Cold Start**: +The release scenario where a clean Android or iOS installation completes first launch and all core product flows in airplane mode. +_Avoid_: Warm cache test, reconnect fallback, preloaded account, partial offline mode + +**Platform-Managed Data Transfer**: +An operating-system backup, restore, or device-transfer path initiated outside OnTime's explicit backup flow. +_Avoid_: OnTime Backup, Backup Restore, supported recovery + +**Installation Data Key**: +A randomly generated device-bound secret that unlocks Durable OnTime Data within one app installation. +_Avoid_: Backup Password, account secret, device passcode, recovery key + +**Schedule Notification Coverage**: +The nearest future Schedules currently armed for delivery within a Supported Product Platform's capacity. +_Avoid_: Seven-day window, server alarm window, synchronized delivery queue + +**Private Notification Content**: +The default lock-screen delivery text that identifies OnTime and the preparation prompt without revealing Schedule details. +_Avoid_: Empty notification, disabled delivery, encrypted notification + +**Detailed Notification Content**: +User-enabled lock-screen delivery text that may reveal a Schedule name and its differing Schedule Time Zone, but never its Place, note, or Preparation details. +_Avoid_: Default notification, full Schedule detail, unrestricted preview + +**Local Data Reset**: +A destructive user action that removes every OnTime-owned datum and delivery registration from the current installation. +_Avoid_: Account deletion, logout, cache clear, server deletion + +**Schedule Time Zone**: +The named time zone that fixes the intended civil date and time of one Schedule. +_Avoid_: Current device time zone, UTC offset, display locale + +**Nonexistent Schedule Time**: +A civil time that does not occur in the selected Schedule Time Zone because its clock moves forward. +_Avoid_: Invalid date, past time, automatically adjusted time + +**Ambiguous Schedule Time**: +A civil time that occurs twice in the selected Schedule Time Zone because its clock moves backward. +_Avoid_: Duplicate Schedule, repeated notification, unspecified offset **Schedule**: A planned commitment with an intended time, destination place, travel time, and optional preparation plan. @@ -25,42 +157,6 @@ _Avoid_: Reminder, notification, alarm One named action in a Preparation with an expected duration. _Avoid_: UI row, checklist widget, notification step -**Product Usage Event**: -A named record that a user performed a product-relevant action, excluding raw personal content. -_Avoid_: User activity, tracking event, raw interaction log - -**Analytics Purpose**: -The approved reason a Product Usage Event may be collected or used. -_Avoid_: Use case, tracking reason - -**Experiment**: -A pseudonymous feature or configuration comparison used for product improvement. -_Avoid_: Personalization, marketing campaign, user targeting - -**Deferred Analytics Purpose**: -An Analytics Purpose that is recognized but not active until a later privacy and consent review approves it. -_Avoid_: Future tracking, inactive use case - -**Pseudonymous Analytics Subject**: -The non-directly-identifying actor associated with a Product Usage Event. -_Avoid_: User identity, personal identity, email identity - -**Analytics Preference**: -The user's current choice about whether optional Product Usage Events may be collected. -_Avoid_: Tracking consent, privacy switch - -**Help Improve OnTime**: -The user-facing name for the Analytics Preference. -_Avoid_: Tracking, marketing analytics, personalization - -**Analytics Provider**: -An external service approved to receive Product Usage Events. -_Avoid_: Tracking vendor, analytics SDK - -**Workflow Milestone Event**: -A Product Usage Event that marks completion or failure of a meaningful user workflow step. -_Avoid_: Tap event, raw navigation log, interaction trace - **Schedule**: A planned commitment with a target time that OnTime helps the user prepare for. _Avoid_: Event, appointment, alarm @@ -73,18 +169,6 @@ _Avoid_: Preparation chain, task list One named action with an expected duration inside a Preparation. _Avoid_: Task, checklist item, alarm step -**Provider Authentication Completed**: -The state where the external Apple or Google account prompt has returned credentials to OnTime. -_Avoid_: Login completed, signed in, session ready - -**OnTime Session Established**: -The state where OnTime has accepted provider credentials, created an app session, and can route the user into the signed-in app experience. -_Avoid_: Provider login completed, credential received - -**Analytics Event Parameter**: -An allowlisted non-content value attached to a Product Usage Event. -_Avoid_: Event payload, arbitrary metadata, raw detail - **Schedule**: A planned commitment with a place, appointment time, travel time, optional buffer time, and preparation. _Avoid_: Event, appointment record @@ -203,29 +287,143 @@ _Avoid_: Loaded range, stream range, cached range ## Relationships -- A **Product Usage Event** may describe a **Schedule**, **Preparation**, notification, alarm, onboarding, or account action without storing the user's raw schedule names, notes, place names, credentials, tokens, or free text. +- **Local-only OnTime** keeps Schedules, Places, Preparations, and preferences within one app installation. +- **Local-only OnTime** may use device-provided notification and alarm capabilities without involving a remote service. +- **Local-only OnTime** has exactly one **Local Profile** per app installation. +- A **Local Profile** has no name, email address, social identity, credential, authentication token, or logout state. +- An **OnTime Backup** may exist outside the app installation in a location chosen and controlled by the user. +- **Local-only OnTime** does not automatically create, upload, download, or synchronize an **OnTime Backup**. +- An **OnTime Backup** must be unlocked before its contents can be restored or inspected. +- Every **OnTime Backup** is protected by the **Backup Password** chosen for that export. +- OnTime cannot retrieve, reset, or recover a forgotten **Backup Password**. +- OnTime does not remember a **Backup Password** after an export or restore attempt ends. +- Losing a **Backup Password** makes only that OnTime Backup unusable and does not alter active local data. +- A Backup Password contains 15 through 128 Unicode characters and remains within the backup format's bounded encoded size. +- A Backup Password accepts Unicode, spaces, and symbols without requiring mixtures of character classes; it remains case-sensitive and preserves leading and trailing spaces. +- Export requires the same Backup Password to be entered twice, while restore requires the complete password once. +- OnTime permits pasting a Backup Password but does not save it, autofill it, create a hint, or provide recovery. +- Every OnTime Backup declares one supported **Backup Cryptographic Suite** and fresh per-backup cryptographic material. +- A Backup Cryptographic Suite authenticates encrypted content, required plaintext header fields, chunk order, and stream completion; any tampering, reordering, or truncation invalidates the backup. +- OnTime rejects unsupported or unsafe cryptographic parameters before allocating attacker-controlled resources or changing active data. +- Backup cryptography uses a reviewed library implementation and never a custom cipher or password-key-derivation construction. +- A **Backup Restore** validates the complete OnTime Backup before changing active local data. +- A successful **Backup Restore** replaces rather than merges the current Local Profile data. +- A failed **Backup Restore** leaves the current Local Profile data unchanged. +- An **OnTime Backup** contains all **Durable OnTime Data**, including the Local Profile, onboarding state, Schedules, Places, Preparations, retained outcomes, and app preferences. +- An **OnTime Backup** excludes an active Preparation Run, Early Start Session, device identifier, scheduled-notification registry, operating-system permission, cache, and log. +- A successful **Backup Restore** recalculates Schedule Notifications from restored future Schedules instead of restoring device-specific registrations. +- **Durable OnTime Data** is encrypted while stored in the current app installation as well as when exported in an OnTime Backup. +- Every **OnTime Backup** declares exactly one **Backup Format Version** independently of the app and local database versions. +- An OnTime Backup exported on Android can be restored on iOS, and one exported on iOS can be restored on Android. +- An OnTime Backup represents Durable OnTime Data in a platform-neutral form rather than copying the encrypted Local Data Store file. +- An OnTime Backup declares its creation time, contained data categories and counts, encryption parameters, and integrity metadata. +- Backup Restore validates and migrates the complete backup in a staging Local Data Store before atomically replacing active data. +- Backup Restore rejects content that the destination platform or supported format cannot represent before changing active data. +- Backup Restore never carries over the source Installation Data Key, permissions, notification identifiers, or operating-system delivery registrations. +- Every OnTime Backup represents exactly one **Backup Cutoff** established from a consistent Local Data Store snapshot. +- Durable OnTime Data committed after the Backup Cutoff remains active but is excluded from that OnTime Backup. +- After OnTime captures the Backup Cutoff snapshot, the user may continue using the app while encryption and file writing finish. +- OnTime exposes a completed backup file only after encryption, integrity verification, and file writing all succeed; cancellation or failure does not leave a file presented as a valid OnTime Backup. +- The backup creation time shown to the user is the Backup Cutoff, not the later file-writing completion time. +- **Backup Freshness** is Never Exported, No Changes Since Export, or Unexported Changes based only on the latest successful export and later Durable OnTime Data revisions. +- OnTime records the latest successful export's Backup Cutoff but does not retain its destination, file permission, or Backup Password. +- Backup Restore does not change Backup Freshness to No Changes Since Export; only a successful export on the current installation does. +- When Durable OnTime Data remains unexported for 30 days, OnTime shows a non-blocking in-app backup reminder without scheduling a system notification or disabling another feature. +- For a Local Profile that has never exported, the 30-day period begins with its first Durable OnTime Data creation. +- A **Backup Restore** migrates every older released Backup Format Version forward before replacing active data. +- A **Backup Restore** rejects an unknown newer Backup Format Version before changing active data and directs the user to update OnTime. +- Backup Restore decrypts and completely validates an OnTime Backup before showing a **Restore Preview**. +- A Restore Preview shows the Backup Cutoff, source app version, contained data categories and counts, and that active local data will be replaced. +- Backup Restore requires explicit final confirmation from the Restore Preview before it may replace the Local Data Store. +- Cancelling a Restore Preview removes its staging data and leaves active data unchanged. +- Backup Cutoff, source app version, data categories, and counts remain encrypted inside an OnTime Backup; its plaintext header contains only values required to identify and decrypt the format. +- The **Local-only Transition** never signs in to or fetches data from the legacy OnTime server. +- A new or upgraded installation after the **Local-only Transition** creates a new Local Profile and encrypted Local Data Store through onboarding. +- The Local-only Transition does not reinterpret **Legacy Installation State** as Durable OnTime Data or attempt to reconstruct partial Schedules and Preparations from it. +- The Local-only Transition removes Legacy Installation State, including credentials, remote caches, delivery records, and active sessions, before onboarding and resumes that cleanup after interruption. +- Remote-only records and Legacy Installation State are not recoverable through Local-only OnTime; future installation transfer uses only an OnTime Backup. +- The **Local-only Cutover Marker** is written only after legacy cleanup succeeds and makes the Local-only Transition idempotent across later launches. +- The encrypted Local Data Store uses an identity distinct from every legacy database so a server-backed build cannot open or overwrite it. +- A product release contains only the local-only runtime; it does not ship a server/local feature flag, login fallback, or remote recovery path. +- Returning to a server-backed app version after the Local-only Cutover Marker is not a supported operation. +- Failure after cutover enters Recovery Mode and never reactivates the legacy server runtime. +- Android and iOS are the only **Supported Product Platforms**. +- Web and desktop builds are not **Supported Product Platforms**; a Web build may exist only for development and visual verification. +- Privacy information required for normal use is available within OnTime without network access. +- **User-Initiated External Navigation** is the only product action that may open a network-capable external app. +- **User-Initiated External Navigation** must not include Local Profile data, Schedule identifiers, installation identifiers, or other user data in its destination. +- Every Android and iOS release satisfies the **Product Network Boundary** without relying on runtime connectivity checks or an offline toggle. +- The Product Network Boundary excludes User-Initiated External Navigation because the operating-system destination, not OnTime, performs any resulting network access. +- Debug-only transport used by Flutter development tooling is not product behavior and must not be present in a release artifact. +- Every Android and iOS release passes an **Offline Cold Start** from a clean installation. +- Offline Cold Start covers onboarding, Local Profile creation, Schedule, Place, and Preparation management, calendar and home views, Local Punctuality Score, local delivery settings, OnTime Backup, Backup Restore, and bundled legal information. +- Images, fonts, localizations, time-zone rules, legal text, and every other resource required by Offline Cold Start ship inside the release artifact. +- Failure to complete User-Initiated External Navigation while offline leaves OnTime data and navigation state valid and explains that the external destination is unavailable. +- **Platform-Managed Data Transfer** is not a supported OnTime backup or recovery path. +- OnTime excludes its active data from **Platform-Managed Data Transfer** wherever the Supported Product Platform exposes such control. +- OnTime does not promise that every operating system or device manufacturer will honor the requested exclusion. +- Each app installation has exactly one **Installation Data Key** stored only in device-bound secure storage. +- The **Installation Data Key** is not synchronized, backed up, exported, or included in an OnTime Backup. +- OnTime uses the **Installation Data Key** without requiring a Backup Password or biometric prompt during normal app use. +- Losing the **Installation Data Key** makes active Durable OnTime Data unreadable; recovery requires a readable OnTime Backup or a destructive local-data reset. +- Local-only OnTime does not add an app-specific PIN or biometric lock; access control remains the responsibility of the Supported Product Platform's device lock. +- Creating or changing a future Schedule immediately attempts to include it in **Schedule Notification Coverage**, regardless of how far away it is. +- **Schedule Notification Coverage** prioritizes the nearest eligible future Schedules when platform capacity is limited. +- OnTime recalculates **Schedule Notification Coverage** after app launch or resume, Schedule mutation, Backup Restore, reboot, time or time-zone change, and relevant permission change. +- A future Schedule beyond current platform capacity is reconsidered during the next recalculation rather than being promised immediate delivery. +- **Private Notification Content** is the default for every new Local Profile. +- Private Notification Content does not reveal a Schedule name, Place, note, Preparation name, or Preparation Step on the lock screen. +- The user may explicitly enable **Detailed Notification Content**, which may reveal only the Schedule name and a differing Schedule Time Zone. +- Opening either notification mode routes to full Schedule information only through operating-system device access control. +- The selected notification-content mode is a durable preference included in OnTime Backup. +- A **Local Data Reset** removes the Local Profile, all Durable OnTime Data, active sessions, device-specific state, scheduled notifications and alarms, the encrypted database, and the Installation Data Key. +- A **Local Data Reset** does not and cannot remove an OnTime Backup previously exported outside the app installation. +- An interrupted **Local Data Reset** resumes cleanup on the next launch before OnTime permits creation of a new Local Profile. +- Every Schedule has exactly one **Schedule Time Zone** captured when the Schedule is created or explicitly changed. +- A new Schedule defaults to the current named device time zone, and its creation and edit flows allow the user to select another Schedule Time Zone. +- Changing the device time zone does not change a Schedule's intended civil date and time in its **Schedule Time Zone**. +- Schedule Notification timing is recalculated from the Schedule Time Zone after a device time-zone change. +- An OnTime Backup preserves each Schedule's intended civil date, time, and **Schedule Time Zone**. +- A Schedule displays its intended civil date, time, and **Schedule Time Zone** as the primary commitment time. +- When the device time zone differs, the Schedule also displays the equivalent current-device date and time; when they match, it does not duplicate the time. +- A Schedule Notification using Detailed Notification Content identifies the Schedule Time Zone when it differs from the current device time zone. +- OnTime rejects a **Nonexistent Schedule Time** and identifies the next valid civil time without selecting it automatically. +- An **Ambiguous Schedule Time** requires the user to choose one of the two represented offsets before saving. +- A Schedule and its OnTime Backup preserve the user's chosen occurrence of an **Ambiguous Schedule Time**. +- Time-zone rules are updated only through an OnTime app release, not through a runtime network request. +- After a time-zone rule update, a future Schedule keeps its intended civil date, time, and Schedule Time Zone while OnTime recalculates its absolute instant and Schedule Notification. +- OnTime identifies future Schedules whose absolute notification time changed because of a time-zone rule update; completed and past Schedules remain unchanged. +- The **Local Data Store** is the only authoritative persistence boundary for Durable OnTime Data. +- All durable preferences belong to the Local Data Store together with the Local Profile and user content. +- Storage outside the Local Data Store may contain only the Installation Data Key or **Reconstructible App State** and must not become a second source of Durable OnTime Data. +- OnTime Backup, Backup Restore, and Local Data Reset operate against the Local Data Store as one consistent data boundary. +- A Local Data Store schema migration is atomic: failure leaves the previously readable database state unchanged. +- OnTime enters **Recovery Mode** instead of automatically deleting or recreating a Local Data Store that cannot be opened or migrated. +- Recovery Mode preserves the Local Data Store and Installation Data Key until a Backup Restore succeeds or the user explicitly performs Local Data Reset. +- Recovery Mode permits only retrying startup, Backup Restore, and Local Data Reset; normal product screens and background Schedule Notification processing remain unavailable. +- Only On Time and Late Schedule Outcomes are eligible for the **Local Punctuality Score**; an Abnormal outcome is excluded. +- The Local Punctuality Score equals the On Time eligible outcome count divided by all eligible outcomes since the latest **Punctuality Score Reset**, multiplied by 100. +- With no eligible outcome since the latest Punctuality Score Reset, the Local Punctuality Score is not yet calculated rather than zero. +- Completing a Schedule and registering its eligible score contribution form one atomic local operation, and one Schedule contributes at most once. +- Deleting a completed Schedule does not retroactively change a Local Punctuality Score contribution already registered. +- A Punctuality Score Reset does not delete Schedules or Schedule Outcomes; Local Data Reset removes the complete score history. +- An OnTime Backup preserves the Local Punctuality Score aggregation basis and its latest reset boundary. +- **Schedule History** has no age-based or storage-based automatic expiration. +- Deleting a Schedule removes its name, Place, note, Preparation, Schedule Outcome detail, delivery registrations, and appearance in current backup data. +- After a completed Schedule is deleted, only its non-identifying On Time or Late aggregate contribution may remain for Local Punctuality Score continuity. +- Restoring an OnTime Backup may reintroduce a Schedule deleted after that backup's Backup Cutoff, and Restore Preview warns about that replacement effect. - A **Schedule** has one effective **Preparation** for calculating preparation timing. - A **Default Preparation** may seed a new **Schedule** before the user chooses a different preparation. - A **Custom Preparation** belongs to one **Schedule**. -- **Preparation Mode** may appear as an **Analytics Event Parameter** without exposing preparation step names. - A **Monthly Calendar** displays **Schedules** grouped by calendar day. - A **Calendar Month Range** starts at the first day of its first month and ends before the first day of the month after its last month. - A **Monthly Calendar** may extend a **Calendar Month Range** when the user moves to an adjacent month. -- An **OnTime User** may own zero or more **Schedules**. +- A **Local Profile** may own zero or more **Schedules**. - A **Schedule** has one **Place**. - A **Schedule** may use one **Preparation**. - A **Preparation** contains zero or more **Preparation Steps** in user-defined order. - A **Preparation Step** belongs to exactly one **Preparation**. - A **Schedule Notification** uses **Schedule** and **Preparation** timing, but is not itself a **Schedule** or **Preparation**. -- First-release **Product Usage Events** are **Workflow Milestone Events**, not every tap or raw navigation step. -- First-release **Workflow Milestone Events** cover analytics preference, onboarding, authentication, schedule, notification permission, alarm, and schedule-finish outcomes. -- **Provider Authentication Completed** precedes **OnTime Session Established** during Apple or Google sign-in. -- **Provider Authentication Completed** does not mean the user is signed in to OnTime. -- The signed-in app experience begins only after **OnTime Session Established**. -- A **Product Usage Event** may include **Analytics Event Parameters** such as workflow, result, stable error category, coarse count, coarse duration, platform, or app version. -- An **Analytics Event Parameter** must not contain user-authored text, direct identifiers, tokens, raw exception strings, request bodies, or response bodies. -- A **Product Usage Event** uses a stable snake_case name and includes a schema version. -- A changed **Product Usage Event** meaning requires a new event name or schema version. - A **Schedule** may have one **Preparation** for that specific commitment. - A **Preparation** contains zero or more **Preparation Steps** in user-defined order. - A user's default **Preparation** may be applied to a **Schedule** and then changed for that Schedule. @@ -262,46 +460,154 @@ _Avoid_: Loaded range, stream range, cached range - The current step and completion state of a **Preparation Run** are derived from its starting action, user-performed **Preparation Action Events**, and the current time. - Automatic step transitions are derived states, not **Preparation Action Events**. - A **Preparation Run** must not outlive the scheduled commitment it belongs to. -- Active first-release **Analytics Purposes** are product improvement, debugging and operations, and experimentation. -- A first-release **Experiment** must not be used for marketing targeting, sensitive segmentation, or personalized treatment. -- Marketing and personalization are **Deferred Analytics Purposes**. -- A **Product Usage Event** belongs to a **Pseudonymous Analytics Subject**, using an internal user or analytics identifier for signed-in use and an installation identifier before sign-in. -- A **Pseudonymous Analytics Subject** must not be an email address, display name, OAuth identifier, FCM token, or raw personal content value. -- The first-release **Analytics Preference** is opt-out for active Analytics Purposes. -- A disabled **Analytics Preference** stops future optional Product Usage Events. -- Before sign-in, the **Analytics Preference** is installation-scoped. -- After sign-in, the **Analytics Preference** is account-scoped and should apply across the user's signed-in devices. -- Before sign-in, a **Product Usage Event** may be associated only with an installation-scoped **Pseudonymous Analytics Subject**. -- After sign-in, future **Product Usage Events** may be associated with a signed-in **Pseudonymous Analytics Subject**. -- After sign-out, future **Product Usage Events** return to an installation-scoped **Pseudonymous Analytics Subject**. -- Account deletion stops future user-linked **Product Usage Events** and may retain historical analytics only in aggregate or de-identified form. -- A first-release **Analytics Provider** may receive Product Usage Events only after privacy, Data Safety, retention, and deletion responsibilities are approved. - ## Example dialogue -> **Dev:** "Should the analytics event include the schedule note so we can understand why users are late?" -> **Domain expert:** "No. A **Product Usage Event** can say a schedule was finished late, but it must not include the user's raw note." -> > **Dev:** "If the user taps Start before the scheduled notification, is that a separate schedule run?" > **Domain expert:** "No - it is an **Early Start Session**, which is still the **Schedule Preparation Session** for that **Schedule**." > **Dev:** "Can a **Schedule Notification** replace the **Schedule** if the user taps it?" > **Domain expert:** "No. The **Schedule Notification** only prompts preparation for the **Schedule**; the **Schedule** remains the planned commitment." +> +> **Dev:** "Does a lock-screen notification reveal my Schedule name by default?" +> **Domain expert:** "No. Private Notification Content is the default; Schedule name and a differing time zone appear only after you enable Detailed Notification Content." +> +> **Dev:** "Will **Local-only OnTime** synchronize a Schedule after the device reconnects?" +> **Domain expert:** "No. Reconnection changes nothing because the Schedule belongs only to this app installation." +> +> **Dev:** "Can I move my Schedules to a new phone without an account?" +> **Domain expert:** "Yes. Export an **OnTime Backup** yourself, then explicitly restore it on the new installation." +> +> **Dev:** "Can that new phone use the other supported mobile platform?" +> **Domain expert:** "Yes. The OnTime Backup is platform-neutral and restores between Android and iOS after full validation." +> +> **Dev:** "If I edit a Schedule while its backup file is still being encrypted, is that edit inside the backup?" +> **Domain expert:** "Only if it committed before the Backup Cutoff. Later edits remain active and belong in the next backup." +> +> **Dev:** "Does a Fresh backup status prove that the exported file still exists?" +> **Domain expert:** "No. Backup Freshness records the last successful export boundary; the user remains responsible for the external file." +> +> **Dev:** "Can OnTime reset the **Backup Password** if I forget it?" +> **Domain expert:** "No. If the old installation still has the active data, create a new **OnTime Backup** with a new password." +> +> **Dev:** "Can a backup still restore if an encrypted chunk was removed or reordered?" +> **Domain expert:** "No. Its Backup Cryptographic Suite authenticates the complete ordered stream and rejects any altered or incomplete backup." +> +> **Dev:** "Does OnTime require an uppercase letter, number, and symbol in a Backup Password?" +> **Domain expert:** "No. It requires sufficient length, accepts Unicode and spaces, and preserves the complete case-sensitive password." +> +> **Dev:** "Will a **Backup Restore** combine my current Schedules with the backup?" +> **Domain expert:** "No. It validates the whole backup first and then replaces the current Local Profile data as one operation." +> +> **Dev:** "Can choosing a valid backup file immediately overwrite my current data?" +> **Domain expert:** "No. OnTime first shows a Restore Preview and replaces data only after your final confirmation." +> +> **Dev:** "Will restoring a backup resume the timer that was running on my old phone?" +> **Domain expert:** "No. Active sessions are not **Durable OnTime Data**; OnTime restores the Schedule and recalculates future Schedule Notifications." +> +> **Dev:** "Can the current app restore an OnTime Backup from an older release?" +> **Domain expert:** "Yes. Its **Backup Format Version** selects the required forward migrations before any local data is replaced." +> +> **Dev:** "Will the local-only release download my old server Schedules once?" +> **Domain expert:** "No. The **Local-only Transition** never contacts the legacy server and does not promote incomplete Legacy Installation State into user data." +> +> **Dev:** "If the new local database fails to start, can the app temporarily return to the server version?" +> **Domain expert:** "No. Cutover is one-way; startup failure enters Recovery Mode and never restores a server runtime." +> +> **Dev:** "What email address belongs to the **Local Profile**?" +> **Domain expert:** "None. It owns local data and preferences but does not identify the person using the installation." +> +> **Dev:** "Does a working Widgetbook Web build make Web a **Supported Product Platform**?" +> **Domain expert:** "No. Product support is limited to Android and iOS; Web is a development and visual-verification target." +> +> **Dev:** "Does opening the public privacy page mean OnTime is online?" +> **Domain expert:** "Only if the user explicitly chooses **User-Initiated External Navigation**; OnTime itself does not fetch or embed the page." +> +> **Dev:** "Can an unused Firebase or HTTP client remain in the release if no screen calls it?" +> **Domain expert:** "No. The Product Network Boundary excludes the capability itself, not only known requests." +> +> **Dev:** "Can the first launch require one successful connection to download legal text or time-zone data?" +> **Domain expert:** "No. Offline Cold Start requires every core resource to ship in the release artifact." +> +> **Dev:** "Can I rely on my phone's automatic backup instead of an **OnTime Backup**?" +> **Domain expert:** "No. **Platform-Managed Data Transfer** is excluded where possible and is not a supported recovery path." +> +> **Dev:** "Can the **Installation Data Key** unlock an OnTime Backup on my new phone?" +> **Domain expert:** "No. The installation key never leaves its device; a **Backup Password** unlocks the portable backup." +> +> **Dev:** "Is a Schedule one month away too far away to notify me?" +> **Domain expert:** "No. Distance is not the boundary; **Schedule Notification Coverage** includes the nearest future Schedules up to the platform's capacity." +> +> **Dev:** "Does **Local Data Reset** delete the OnTime Backup I saved in Files?" +> **Domain expert:** "No. It removes everything owned by this installation, but an exported backup remains under the user's control." +> +> **Dev:** "If I travel, does my 9:00 Seoul Schedule become 9:00 in the new device time zone?" +> **Domain expert:** "No. Its **Schedule Time Zone** keeps the commitment at 9:00 Seoul time until you explicitly edit it." +> +> **Dev:** "Why did my Seoul Schedule notify me on the previous afternoon in Los Angeles?" +> **Domain expert:** "The Schedule shows both its 9:00 Seoul commitment and the equivalent current-device time so the delivery is explainable." +> +> **Dev:** "Will OnTime silently move a Schedule out of a daylight-saving gap?" +> **Domain expert:** "No. A **Nonexistent Schedule Time** cannot be saved, and an **Ambiguous Schedule Time** requires an explicit occurrence choice." +> +> **Dev:** "What happens if a country changes its time-zone law after I create a Schedule?" +> **Domain expert:** "The future Schedule keeps its intended local time, OnTime recalculates the notification from the updated rules, and tells you if the absolute time changed." +> +> **Dev:** "Can a preference file or remote response contain newer Schedule data than the database?" +> **Domain expert:** "No. Durable OnTime Data has one authority: the Local Data Store. Everything else is either a device-bound key or reconstructible state." +> +> **Dev:** "Should a failed database migration start over with an empty profile?" +> **Domain expert:** "No. Enter Recovery Mode and preserve the data until restore succeeds or the user explicitly resets it." +> +> **Dev:** "Should an Abnormal completion or a deleted completed Schedule rewrite the punctuality percentage?" +> **Domain expert:** "No. Abnormal outcomes are never eligible, and deleting a Schedule does not erase a score contribution already registered." +> +> **Dev:** "Does preserving a deleted Schedule's score contribution also preserve its name or Place?" +> **Domain expert:** "No. Deletion removes the Schedule details and leaves only a non-identifying aggregate contribution." ## Flagged ambiguities -- "User" was overloaded as both the human actor and stored profile; resolved: use **OnTime User** for the account/profile concept and plain user only for the person performing an action. +- "Offline-only" could mean temporary disconnected operation with later synchronization; resolved: **Local-only OnTime** has no remote synchronization path. +- "Backup" could imply automatic cloud synchronization; resolved: an **OnTime Backup** is a user-initiated portable copy, not synchronization. +- "Portable backup" could mean only another device on the same platform; resolved: an OnTime Backup is a versioned platform-neutral representation that restores between Android and iOS. +- "Backup time" could mean either snapshot or file completion time; resolved: the **Backup Cutoff** is the single data snapshot time represented by the backup. +- "Backup is fresh" could imply that OnTime can still access the external file; resolved: **Backup Freshness** compares local revisions with the last successful export and does not guarantee file existence. +- "Backup file" could imply a readable export; resolved: every **OnTime Backup** is encrypted. +- "Encrypted backup" could mean confidentiality without tamper detection; resolved: the **Backup Cryptographic Suite** authenticates the header, ordered content, and complete stream. +- "Backup password recovery" could imply a server-held recovery path; resolved: a forgotten **Backup Password** cannot be retrieved or reset. +- "Remember backup password" could imply device or biometric retention; resolved: OnTime requires the **Backup Password** for every export and restore attempt. +- "Strong backup password" could imply mandatory character classes; resolved: Backup Password strength is length-based, permits Unicode and spaces, and imposes no composition rule. +- "Import" could imply merging selected records; resolved: a **Backup Restore** replaces all current Local Profile data and never performs a merge. +- "Restore confirmation" could mean confirming only the file picker; resolved: the user confirms again from a verified **Restore Preview** after seeing the backup summary and replacement impact. +- "All data" in a backup could include device-bound runtime state; resolved: an **OnTime Backup** contains **Durable OnTime Data** and excludes active sessions, permissions, registrations, caches, and logs. +- "Encrypt the backup" could imply that active app data remains readable at rest; resolved: **Durable OnTime Data** is encrypted both inside the installation and inside an **OnTime Backup**. +- "Backup version" could mean an app or database version; resolved: **Backup Format Version** is an independent compatibility contract. +- "Migration" could imply a final legacy-server import or salvage of local caches; resolved: the **Local-only Transition** fetches nothing, removes Legacy Installation State, and starts a new Local Profile. +- "Cutover flag" could imply a reversible local/server feature switch; resolved: the **Local-only Cutover Marker** records completed one-way cleanup and provides no server fallback. +- "Supported platform" could mean any platform Flutter can compile; resolved: a **Supported Product Platform** is Android or iOS only. +- "External link" could imply an in-app network request; resolved: **User-Initiated External Navigation** leaves OnTime through an operating-system app and carries no OnTime user data. +- "No server use" could mean merely avoiding known API calls; resolved: the **Product Network Boundary** removes network clients and verifies that release artifacts cannot initiate product network requests. +- "Works offline" could mean only after a connected warm-up; resolved: **Offline Cold Start** begins from a clean installation in airplane mode with no preloaded cache. +- "Device backup" could be mistaken for an OnTime recovery guarantee; resolved: **Platform-Managed Data Transfer** is excluded where possible and only an **OnTime Backup** is supported. +- "Encryption key" could mean either active-data or backup protection; resolved: an **Installation Data Key** protects active local data, while a **Backup Password** protects one portable OnTime Backup. +- "No login" could imply a replacement app lock; resolved: Local-only OnTime adds no app-specific PIN or biometric gate. +- "Profile" could imply a named account; resolved: a **Local Profile** contains no personal identity or authentication state. +- "Delete account" implied a remote identity and server-side deletion; resolved: **Local Data Reset** removes installation-owned data and has no server effect. +- "Schedule time" could mean a floating device-local clock value; resolved: it is fixed by the Schedule's intended civil date, time, and **Schedule Time Zone**. +- "Displayed Schedule time" could mean either commitment time or current-device time; resolved: commitment time is primary and the device equivalent is secondary only when zones differ. +- "Default time zone" could imply an unchangeable device setting; resolved: a new Schedule starts with the current device zone but the user may explicitly select another **Schedule Time Zone**. +- "Invalid DST time" could mean either a missing or repeated civil time; resolved: **Nonexistent Schedule Time** is rejected, while **Ambiguous Schedule Time** requires an explicit occurrence. +- "Time-zone update" could imply online rule synchronization; resolved: rules change only with an app release, and affected future Schedule notifications are recalculated locally. +- "Local storage" could imply several equally authoritative files and caches; resolved: only the Local Data Store owns Durable OnTime Data, while other storage is limited to the Installation Data Key or Reconstructible App State. +- "Migration fallback" could imply silently creating an empty database; resolved: an unreadable or failed migration enters Recovery Mode without deleting the existing Local Data Store or Installation Data Key. +- "Punctuality score" could imply a server-owned or lifetime value; resolved: the **Local Punctuality Score** uses only eligible outcomes since the latest Punctuality Score Reset and is uncalculated before the first eligible result. +- "Keep history" could imply hidden permanent retention after deletion; resolved: Schedule History lasts until explicit deletion, after which only a non-identifying punctuality aggregate may remain. +- "Alarm window" could imply a fixed server or seven-day range; resolved: **Schedule Notification Coverage** is capacity-based and prioritizes the nearest future Schedules. +- "User" was overloaded as both the human actor and stored profile; resolved: use **Local Profile** for the stored installation-bound concept and plain user only for the person performing an action. +- "Account", "login", "logout", and "member withdrawal" implied server identity; resolved: **Local-only OnTime** has one **Local Profile**, and the destructive user action is a local-data reset. - "Schedule" was used near notification and alarm flows; resolved: a **Schedule** is the planned commitment, while notifications and alarms are delivery experiences for preparation timing. +- "Notification details" could imply that all Schedule fields are safe on the lock screen; resolved: Private Notification Content is the default, and Detailed Notification Content is opt-in and limited to Schedule name and a differing Schedule Time Zone. - "Preparation state" was ambiguous between step progress and display styling; resolved: **Preparation Step** progress belongs to preparation language, while visual labels should not redefine the domain. -- "User activities" was used broadly; resolved: the canonical term is **Product Usage Event**, and raw personal content is out of scope. -- "Analytics" was used to include all possible purposes; resolved: marketing and personalization are deferred, not first-release purposes. -- "User identity" for analytics was ambiguous; resolved: analytics uses a **Pseudonymous Analytics Subject**, not directly identifying user data. -- "Consent" was ambiguous for analytics; resolved: first-release analytics is opt-out with a user-visible **Analytics Preference**. -- "Third party" was ambiguous for analytics; resolved: the canonical term is **Analytics Provider**. -- "Event taxonomy" was broad; resolved: first-release analytics tracks **Workflow Milestone Events** only. -- "Event payload" was too open-ended; resolved: events use allowlisted **Analytics Event Parameters** only. - "Schedule preparation session" was implicit in code but not in the glossary; resolved: the canonical term is **Schedule Preparation Session**, with **Early Start Session** for sessions started before the **Preparation Start Moment**. -- "Login completed" was ambiguous for Apple and Google sign-in; resolved: external account prompt completion is **Provider Authentication Completed**, while usable OnTime sign-in is **OnTime Session Established**. - "Preparation" was ambiguous between the user's fallback steps and a schedule-specific edited set; resolved: use **Default Preparation** for the fallback and **Custom Preparation** for the schedule-specific version. - "Alarm permission" was ambiguous between **Exact Timing Permission** and notification permission; resolved: notification permission may enable a **Fallback Notification**, but does not mean **Exact Timing Permission** is granted. - "Pending" was ambiguous for notification status; resolved: the canonical state is **No Scheduled Notification** when notifications are enabled but no upcoming Schedule Notification is armed. diff --git a/README.md b/README.md index 82ace6f0..870e4b83 100644 --- a/README.md +++ b/README.md @@ -225,35 +225,28 @@ Firebase Hosting deploys `build/web` to project `ontime-c63f1`. Build a release APK: ```sh -flutter build apk --release \ - --dart-define=ENV=staging \ - --dart-define=REST_API_URL= +flutter build apk --release ``` Build an app bundle for Play Console upload: ```sh -flutter build appbundle --release \ - --dart-define=ENV=prod \ - --dart-define=REST_API_URL= +flutter build appbundle --release ``` -Before production upload, confirm the release `google-services.json`, signing configuration, version name, and version code. The current Gradle release block uses the debug signing config as a local-build fallback, so production signing must be configured before store release. +Before production upload, confirm signing configuration, version name, version code, and that `dart run tool/check_local_only_boundary.dart` passes. Product builds do not use Firebase configuration or API endpoint defines. ### iOS Build locally: ```sh -flutter build ios --release \ - --dart-define=ENV=prod \ - --dart-define=REST_API_URL= \ - --dart-define=GOOGLE_RESERVED_CLIENT_ID_IOS= +flutter build ipa --release --export-method app-store ``` -Create an App Store archive from Xcode or CI with the same Dart defines. See [docs/iOS-Release-Configuration.md](docs/iOS-Release-Configuration.md) for the release-only validation flow. +See [docs/iOS-Release-Configuration.md](docs/iOS-Release-Configuration.md) for the local-only release validation flow. -Before production upload, confirm `ios/Runner/GoogleService-Info.plist`, Apple signing, bundle id, app capabilities, push notification entitlement, build number, and App Store Connect metadata. +Before production upload, confirm Apple signing, bundle ID, local-notification and AlarmKit capabilities, build number, encryption disclosure, and App Store Connect metadata. The release must not contain Firebase, push-notification, remote-authentication, or server configuration. ## Widgetbook diff --git a/android/app/build.gradle b/android/app/build.gradle index 4017c562..87128909 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -6,88 +6,6 @@ plugins { } def androidApplicationId = "club.devkor.ontime" -def isReleaseBuild = gradle.startParameter.taskNames.any { - it.toLowerCase().contains("release") -} -def googleServicesBuildType = isReleaseBuild ? "release" : "debug" -def googleServicesConfigCandidates = { buildType -> - [ - file("src/${buildType}/google-services.json"), - file("google-services.json"), - file("src/google-services.json"), - ] -} -def selectGoogleServicesConfigFile = { buildType -> - googleServicesConfigCandidates(buildType).find { it.exists() } -} -def releaseGoogleServicesConfigFile = selectGoogleServicesConfigFile("release") -def validateGoogleServicesPackageName = { File configFile -> - def parsedConfig - - try { - parsedConfig = new groovy.json.JsonSlurper().parse(configFile) - } catch (Exception exception) { - throw new GradleException( - "Unable to parse Android Firebase config at ${configFile}. " + - "Confirm ANDROID_GOOGLE_SERVICES_JSON_B64 decodes to valid google-services.json.", - exception - ) - } - - def matchingClient = (parsedConfig.client ?: []).find { client -> - client?.client_info?.android_client_info?.package_name == androidApplicationId - } - - if (!matchingClient) { - throw new GradleException( - "Android Firebase config at ${configFile} must include a client for package " + - "${androidApplicationId}. Update the release google-services.json source or " + - "ANDROID_GOOGLE_SERVICES_JSON_B64 secret before building a release." - ) - } -} - -if (isReleaseBuild) { - if (!releaseGoogleServicesConfigFile) { - throw new GradleException( - "Android release builds require Firebase config. " + - "Provide android/app/src/release/google-services.json, usually by " + - "decoding the ANDROID_GOOGLE_SERVICES_JSON_B64 CI secret before " + - "running a release build." - ) - } - - validateGoogleServicesPackageName(releaseGoogleServicesConfigFile) -} - -def googleServicesConfigFiles = googleServicesConfigCandidates(googleServicesBuildType) - -if (googleServicesConfigFiles.any { it.exists() }) { - apply plugin: "com.google.gms.google-services" -} else { - logger.lifecycle("google-services.json not found; skipping Google Services plugin for this local build.") -} - -tasks.register("validateAndroidGoogleServices") { - group = "verification" - description = "Validates Android release google-services.json exists and matches ${androidApplicationId}." - - doLast { - def configFile = selectGoogleServicesConfigFile("release") - - if (!configFile) { - throw new GradleException( - "Android release Firebase config is missing. Provide " + - "android/app/src/release/google-services.json or decode " + - "ANDROID_GOOGLE_SERVICES_JSON_B64 before running this task." - ) - } - - validateGoogleServicesPackageName(configFile) - logger.lifecycle("Validated Android release Firebase config: ${configFile}") - } -} - def isReleaseTask = gradle.startParameter.taskNames.any { it.toLowerCase().contains("release") } def releaseSigningProperties = new Properties() def releaseSigningPropertiesFile = rootProject.file("key.properties") diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1fdfbc7a..cecb5844 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,9 @@ requestExactAlarmPermission(result) "scheduleNativeAlarm" -> scheduleNativeAlarm(call, result) "cancelNativeAlarm" -> cancelNativeAlarm(call, result) + "getLocalTimeZone" -> result.success(TimeZone.getDefault().id) + "excludeFromBackup" -> result.success(null) "getLaunchPayload" -> { NativeLog.d(TAG, "getLaunchPayload -> ${NativeLog.summarizeMap(launchPayload)}") result.success(launchPayload) diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 00000000..ce0b3243 --- /dev/null +++ b/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..224515d8 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/android/settings.gradle b/android/settings.gradle index 2ffd17cd..60dcd4a1 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -19,9 +19,6 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" version "8.6.0" apply false - // START: FlutterFire Configuration - id "com.google.gms.google-services" version "4.4.2" apply false - // END: FlutterFire Configuration id "org.jetbrains.kotlin.android" version "2.2.0" apply false } diff --git a/docs/Release-Checklist.md b/docs/Release-Checklist.md index e8cd6f13..deeeb3c3 100644 --- a/docs/Release-Checklist.md +++ b/docs/Release-Checklist.md @@ -30,16 +30,18 @@ release candidate: ```sh flutter pub get dart run build_runner build --delete-conflicting-outputs +dart run tool/check_local_only_boundary.dart flutter analyze flutter test flutter build appbundle --release +flutter build ipa --release --export-method app-store ``` - After code generation, run `git diff --exit-code` or review the diff to confirm generated files are intentionally updated. -- For local Android release builds, provide the signing, Firebase, `ENV`, and - `REST_API_URL` inputs documented in `docs/Android-Release-Configuration.md` - and `docs/Android-Release-Signing.md`. +- For local Android release builds, provide only the signing inputs documented + in `docs/Android-Release-Signing.md`; product builds must not require Firebase + or API environment configuration. - For CI Play uploads, dispatch the `Android Play Internal Deploy` workflow from `main` with an explicit `android_version_code`; it runs package install, code generation, generated-file drift checking, analysis, tests, AAB build, @@ -74,9 +76,9 @@ flutter build appbundle --release output. - Keep generated Dart outputs in the same PR as the source change that requires them. -- Do not commit local release secrets, generated Firebase config files, - keystores, `android/key.properties`, Play service-account JSON, coverage - output, or build artifacts. +- Do not commit local release secrets, keystores, provisioning profiles, + `android/key.properties`, Play or App Store credentials, coverage output, or + build artifacts. - Review `pubspec.lock`, native lockfiles, and generated platform files when dependencies or plugins change. @@ -86,10 +88,8 @@ flutter build appbundle --release `hotfix/x.y.z` for urgent production repairs. - Confirm final production deployments are triggered from a `vX.Y.Z` tag, not directly from `main` or a release branch. -- Use `ENV=staging` for release-candidate QA builds. -- Use `ENV=prod` for tagged production builds. -- Confirm `REST_API_URL` points at the approved release or production API - environment. +- Confirm the release candidate does not accept API endpoint, remote-auth, + Firebase, or environment-specific product configuration. - Require manual approval before promoting tagged builds to Play Store or App Store production. @@ -144,8 +144,8 @@ flutter build appbundle --release - Review the app icon set in `ios/Runner/Assets.xcassets/AppIcon.appiconset`. - Confirm the launch screen, supported orientations, background modes, entitlements, and AlarmKit capability declarations match the release target. -- Pass `GOOGLE_RESERVED_CLIENT_ID_IOS` for release/archive builds as documented - in `docs/iOS-Release-Configuration.md`. +- Run the local-only boundary check and build the signed IPA as documented in + `docs/iOS-Release-Configuration.md`. ## Web diff --git a/docs/adr/0001-use-firebase-analytics-for-product-usage-events.md b/docs/adr/0001-use-firebase-analytics-for-product-usage-events.md index 91377ada..f9946c0b 100644 --- a/docs/adr/0001-use-firebase-analytics-for-product-usage-events.md +++ b/docs/adr/0001-use-firebase-analytics-for-product-usage-events.md @@ -1,3 +1,7 @@ +--- +status: superseded by ADR-0011 +--- + # Use Firebase Analytics for Product Usage Events The first third-party Analytics Provider for Product Usage Events will be Firebase Analytics because OnTime already depends on Firebase for core app and messaging behavior, making the provider review smaller than introducing a separate analytics vendor. This choice still requires privacy policy, Google Play Data Safety, retention, deletion, and opt-out behavior to be reviewed before release because Product Usage Events will be sent to an external analytics provider. diff --git a/docs/adr/0002-track-analytics-from-feature-blocs.md b/docs/adr/0002-track-analytics-from-feature-blocs.md index 20a726a5..96f6d503 100644 --- a/docs/adr/0002-track-analytics-from-feature-blocs.md +++ b/docs/adr/0002-track-analytics-from-feature-blocs.md @@ -1,3 +1,7 @@ +--- +status: superseded by ADR-0011 +--- + # Track Analytics from Feature BLoCs Workflow Milestone Events will be emitted from the feature BLoCs or Cubits that own the completed workflow, using an injected tracking use case. This keeps analytics tied to domain outcomes instead of raw UI interactions, avoids a global BlocObserver that could accidentally observe sensitive form state, and keeps event emission testable without depending on widget navigation. diff --git a/docs/adr/0003-retain-deidentified-historical-analytics-after-account-deletion.md b/docs/adr/0003-retain-deidentified-historical-analytics-after-account-deletion.md index a79f7780..bf43933f 100644 --- a/docs/adr/0003-retain-deidentified-historical-analytics-after-account-deletion.md +++ b/docs/adr/0003-retain-deidentified-historical-analytics-after-account-deletion.md @@ -1,3 +1,7 @@ +--- +status: superseded by ADR-0011 +--- + # Retain De-Identified Historical Analytics After Account Deletion When an account is deleted, OnTime will stop future user-linked Product Usage Events and clear the analytics user association, but historical Firebase Analytics data may be retained only in aggregate or de-identified form. This avoids promising database-style cascade deletion for provider-managed analytics exports while preserving product improvement, debugging and operations, and experimentation value. diff --git a/docs/adr/0004-disable-analytics-outside-production-by-default.md b/docs/adr/0004-disable-analytics-outside-production-by-default.md index 7f2b94c8..fa939644 100644 --- a/docs/adr/0004-disable-analytics-outside-production-by-default.md +++ b/docs/adr/0004-disable-analytics-outside-production-by-default.md @@ -1,3 +1,7 @@ +--- +status: superseded by ADR-0011 +--- + # Disable Analytics Outside Production By Default Product Usage Events will be sent to the Analytics Provider only for production builds by default, with any development or staging collection requiring an explicit override. This prevents local development, tests, Widgetbook, and manual QA from polluting production funnels, debugging signals, and experiment data. diff --git a/docs/adr/0005-avoid-automatic-screen-tracking-for-first-release.md b/docs/adr/0005-avoid-automatic-screen-tracking-for-first-release.md index f44ab4b6..5e2283d1 100644 --- a/docs/adr/0005-avoid-automatic-screen-tracking-for-first-release.md +++ b/docs/adr/0005-avoid-automatic-screen-tracking-for-first-release.md @@ -1,3 +1,7 @@ +--- +status: superseded by ADR-0011 +--- + # Avoid Automatic Screen Tracking for First Release The first analytics release will not enable automatic screen-view tracking. OnTime will track explicit Workflow Milestone Events from feature BLoCs and Cubits instead, keeping the event stream focused and reducing the chance that route parameters or alarm navigation context create noisy or sensitive analytics. diff --git a/docs/adr/0006-sync-analytics-preference-across-signed-in-devices.md b/docs/adr/0006-sync-analytics-preference-across-signed-in-devices.md index 6141b0e9..abf35c0b 100644 --- a/docs/adr/0006-sync-analytics-preference-across-signed-in-devices.md +++ b/docs/adr/0006-sync-analytics-preference-across-signed-in-devices.md @@ -1,3 +1,7 @@ +--- +status: superseded by ADR-0011 +--- + # Sync Analytics Preference Across Signed-In Devices The Analytics Preference is installation-scoped before sign-in and account-scoped after sign-in, so a signed-in user's opt-out should apply across their devices. This requires a backend-supported account preference rather than relying only on local app storage, because optional analytics must stop consistently once the user disables Help Improve OnTime. diff --git a/docs/adr/0007-defer-remote-config-until-a-concrete-experiment.md b/docs/adr/0007-defer-remote-config-until-a-concrete-experiment.md index 38a79b9b..d2857d1e 100644 --- a/docs/adr/0007-defer-remote-config-until-a-concrete-experiment.md +++ b/docs/adr/0007-defer-remote-config-until-a-concrete-experiment.md @@ -1,3 +1,7 @@ +--- +status: superseded by ADR-0011 +--- + # Defer Remote Config Until a Concrete Experiment The first analytics release will add Firebase Analytics, Analytics Preference controls, and Workflow Milestone Events, but it will not add Firebase Remote Config. Remote Config should be introduced only when there is a concrete Experiment with defined variants, success metrics, rollout rules, and rollback behavior. diff --git a/docs/adr/0011-make-ontime-local-only.md b/docs/adr/0011-make-ontime-local-only.md new file mode 100644 index 00000000..cbc5db18 --- /dev/null +++ b/docs/adr/0011-make-ontime-local-only.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Make OnTime local-only + +OnTime will keep active user data within the current app installation and will not depend on an OnTime backend, social authentication, push messaging, or an analytics provider during normal app behavior. Each installation has one non-identifying Local Profile created through onboarding; names, email addresses, social identity, credentials, tokens, login, and logout are removed, while the former account-deletion action becomes a destructive local-data reset. Product Usage Events, experiments, and the Analytics Preference are removed rather than retained as device-only analytics; development diagnostics are not product analytics. Device-provided local notification and alarm capabilities remain in scope. A user may explicitly export or restore a portable OnTime Backup through an OS-managed file location, but OnTime will not automatically synchronize or communicate with a storage provider. Privacy information required for normal use will be bundled in the app; only an explicit user action may open a public legal, support, email, or store destination in an external operating-system app, without attaching OnTime user data. This trades cross-device synchronization, account recovery, remote push, and product analytics for deterministic offline operation, lower operating cost, and a smaller privacy boundary. diff --git a/docs/adr/0012-encrypt-backups-with-a-user-password.md b/docs/adr/0012-encrypt-backups-with-a-user-password.md new file mode 100644 index 00000000..608e4693 --- /dev/null +++ b/docs/adr/0012-encrypt-backups-with-a-user-password.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Encrypt backups with a user password + +Every OnTime Backup will be encrypted with a password chosen by the user during export and will require that password for cross-device restore. OnTime will retain the password only for the active export or restore attempt, will not save it in app storage or a device keychain, and will not offer biometric autofill. A device-bound key was rejected because it would prevent restoration on another installation, while an OnTime-managed recovery path was rejected because local-only OnTime has no account or server capable of recovering secrets. A forgotten password therefore makes that backup permanently unreadable without affecting active data or other backups. diff --git a/docs/adr/0013-replace-durable-data-on-backup-restore.md b/docs/adr/0013-replace-durable-data-on-backup-restore.md new file mode 100644 index 00000000..3ece5e3f --- /dev/null +++ b/docs/adr/0013-replace-durable-data-on-backup-restore.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Replace durable data on backup restore + +Backup Restore will validate a complete OnTime Backup and atomically replace the installation's current durable data instead of merging records. Backups include the Local Profile, onboarding state, schedules, places, preparations, retained outcomes, and app preferences, but exclude active preparation sessions, early-start state, device identifiers, operating-system permissions, scheduled-notification registrations, caches, and logs. After a successful restore, OnTime will derive and register notifications again from restored future schedules. This avoids synchronization-style conflict rules, duplicate alarms, and partially restored profiles at the cost of requiring users to create a safety backup before replacing existing data. diff --git a/docs/adr/0014-migrate-older-backups-forward.md b/docs/adr/0014-migrate-older-backups-forward.md new file mode 100644 index 00000000..6641cbf8 --- /dev/null +++ b/docs/adr/0014-migrate-older-backups-forward.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Migrate older backups forward + +Each OnTime Backup will declare a Backup Format Version independent of the app release and local database schema. Every current release must restore all older released backup formats by validating and migrating them forward before changing active data. A backup created by a newer unknown format will be rejected without mutation and the user will be told to update OnTime. This creates a long-lived maintenance obligation, but it preserves the usefulness of the user's only portable recovery artifact while allowing encryption and data schemas to evolve. diff --git a/docs/adr/0015-start-local-only-without-server-import.md b/docs/adr/0015-start-local-only-without-server-import.md new file mode 100644 index 00000000..1e68316a --- /dev/null +++ b/docs/adr/0015-start-local-only-without-server-import.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Start local-only without a server import + +The local-only release will not provide a one-time login or import from the legacy OnTime server. Both new and upgraded installations will create a new Local Profile and encrypted Local Data Store through onboarding. Existing Schedule persistence is remote-only, its unused local table is not an authoritative source, and local Preparation paths are incomplete; therefore the transition will not promote any legacy Drift rows or SharedPreferences values into Durable OnTime Data. It will idempotently remove Legacy Installation State, including credentials, remote caches, device delivery records, and active sessions, before onboarding, resuming cleanup if interrupted. Remote-only records and legacy local state will not carry forward or be described as recoverable; only OnTime Backups created by the local-only product will support future installation transfer. Attempting a best-effort salvage was rejected because partial caches could surface missing or incorrectly related Schedules and Preparations as trusted user data. diff --git a/docs/adr/0016-support-local-only-on-android-and-ios.md b/docs/adr/0016-support-local-only-on-android-and-ios.md new file mode 100644 index 00000000..5a08e36d --- /dev/null +++ b/docs/adr/0016-support-local-only-on-android-and-ios.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Support local-only OnTime on Android and iOS + +Android and iOS are the only supported product platforms for Local-only OnTime. Web may remain as a development and visual-verification target, including Widgetbook, but it will not be deployed as the OnTime product; macOS, Windows, and Linux are also outside product support. This narrows the current Flutter project's apparent multi-platform surface so OnTime can promise durable local storage, portable file backup, and mobile notification and alarm behavior without Web hosting or browser-storage reliability becoming product dependencies. diff --git a/docs/adr/0017-exclude-platform-managed-data-transfer.md b/docs/adr/0017-exclude-platform-managed-data-transfer.md new file mode 100644 index 00000000..062c5df3 --- /dev/null +++ b/docs/adr/0017-exclude-platform-managed-data-transfer.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Exclude platform-managed data transfer + +OnTime will request exclusion of its active data from Android cloud backup, Android device-transfer rules, and iOS system backup, making an encrypted user-created OnTime Backup the only supported portable recovery path. Platform-managed backup and transfer were rejected because they can move readable app state outside OnTime's explicit password-protected flow and make local-only behavior platform-dependent. The exclusion is best-effort because operating systems and device manufacturers may not honor every app request, so product and privacy documentation must not promise absolute prevention. diff --git a/docs/adr/0018-encrypt-active-data-with-a-device-bound-key.md b/docs/adr/0018-encrypt-active-data-with-a-device-bound-key.md new file mode 100644 index 00000000..b11eb6e0 --- /dev/null +++ b/docs/adr/0018-encrypt-active-data-with-a-device-bound-key.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Encrypt active data with a device-bound key + +Each installation will encrypt Durable OnTime Data with a randomly generated Installation Data Key held only in device-bound operating-system secure storage. The key will be used automatically during normal app operation and will not be synchronized, included in platform backup, exported, or placed in an OnTime Backup. Local-only OnTime will not add an app-specific PIN or biometric gate; access control remains the device lock's responsibility. This adds protection if the database file escapes the app container without turning every app launch, alarm, or notification route into an authentication flow. If the secure key is lost or corrupted, the active database is intentionally unrecoverable; the supported choices are restoring a password-protected OnTime Backup or performing a destructive local-data reset. diff --git a/docs/adr/0019-schedule-future-notifications-by-platform-capacity.md b/docs/adr/0019-schedule-future-notifications-by-platform-capacity.md new file mode 100644 index 00000000..bde00f93 --- /dev/null +++ b/docs/adr/0019-schedule-future-notifications-by-platform-capacity.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Schedule future notifications by platform capacity + +Local-only OnTime will remove the server-derived seven-day alarm window. Creating or changing a future schedule will immediately attempt to arm its preparation delivery regardless of distance, with the nearest eligible schedules taking priority when a platform limits pending delivery capacity. OnTime will reconcile from local durable data after launch or resume, schedule mutation, backup restore, reboot, clock or time-zone change, and relevant permission change. This improves long-range offline reliability while acknowledging that schedules beyond current platform capacity cannot be promised until a later reconciliation makes room. diff --git a/docs/adr/0020-reset-all-installation-owned-data.md b/docs/adr/0020-reset-all-installation-owned-data.md new file mode 100644 index 00000000..f466fb5b --- /dev/null +++ b/docs/adr/0020-reset-all-installation-owned-data.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Reset all installation-owned data + +The former account-deletion flow will become Local Data Reset: it removes the Local Profile, all durable records and preferences, active preparation and early-start state, device-specific registries, scheduled notifications and alarms, the encrypted database, and the Installation Data Key, then returns to onboarding. Exported OnTime Backups remain untouched because they are outside the installation's control. Reset progress must be recoverable so an interrupted operation finishes cleanup on the next launch before a new Local Profile can be created. This provides a truthful deletion boundary without pretending that a server account or externally stored backup can be deleted by the app. diff --git a/docs/adr/0021-anchor-schedules-to-a-named-time-zone.md b/docs/adr/0021-anchor-schedules-to-a-named-time-zone.md new file mode 100644 index 00000000..5fe7fcc9 --- /dev/null +++ b/docs/adr/0021-anchor-schedules-to-a-named-time-zone.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Anchor schedules to a named time zone + +Every Schedule will preserve its intended civil date and time together with a named Schedule Time Zone captured at creation or explicit edit. New schedules default to the current named device zone, while creation and edit flows allow an explicit alternative for future travel. A civil time skipped by an offset transition cannot be saved, while a repeated civil time requires the user to choose one of its represented offsets; that occurrence choice is preserved in the Schedule and backup. Device time-zone changes will trigger notification recalculation but will not move the commitment, and OnTime Backups will preserve the same zoned meaning across devices. Time-zone rules arrive only in app releases; after a rule update, future schedules retain their intended civil time while absolute instants and notifications are recalculated locally, with affected schedules disclosed to the user and past schedules left unchanged. The intended zoned time is the primary display; when the device is in another time zone, OnTime also shows the current-device equivalent and identifies the original zone in notification content. Treating schedules as floating device-local times was rejected because travel, daylight-saving changes, and cross-zone restore could silently change when a real commitment occurs. This adds a time-zone field and migration obligation but makes schedule and notification behavior deterministic and explainable offline. diff --git a/docs/adr/0022-use-encrypted-drift-as-the-local-data-source.md b/docs/adr/0022-use-encrypted-drift-as-the-local-data-source.md new file mode 100644 index 00000000..1c55937c --- /dev/null +++ b/docs/adr/0022-use-encrypted-drift-as-the-local-data-source.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Use encrypted Drift as the local data source + +Every item of Durable OnTime Data will use one encrypted Drift database as its authoritative Local Data Store. Domain repository contracts remain the application boundary, but their product implementations will use only local DAOs and database watch streams; remote data sources, remote models, Dio clients, authentication tokens, and server synchronization paths will be removed from the product runtime. Durable preferences will move into the same database so backup, restore, migration, and reset can operate on one consistent boundary. SharedPreferences or equivalent storage may hold only Reconstructible App State, while the Installation Data Key remains in device-bound secure storage and operating-system delivery registrations remain platform-owned projections rebuilt from the database. Keeping multiple persistence authorities was rejected because it permits partial backups, conflicting reads, and non-atomic restore or reset behavior. This decision increases the scope of the local schema and migrations but makes offline ownership explicit and testable. diff --git a/docs/adr/0023-preserve-local-data-when-migration-fails.md b/docs/adr/0023-preserve-local-data-when-migration-fails.md new file mode 100644 index 00000000..0fff785a --- /dev/null +++ b/docs/adr/0023-preserve-local-data-when-migration-fails.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Preserve local data when migration fails + +Local Data Store schema migrations will be atomic and will never fall back to silently deleting or recreating the database. If OnTime cannot open the encrypted store or complete a migration, it will preserve both the database and Installation Data Key and start in a restricted Recovery Mode rather than creating an empty Local Profile. Recovery Mode will not start normal product screens or background Schedule Notification processing and will offer only a startup retry, an OnTime Backup restore, or an explicit Local Data Reset. The preserved store and key may be removed only after a replacement restore succeeds or the user confirms Local Data Reset. Automatic destructive recovery was rejected because Local-only OnTime has no server from which lost records can be downloaded again. This requires a recovery startup path and transactional migration tests but makes upgrade failure non-destructive and user-controlled. diff --git a/docs/adr/0024-enforce-no-network-in-product-builds.md b/docs/adr/0024-enforce-no-network-in-product-builds.md new file mode 100644 index 00000000..c3e07cec --- /dev/null +++ b/docs/adr/0024-enforce-no-network-in-product-builds.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Enforce no network in product builds + +Android and iOS release artifacts will enforce a Product Network Boundary rather than relying only on code paths that currently happen not to call a server. Firebase Core, Messaging, and Analytics; Dio and direct HTTP clients; Google and Apple sign-in SDKs; authentication tokens; remote data sources; server URLs; and Firebase build configuration will be removed from the product runtime. The final merged Android release manifest will be verified not to contain INTERNET or ACCESS_NETWORK_STATE permission, while iOS and shared Dart code will use CI checks for prohibited dependencies, imports, and network APIs because iOS has no equivalent general outbound-network permission switch. Each release must also pass an Offline Cold Start from a clean installation in airplane mode across onboarding, Local Profile creation, all core data flows, local delivery settings, backup and restore, and legal information. Images, fonts, localizations, time-zone rules, legal text, and other required resources will ship in the artifact rather than requiring a first-run download. Fixed public legal, support, email, and store destinations may still be handed to an operating-system app through User-Initiated External Navigation without including OnTime data, and an unavailable external destination must fail without damaging app data or navigation state. Flutter debug transport may remain confined to development manifests and tooling but may not enter a release artifact. Keeping dormant network clients or connected-first resource loading was rejected because future code, transitive SDK behavior, or a cold installation could violate the local-only guarantee without an explicit architectural change. diff --git a/docs/adr/0025-calculate-punctuality-score-locally.md b/docs/adr/0025-calculate-punctuality-score-locally.md new file mode 100644 index 00000000..0579a5d3 --- /dev/null +++ b/docs/adr/0025-calculate-punctuality-score-locally.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Calculate punctuality score locally + +OnTime will preserve the existing punctuality calculation as a Local Punctuality Score owned by the Local Data Store. Only On Time and Late Schedule Outcomes are eligible, and the score is the On Time count divided by all eligible outcomes since the latest Punctuality Score Reset, multiplied by 100; Abnormal outcomes are excluded, and a period with no eligible outcome is represented as not yet calculated rather than zero. Completing a Schedule and registering its score contribution will be one atomic local transaction, with an idempotency constraint ensuring that one Schedule contributes at most once. A Punctuality Score Reset starts a new aggregation period without deleting Schedules or outcomes, and deleting a completed Schedule does not retroactively decrement an already registered contribution. The aggregation basis and reset boundary are Durable OnTime Data included in OnTime Backup, while Local Data Reset removes them. Replacing the score with a new heuristic or continuing to depend on the server was rejected because the established user-visible rule can be reproduced deterministically offline. diff --git a/docs/adr/0026-use-a-cross-platform-backup-format.md b/docs/adr/0026-use-a-cross-platform-backup-format.md new file mode 100644 index 00000000..778f0eb7 --- /dev/null +++ b/docs/adr/0026-use-a-cross-platform-backup-format.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Use a cross-platform backup format + +OnTime Backup will be a versioned, platform-neutral representation that can be exported on Android and restored on iOS or exported on iOS and restored on Android. It will not copy the encrypted Drift or SQLite database file because that would couple portability to an installation key, storage engine details, and platform configuration. The encrypted backup envelope will declare its Backup Format Version, creation time, contained data categories and counts, encryption parameters, and integrity metadata. Backup Restore will decrypt, validate, migrate, and materialize all content in a staging Local Data Store before atomically replacing active data; unsupported or unrepresentable content will be rejected before mutation. The source Installation Data Key, permissions, notification identifiers, and operating-system delivery registrations will not cross the boundary and will be regenerated or reconciled by the destination installation. Same-platform raw database copying was rejected because it would not satisfy the product's portable recovery guarantee. diff --git a/docs/adr/0027-export-a-point-in-time-backup-snapshot.md b/docs/adr/0027-export-a-point-in-time-backup-snapshot.md new file mode 100644 index 00000000..1601b8a2 --- /dev/null +++ b/docs/adr/0027-export-a-point-in-time-backup-snapshot.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Export a point-in-time backup snapshot + +Each OnTime Backup will represent one consistent Backup Cutoff captured from the Local Data Store. Every Durable OnTime Data change committed before that cutoff is included, while later commits remain active and are deferred to a later backup. OnTime may release the database snapshot after materializing a stable export input so the user can continue editing while password-based encryption and destination writing finish; the displayed creation time remains the Backup Cutoff rather than file completion time. A backup file will be exposed as complete only after encryption, integrity verification, and writing all succeed, and a cancelled or failed export will not leave an artifact presented as a valid OnTime Backup. Reading tables independently throughout a long export or blocking all app use until the destination write completes was rejected because the former can mix states and the latter is unnecessary once a stable snapshot exists. diff --git a/docs/adr/0028-require-a-verified-restore-preview.md b/docs/adr/0028-require-a-verified-restore-preview.md new file mode 100644 index 00000000..9182b176 --- /dev/null +++ b/docs/adr/0028-require-a-verified-restore-preview.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Require a verified restore preview + +Backup Restore will not replace active data immediately after file selection or password entry. OnTime will first decrypt the complete backup, authenticate its integrity, check its Backup Format Version, migrate it into a staging Local Data Store, and validate all represented data. Only then will it show a Restore Preview containing the Backup Cutoff, source app version, data categories and counts, and an explicit warning that the active Local Data Store will be replaced. Replacement requires a separate final user confirmation; cancellation removes staging data and leaves active data untouched. Preview metadata will remain inside the encrypted payload, while the plaintext envelope header will contain only the format identification and cryptographic parameters needed to derive a key and decrypt it. Immediate restore and plaintext descriptive metadata were rejected because they create avoidable overwrite and privacy risks. diff --git a/docs/adr/0010-derive-preparation-runs-from-action-events.md b/docs/adr/0029-derive-preparation-runs-from-action-events.md similarity index 100% rename from docs/adr/0010-derive-preparation-runs-from-action-events.md rename to docs/adr/0029-derive-preparation-runs-from-action-events.md diff --git a/docs/adr/0030-default-to-private-notification-content.md b/docs/adr/0030-default-to-private-notification-content.md new file mode 100644 index 00000000..5093d8d6 --- /dev/null +++ b/docs/adr/0030-default-to-private-notification-content.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Default to private notification content + +Every new Local Profile will use Private Notification Content for lock-screen Schedule Notification and alarm presentation. The default visible text will identify OnTime and the preparation prompt without revealing the Schedule name, Place, note, Preparation, or Preparation Step. A user may explicitly enable Detailed Notification Content, which may reveal only the Schedule name and, when different from the current device zone, its original Schedule Time Zone; Place, note, and preparation details remain excluded. Opening either mode will route to full information only through the operating system's device access control. The selected mode is a durable preference included in OnTime Backup. Always displaying the current Schedule title was rejected because platform notification storage and lock-screen presentation sit outside the encrypted Local Data Store, while removing all useful detail permanently was rejected in favor of an explicit user choice. diff --git a/docs/adr/0031-use-argon2id-and-authenticated-stream-encryption.md b/docs/adr/0031-use-argon2id-and-authenticated-stream-encryption.md new file mode 100644 index 00000000..758cdfdb --- /dev/null +++ b/docs/adr/0031-use-argon2id-and-authenticated-stream-encryption.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Use Argon2id and authenticated stream encryption + +OnTime Backup Format v1 will derive a 256-bit file key from the normalized Backup Password UTF-8 bytes defined by ADR-0032 using Argon2id 1.3 with a fresh 128-bit random salt, a 64 MiB memory limit, and three operations, following the memory-constrained recommendation in [RFC 9106](https://www.rfc-editor.org/rfc/rfc9106.html). It will encrypt the platform-neutral payload in bounded chunks using libsodium's [XChaCha20-Poly1305 secretstream](https://libsodium.gitbook.io/doc/secret-key_cryptography/secretstream), require an authenticated final tag, and reject missing, reordered, altered, or trailing chunks. The plaintext envelope will contain only a magic value, Backup Format Version, cryptographic-suite identifier, bounded KDF parameters, salt, and secretstream header, and those values will be bound to the ciphertext as authenticated data. Restore will validate algorithm identifiers and safe parameter limits before attacker-controlled allocation, will never downgrade to an unrecognized or weaker suite, and will use published test vectors across Android and iOS. Suite identifiers and stored parameters permit a future Backup Format Version to introduce stronger algorithms while retaining explicit readers for released formats. Custom cryptographic primitives, unauthenticated encryption, and whole-file buffering were rejected in favor of a reviewed library, authenticated truncation detection, and bounded memory use. diff --git a/docs/adr/0032-normalize-and-bound-backup-passwords.md b/docs/adr/0032-normalize-and-bound-backup-passwords.md new file mode 100644 index 00000000..fbfac075 --- /dev/null +++ b/docs/adr/0032-normalize-and-bound-backup-passwords.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Normalize and bound backup passwords + +Before Argon2id key derivation, OnTime will normalize a Backup Password to Unicode NFC and encode the resulting string as UTF-8 identically on Android and iOS. The normalized password must contain 15 through 128 Unicode code points and no more than 1,024 UTF-8 bytes. Unicode characters, ASCII spaces, symbols, and paste input are accepted; case, repeated spaces, and leading or trailing spaces are preserved, and no uppercase, digit, symbol, or other composition rule is imposed. Export requires two matching prepared entries, while restore processes the complete entry once. The app will not persist, autofill, hint, recover, or log the password and will clear native password and derived-key buffers immediately after the operation, with best-effort lifetime minimization for managed strings that cannot be deterministically wiped. NFC provides stable cross-platform treatment for canonically equivalent Unicode input as recommended by [RFC 8265](https://www.rfc-editor.org/rfc/rfc8265.html) and [NIST SP 800-63B-4](https://pages.nist.gov/800-63-4/sp800-63b.html). Trimming, lossy character mapping, short-password acceptance, and mandatory character classes were rejected because they either make equivalent cross-platform input unreliable, weaken offline-file protection, or reduce passphrase usability. diff --git a/docs/adr/0033-make-the-local-only-cutover-one-way.md b/docs/adr/0033-make-the-local-only-cutover-one-way.md new file mode 100644 index 00000000..be18f771 --- /dev/null +++ b/docs/adr/0033-make-the-local-only-cutover-one-way.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Make the local-only cutover one-way + +The product release that introduces Local-only OnTime will perform a one-way, idempotent cutover rather than ship dual server and local modes. It will remove Legacy Installation State, create a Local-only Cutover Marker only after cleanup succeeds, and then create a new Local Profile and encrypted Local Data Store. The encrypted store will use a file name and storage identity distinct from the legacy unencrypted `my_database` so a server-backed build cannot parse, migrate, or overwrite local-only data. Subsequent startup failure will enter Recovery Mode and will never reactivate login, remote data sources, or a server recovery path. Development may sequence the refactor internally, but the released artifact will contain no server/local feature flag or remote fallback, and app downgrade to a server-backed version after cutover is unsupported. Reusing the legacy database or retaining a reversible mode switch was rejected because older schema code and dormant network paths could corrupt the new store or silently break the Product Network Boundary. diff --git a/docs/adr/0034-retain-schedule-history-until-user-deletion.md b/docs/adr/0034-retain-schedule-history-until-user-deletion.md new file mode 100644 index 00000000..c6c9e08c --- /dev/null +++ b/docs/adr/0034-retain-schedule-history-until-user-deletion.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Retain schedule history until user deletion + +Local-only OnTime will not expire Schedule History, Preparation templates, or Schedule Outcome details automatically because age or local storage crosses a threshold. These records remain Durable OnTime Data until the user explicitly deletes them or performs Local Data Reset. Deleting a Schedule will atomically remove its name, Place, note, Preparation relationship and content, detailed Schedule Outcome, and local delivery registrations, and the deleted Schedule will be absent from later OnTime Backups. To preserve the established Local Punctuality Score behavior, an already registered eligible contribution will remain only in non-identifying aggregate On Time or Late counts and will not retain the deleted Schedule identifier or descriptive fields. Restore Preview will explain that replacement with an older Backup Cutoff can reintroduce records deleted after that cutoff. Automatic retention windows and hidden retention of deleted Schedule details were rejected because the former causes unrecoverable offline loss and the latter violates the meaning of explicit deletion. diff --git a/docs/adr/0035-track-backup-freshness-without-automatic-backup.md b/docs/adr/0035-track-backup-freshness-without-automatic-backup.md new file mode 100644 index 00000000..b5c7e07e --- /dev/null +++ b/docs/adr/0035-track-backup-freshness-without-automatic-backup.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Track backup freshness without automatic backup + +My Data will expose Backup Freshness as Never Exported, No Changes Since Export, or Unexported Changes. The Local Data Store will record the latest successful export's Backup Cutoff and a monotonic durable-data revision boundary, but it will not retain the external destination, a file permission, or the Backup Password and therefore will not claim that the exported file still exists. Only a successful export on the current installation changes the status to No Changes Since Export; Backup Restore does not. When at least one Durable OnTime Data change remains unexported for 30 days, or 30 days have passed since the first durable record on an installation that has never exported, OnTime will show a dismissible, non-blocking in-app reminder. It will not create an automatic backup, schedule a system reminder, or disable product functionality. Hiding backup age and pretending that a restore proves an accessible recovery file were rejected because OnTime Backup is the only supported installation-transfer and recovery path, while intrusive system reminders were rejected for a user-initiated feature. diff --git a/docs/iOS-Release-Configuration.md b/docs/iOS-Release-Configuration.md index 0592a056..3f774c3c 100644 --- a/docs/iOS-Release-Configuration.md +++ b/docs/iOS-Release-Configuration.md @@ -1,30 +1,35 @@ # iOS Release Configuration -Release and archive builds must provide the same Dart define values that debug builds use for native iOS plist substitution. - -## Required Dart Defines - -- `GOOGLE_RESERVED_CLIENT_ID_IOS`: reversed iOS client ID used by Google Sign-In as the callback URL scheme in `ios/Runner/Info.plist`. -- `ENV`: app environment label. Use `staging` for release candidates and `prod` for tagged production builds. -- `REST_API_URL`: production REST API base URL. Release builds must use an `https://` URL. +OnTime's Android and iOS product builds are local-only. Release builds do not +accept API endpoints, Firebase configuration, remote-authentication client IDs, +or environment-specific Dart defines. ## Local Release Build -Run release builds with the required define: +Use `pubspec.yaml` as the source of the public version and build number, then +build the signed App Store archive and IPA: ```sh -flutter build ios --release \ - --dart-define=ENV=prod \ - --dart-define=GOOGLE_RESERVED_CLIENT_ID_IOS= \ - --dart-define=REST_API_URL=https://api.ontime.devkor.club +flutter pub get +dart run build_runner build --delete-conflicting-outputs +dart run tool/check_local_only_boundary.dart +flutter analyze +flutter test +flutter build ipa --release --export-method app-store ``` -For Xcode archives, add the same define to the Flutter build invocation or the CI step that prepares the archive. - -## How Validation Works - -The shared Runner scheme decodes Flutter `DART_DEFINES` into `ios/Flutter/Dart-Defines.xcconfig` before the build. `ios/Flutter/Release.xcconfig` includes that generated file so `$(GOOGLE_RESERVED_CLIENT_ID_IOS)` resolves in the built `Info.plist`. - -Release builds fail clearly when a required define is missing or when `REST_API_URL` is not HTTPS. A release-only Xcode build phase also checks the built `Info.plist` and fails if the Google Sign-In URL scheme was not written into `CFBundleURLTypes`, or if the release plist enables arbitrary ATS loads. - -Debug builds use `ios/Runner/Info-Debug.plist`, which keeps `NSAllowsArbitraryLoads` for the current HTTP debug API. Profile and Release builds use `ios/Runner/Info.plist`, which must remain production-strict. +The bundle identifier is `club.devkor.ontime.ios`, and automatic signing uses +the Apple Developer team configured in the Runner target. Never commit signing +certificates, provisioning profiles, App Store Connect credentials, generated +Pods, or built archives. + +## Release Verification + +- Inspect the archived entitlements and merged `Info.plist` before upload. +- Confirm that the product contains no Google/Firebase URL scheme, remote + notification background mode, or push/authentication entitlement. +- Confirm the app starts in airplane mode from a clean installation and can + complete onboarding, schedule management, local delivery, backup, restore, + and reset without contacting a server. +- Upload the IPA to App Store Connect and verify that the processed build has + the same version, build number, bundle identifier, and commit SHA. diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index b0ea75c7..ec97fc6f 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1,2 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" -#include "Dart-Defines.xcconfig" diff --git a/ios/Flutter/Profile.xcconfig b/ios/Flutter/Profile.xcconfig index b0ea75c7..592ceee8 100644 --- a/ios/Flutter/Profile.xcconfig +++ b/ios/Flutter/Profile.xcconfig @@ -1,2 +1 @@ #include "Generated.xcconfig" -#include "Dart-Defines.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index b0ea75c7..c4855bfe 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1,2 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" -#include "Dart-Defines.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 00000000..620e46eb --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 487855ff..585d0176 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 17C364722D564837005901F6 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 17C364712D564837005901F6 /* GoogleService-Info.plist */; }; 2C6F05C62DFD060A00A9E3A1 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6F05C52DFD060A00A9E3A1 /* SceneDelegate.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; @@ -47,7 +46,6 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 175B7EB22D7A844500737EBA /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = ""; }; 175B7EB32D7C9E4A00737EBA /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; - 17C364712D564837005901F6 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 2C6F05C52DFD060A00A9E3A1 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -110,7 +108,6 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( - 17C364712D564837005901F6 /* GoogleService-Info.plist */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, @@ -175,7 +172,6 @@ 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, - 8B7A3E6D2F1C4A9B8D5E0C13 /* Validate Release Info.plist */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, ); @@ -247,7 +243,6 @@ files = ( 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 17C364722D564837005901F6 /* GoogleService-Info.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, ); @@ -272,22 +267,6 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 8B7A3E6D2F1C4A9B8D5E0C13 /* Validate Release Info.plist */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", - ); - name = "Validate Release Info.plist"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "bash \"${SRCROOT}/scripts/validate_release_info_plist.sh\"\n"; - }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index edc746f6..1449f2fb 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,32 +1,5 @@ { "pins" : [ - { - "identity" : "abseil-cpp-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/abseil-cpp-binary.git", - "state" : { - "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", - "version" : "1.2024072200.0" - } - }, - { - "identity" : "app-check", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/app-check.git", - "state" : { - "revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1", - "version" : "11.3.0" - } - }, - { - "identity" : "appauth-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/openid/AppAuth-iOS", - "state" : { - "revision" : "145104f5ea9d58ae21b60add007c33c1cc0c948e", - "version" : "2.0.0" - } - }, { "identity" : "csqlite", "kind" : "remoteSourceControl", @@ -34,123 +7,6 @@ "state" : { "revision" : "1ee46d19a4f451a7aa64ffc64fc99b4748131e62" } - }, - { - "identity" : "firebase-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/firebase-ios-sdk", - "state" : { - "revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270", - "version" : "12.15.0" - } - }, - { - "identity" : "google-ads-on-device-conversion-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", - "state" : { - "revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64", - "version" : "3.6.0" - } - }, - { - "identity" : "googleappmeasurement", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleAppMeasurement.git", - "state" : { - "revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b", - "version" : "12.15.0" - } - }, - { - "identity" : "googledatatransport", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleDataTransport.git", - "state" : { - "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", - "version" : "10.1.0" - } - }, - { - "identity" : "googlesignin-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleSignIn-iOS.git", - "state" : { - "revision" : "913b4005ea26aebe1c97d54e35ad82a515924c71", - "version" : "9.1.0" - } - }, - { - "identity" : "googleutilities", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleUtilities.git", - "state" : { - "revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822", - "version" : "8.1.1" - } - }, - { - "identity" : "grpc-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/grpc-binary.git", - "state" : { - "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", - "version" : "1.69.1" - } - }, - { - "identity" : "gtm-session-fetcher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/gtm-session-fetcher.git", - "state" : { - "revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", - "version" : "3.5.0" - } - }, - { - "identity" : "gtmappauth", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GTMAppAuth.git", - "state" : { - "revision" : "56e0ccf09a6dd29dc7e68bdf729598240ca8aa16", - "version" : "5.0.0" - } - }, - { - "identity" : "interop-ios-for-google-sdks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/interop-ios-for-google-sdks.git", - "state" : { - "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", - "version" : "101.0.0" - } - }, - { - "identity" : "leveldb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/leveldb.git", - "state" : { - "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", - "version" : "1.22.5" - } - }, - { - "identity" : "nanopb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/nanopb.git", - "state" : { - "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163", - "version" : "2.30910.1" - } - }, - { - "identity" : "promises", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/promises.git", - "state" : { - "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", - "version" : "2.4.1" - } } ], "version" : 2 diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 118cb7fa..95d6e55f 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -22,22 +22,6 @@ - - - - - - - - - - - - - - - - - - ???? CFBundleURLTypes - - CFBundleURLSchemes - - $(GOOGLE_RESERVED_CLIENT_ID_IOS) - - CFBundleURLName club.devkor.ontime.alarm @@ -41,15 +35,8 @@ CFBundleVersion $(FLUTTER_BUILD_NUMBER) - FirebaseAppDelegateProxyEnabled - LSRequiresIPhoneOS - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAlarmKitUsageDescription OnTime uses alarms to remind you when it is time to prepare for schedules. UIApplicationSupportsIndirectInputEvents @@ -75,11 +62,6 @@ - UIBackgroundModes - - fetch - remote-notification - UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 5a25e101..3fb0ddda 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -24,12 +24,6 @@ ???? CFBundleURLTypes - - CFBundleURLSchemes - - $(GOOGLE_RESERVED_CLIENT_ID_IOS) - - CFBundleURLName club.devkor.ontime.alarm @@ -41,8 +35,6 @@ CFBundleVersion $(FLUTTER_BUILD_NUMBER) - FirebaseAppDelegateProxyEnabled - LSRequiresIPhoneOS ITSAppUsesNonExemptEncryption @@ -72,11 +64,6 @@ - UIBackgroundModes - - fetch - remote-notification - UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index c6716694..1ca50cc8 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -2,12 +2,6 @@ - aps-environment - development - com.apple.developer.applesignin - - Default - com.apple.developer.usernotifications.time-sensitive diff --git a/ios/Runner/RunnerDebug.entitlements b/ios/Runner/RunnerDebug.entitlements index c6716694..1ca50cc8 100644 --- a/ios/Runner/RunnerDebug.entitlements +++ b/ios/Runner/RunnerDebug.entitlements @@ -2,12 +2,6 @@ - aps-environment - development - com.apple.developer.applesignin - - Default - com.apple.developer.usernotifications.time-sensitive diff --git a/ios/scripts/extract_dart_defines.sh b/ios/scripts/extract_dart_defines.sh deleted file mode 100755 index 94be0c00..00000000 --- a/ios/scripts/extract_dart_defines.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env bash - -SRCROOT="${SRCROOT:-$(pwd)}" - -OUTPUT_FILE="${SRCROOT}/Flutter/Dart-Defines.xcconfig" -REQUIRED_RELEASE_DEFINES=("GOOGLE_RESERVED_CLIENT_ID_IOS" "REST_API_URL") - -set -euo pipefail - -mkdir -p "$(dirname "$OUTPUT_FILE")" -: > "$OUTPUT_FILE" - -decode_base64() { - local value="$1" - - if printf '%s' "$value" | base64 --decode >/dev/null 2>&1; then - printf '%s' "$value" | base64 --decode - else - printf '%s' "$value" | base64 -D - fi -} - -is_release_configuration() { - [[ "${CONFIGURATION:-}" == "Release" ]] -} - -IFS=',' read -r -a define_items <<<"${DART_DEFINES:-}" - -for index in "${!define_items[@]}" -do - if [[ -z "${define_items[$index]}" ]]; then - continue - fi - - item=$(decode_base64 "${define_items[$index]}") - - lowercase_item=$(echo "$item" | tr '[:upper:]' '[:lower:]') - if [[ $lowercase_item != flutter* ]]; then - echo "$item" >> "$OUTPUT_FILE" - fi -done - -if is_release_configuration; then - for required_define in "${REQUIRED_RELEASE_DEFINES[@]}" - do - if ! grep -Eq "^${required_define}=.+" "$OUTPUT_FILE"; then - echo "error: Missing required iOS release dart define: ${required_define}" >&2 - echo "error: Pass it with --dart-define=${required_define}= for release/archive builds." >&2 - exit 1 - fi - done - - rest_api_url=$(grep -E "^REST_API_URL=" "$OUTPUT_FILE" | tail -n 1 | cut -d= -f2-) - if [[ "$rest_api_url" != https://* ]]; then - echo "error: iOS release REST_API_URL must use HTTPS." >&2 - echo "error: Received REST_API_URL=${rest_api_url}" >&2 - exit 1 - fi -fi diff --git a/ios/scripts/validate_release_info_plist.sh b/ios/scripts/validate_release_info_plist.sh deleted file mode 100755 index f3751eba..00000000 --- a/ios/scripts/validate_release_info_plist.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -if [[ "${CONFIGURATION:-}" != "Release" ]]; then - exit 0 -fi - -required_scheme="${GOOGLE_RESERVED_CLIENT_ID_IOS:-}" -info_plist="${TARGET_BUILD_DIR:-}/${INFOPLIST_PATH:-}" - -if [[ -z "$required_scheme" || "$required_scheme" == *'$('* ]]; then - echo "error: GOOGLE_RESERVED_CLIENT_ID_IOS is not resolved for the iOS release build." >&2 - echo "error: Pass --dart-define=GOOGLE_RESERVED_CLIENT_ID_IOS= before archiving." >&2 - exit 1 -fi - -if [[ ! -f "$info_plist" ]]; then - echo "error: Built Info.plist not found at ${info_plist}." >&2 - exit 1 -fi - -if ! /usr/libexec/PlistBuddy -c "Print :CFBundleURLTypes" "$info_plist" \ - | grep -Fq "$required_scheme"; then - echo "error: Built Info.plist does not contain the Google Sign-In URL scheme." >&2 - echo "error: Expected CFBundleURLTypes to include: ${required_scheme}" >&2 - exit 1 -fi - -arbitrary_loads=$(/usr/libexec/PlistBuddy -c "Print :NSAppTransportSecurity:NSAllowsArbitraryLoads" "$info_plist" 2>/dev/null || true) -if [[ "$arbitrary_loads" == "true" || "$arbitrary_loads" == "1" || "$arbitrary_loads" == "YES" ]]; then - echo "error: iOS release Info.plist must not allow arbitrary ATS loads." >&2 - echo "error: Remove NSAppTransportSecurity:NSAllowsArbitraryLoads from the release plist." >&2 - exit 1 -fi diff --git a/lib/core/backup/backup_crypto.dart b/lib/core/backup/backup_crypto.dart new file mode 100644 index 00000000..8d0c5d02 --- /dev/null +++ b/lib/core/backup/backup_crypto.dart @@ -0,0 +1,205 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:on_time_front/core/backup/backup_password.dart'; +import 'package:sodium_libs/sodium_libs_sumo.dart'; + +class BackupCrypto { + BackupCrypto({Future Function()? sodiumLoader}) + : _sodiumLoader = sodiumLoader ?? SodiumSumoInit.init; + + static const _magic = 'ONTIMEBK'; + static const formatVersion = 1; + static const opsLimit = 3; + static const memLimit = 64 * 1024 * 1024; + static const chunkSize = 64 * 1024; + static const _maxHeaderBytes = 4096; + static const _maxFrameCount = 100000; + static const _maxFrameBytes = chunkSize + 1024; + final Future Function() _sodiumLoader; + + Future encrypt({ + required Uint8List plaintext, + required String password, + }) async { + final normalizedPassword = BackupPassword.parse(password); + final sodium = await _sodiumLoader(); + final salt = sodium.randombytes.buf(16); + final header = utf8.encode( + jsonEncode({ + 'formatVersion': formatVersion, + 'suite': 'argon2id13+xchacha20poly1305-secretstream', + 'opsLimit': opsLimit, + 'memLimit': memLimit, + 'salt': base64UrlEncode(salt), + 'chunkSize': chunkSize, + }), + ); + final key = await _deriveKey(sodium, normalizedPassword, salt); + try { + final chunks = [ + for (var offset = 0; offset < plaintext.length; offset += chunkSize) + Uint8List.sublistView( + plaintext, + offset, + (offset + chunkSize).clamp(0, plaintext.length), + ), + ]; + if (chunks.isEmpty) chunks.add(Uint8List(0)); + final encrypted = await sodium.crypto.secretStream + .pushEx( + key: key, + messageStream: Stream.fromIterable([ + for (final (index, chunk) in chunks.indexed) + SecretStreamPlainMessage( + chunk, + additionalData: Uint8List.fromList(header), + tag: index == chunks.length - 1 + ? SecretStreamMessageTag.finalPush + : SecretStreamMessageTag.message, + ), + ]), + ) + .toList(); + + final builder = BytesBuilder(copy: false) + ..add(ascii.encode(_magic)) + ..add(_uint32(header.length)) + ..add(header) + ..add(_uint32(encrypted.length)); + for (final frame in encrypted) { + builder + ..add(_uint32(frame.message.length)) + ..add(frame.message); + } + return builder.takeBytes(); + } finally { + key.dispose(); + salt.fillRange(0, salt.length, 0); + } + } + + Future decrypt({ + required Uint8List container, + required String password, + }) async { + final normalizedPassword = BackupPassword.parse(password); + final reader = _ByteReader(container); + if (ascii.decode(reader.read(_magic.length)) != _magic) { + throw const FormatException('Not an OnTime backup file.'); + } + final headerLength = reader.readUint32(); + if (headerLength <= 0 || headerLength > _maxHeaderBytes) { + throw const FormatException('Unsafe backup header length.'); + } + final header = reader.read(headerLength); + final metadata = jsonDecode(utf8.decode(header)); + if (metadata is! Map) { + throw const FormatException('Invalid backup header.'); + } + _validateHeader(metadata); + final salt = Uint8List.fromList( + base64Url.decode(metadata['salt'] as String), + ); + if (salt.length != 16) throw const FormatException('Invalid backup salt.'); + + final frameCount = reader.readUint32(); + if (frameCount < 2 || frameCount > _maxFrameCount) { + throw const FormatException('Unsafe backup frame count.'); + } + final frames = []; + for (var index = 0; index < frameCount; index++) { + final length = reader.readUint32(); + if (length <= 0 || length > _maxFrameBytes) { + throw const FormatException('Unsafe backup frame length.'); + } + frames.add(reader.read(length)); + } + if (!reader.isAtEnd) throw const FormatException('Unexpected backup data.'); + + final sodium = await _sodiumLoader(); + final key = await _deriveKey(sodium, normalizedPassword, salt); + try { + final decrypted = await sodium.crypto.secretStream + .pullEx( + key: key, + cipherStream: Stream.fromIterable([ + SecretStreamCipherMessage(frames.first), + for (final frame in frames.skip(1)) + SecretStreamCipherMessage(frame, additionalData: header), + ]), + ) + .toList(); + return Uint8List.fromList([ + for (final message in decrypted) ...message.message, + ]); + } catch (_) { + throw const FormatException('Wrong password or damaged backup file.'); + } finally { + key.dispose(); + salt.fillRange(0, salt.length, 0); + } + } + + Future _deriveKey( + SodiumSumo sodium, + BackupPassword password, + Uint8List salt, + ) { + final signedPassword = Int8List.fromList( + password.utf8Bytes.map((byte) => byte > 127 ? byte - 256 : byte).toList(), + ); + return sodium.runIsolated((isolated, _, _) { + return isolated.crypto.pwhash( + outLen: isolated.crypto.secretStream.keyBytes, + password: signedPassword, + salt: salt, + opsLimit: opsLimit, + memLimit: memLimit, + alg: CryptoPwhashAlgorithm.argon2id13, + ); + }); + } + + void _validateHeader(Map header) { + if (header['formatVersion'] != formatVersion) { + throw const FormatException('Unsupported backup format version.'); + } + if (header['suite'] != 'argon2id13+xchacha20poly1305-secretstream' || + header['opsLimit'] != opsLimit || + header['memLimit'] != memLimit || + header['chunkSize'] != chunkSize || + header['salt'] is! String) { + throw const FormatException('Unsupported or unsafe backup crypto suite.'); + } + } + + Uint8List _uint32(int value) { + final bytes = ByteData(4)..setUint32(0, value, Endian.big); + return bytes.buffer.asUint8List(); + } +} + +class _ByteReader { + _ByteReader(this._bytes); + + final Uint8List _bytes; + int _offset = 0; + + bool get isAtEnd => _offset == _bytes.length; + + Uint8List read(int length) { + if (length < 0 || _offset + length > _bytes.length) { + throw const FormatException('Truncated backup file.'); + } + final result = Uint8List.sublistView(_bytes, _offset, _offset + length); + _offset += length; + return result; + } + + int readUint32() { + final value = ByteData.sublistView(read(4)).getUint32(0, Endian.big); + return value; + } +} diff --git a/lib/core/backup/backup_password.dart b/lib/core/backup/backup_password.dart new file mode 100644 index 00000000..ec107303 --- /dev/null +++ b/lib/core/backup/backup_password.dart @@ -0,0 +1,31 @@ +import 'dart:convert'; + +import 'package:unorm_dart/unorm_dart.dart' as unorm; + +class BackupPassword { + BackupPassword._(this.normalized, this.utf8Bytes); + + static const minCodePoints = 15; + static const maxCodePoints = 128; + static const maxUtf8Bytes = 1024; + + final String normalized; + final List utf8Bytes; + + static BackupPassword parse(String value) { + final normalized = unorm.nfc(value); + final codePoints = normalized.runes.length; + final bytes = utf8.encode(normalized); + if (codePoints < minCodePoints || codePoints > maxCodePoints) { + throw const FormatException( + 'Backup password must contain 15 to 128 Unicode code points.', + ); + } + if (bytes.length > maxUtf8Bytes) { + throw const FormatException( + 'Backup password must be no more than 1024 UTF-8 bytes.', + ); + } + return BackupPassword._(normalized, bytes); + } +} diff --git a/lib/core/backup/backup_service.dart b/lib/core/backup/backup_service.dart new file mode 100644 index 00000000..8b4503b4 --- /dev/null +++ b/lib/core/backup/backup_service.dart @@ -0,0 +1,632 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:file_selector/file_selector.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'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/services/app_metadata_service.dart'; +import 'package:on_time_front/core/services/device_info_service/shared.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.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/preparation_template_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; + +enum BackupFreshness { neverExported, noChanges, unexportedChanges } + +class BackupFreshnessStatus { + const BackupFreshnessStatus({ + required this.freshness, + this.lastExportedAt, + this.reminderDue = false, + }); + + final BackupFreshness freshness; + final DateTime? lastExportedAt; + final bool reminderDue; +} + +class BackupRestorePreview { + const BackupRestorePreview({ + required this.cutoff, + required this.sourceAppVersion, + required this.sourcePlatform, + required this.scheduleCount, + required this.templateCount, + required this.defaultPreparationStepCount, + }); + + final DateTime cutoff; + final String sourceAppVersion; + final String sourcePlatform; + final int scheduleCount; + final int templateCount; + final int defaultPreparationStepCount; +} + +class BackupRestoreCandidate { + const BackupRestoreCandidate._(this._data, this.preview); + + final _BackupData _data; + final BackupRestorePreview preview; +} + +@lazySingleton +class BackupService { + BackupService( + this._database, + this._metadataProvider, { + @ignoreParam BackupCrypto? crypto, + }) : _crypto = crypto ?? BackupCrypto(); + + static const _typeGroup = XTypeGroup( + label: 'OnTime Backup', + extensions: ['ontimebackup'], + mimeTypes: ['application/octet-stream'], + ); + + final AppDatabase _database; + final AppMetadataProvider _metadataProvider; + final BackupCrypto _crypto; + + 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( + encrypted, + name: 'OnTime-${_fileDate(snapshot.cutoff)}.ontimebackup', + mimeType: 'application/octet-stream', + ).saveTo(location.path); + await _database.userDao.markExported( + userId: localProfileId, + revision: snapshot.dataRevision, + cutoff: snapshot.cutoff, + ); + return true; + } + + Future selectAndPreviewRestore( + String password, + ) async { + final file = await openFile(acceptedTypeGroups: const [_typeGroup]); + if (file == null) return null; + return previewEncryptedBackup(await file.readAsBytes(), password); + } + + /// Creates the same portable container used by the OS file export flow. + Future createEncryptedBackup(String password) async { + return _encryptSnapshot(await _captureSnapshot(), password); + } + + /// Fully decrypts, authenticates, parses and validates a backup before apply. + Future previewEncryptedBackup( + Uint8List encrypted, + String password, + ) async { + final decrypted = await _crypto.decrypt( + container: encrypted, + password: password, + ); + final decoded = jsonDecode(utf8.decode(decrypted)); + final data = _BackupData.fromJson(_asMap(decoded, 'backup')); + return BackupRestoreCandidate._( + data, + BackupRestorePreview( + cutoff: data.cutoff, + sourceAppVersion: data.sourceAppVersion, + sourcePlatform: data.sourcePlatform, + scheduleCount: data.schedules.length, + templateCount: data.templates.length, + defaultPreparationStepCount: + data.defaultPreparation.preparationStepList.length, + ), + ); + } + + Future _encryptSnapshot( + _BackupData snapshot, + String password, + ) { + return _crypto.encrypt( + plaintext: Uint8List.fromList(utf8.encode(jsonEncode(snapshot.toJson()))), + password: password, + ); + } + + Future applyRestore(BackupRestoreCandidate candidate) async { + final data = candidate._data; + final profile = data.profile.valueOrNull!; + await _database.transaction(() async { + await _database.deleteAllDurableData(); + await _database + .into(_database.users) + .insert( + UsersCompanion.insert( + id: const Value(localProfileId), + spareTime: profile.spareTime.inMinutes, + note: profile.note, + isOnboardingCompleted: Value(profile.isOnboardingCompleted), + eligibleOutcomeCount: Value(profile.eligibleOutcomeCount), + onTimeOutcomeCount: Value(profile.onTimeOutcomeCount), + alarmsEnabled: Value(data.alarmsEnabled), + alarmOffsetMinutes: Value(data.alarmOffsetMinutes), + detailedNotificationContent: Value( + data.detailedNotificationContent, + ), + dataRevision: Value(data.dataRevision + 1), + firstDurableDataAt: Value(data.cutoff), + lastDurableDataAt: Value(data.cutoff), + ), + ); + await _database.preparationUserDao.createPreparationUser( + data.defaultPreparation, + localProfileId, + ); + for (final schedule in data.schedules) { + await _database.scheduleDao.createSchedule( + schedule.toScheduleWithPlaceRow(), + ); + final preparation = data.schedulePreparations[schedule.id]; + if (preparation != null && preparation.preparationStepList.isNotEmpty) { + await _database.preparationScheduleDao.createPreparationSchedule( + preparation, + schedule.id, + ); + } + } + for (final template in data.templates) { + await _database.preparationTemplateDao.put( + id: template.id, + name: template.name, + preparation: template.preparation, + now: template.updatedAt, + ); + } + }); + } + + Future getFreshness() async { + final user = await (_database.select( + _database.users, + )..where((table) => table.id.equals(localProfileId))).getSingleOrNull(); + if (user?.lastExportedRevision == null) { + return BackupFreshnessStatus( + freshness: BackupFreshness.neverExported, + reminderDue: _isOlderThanReminderBoundary(user?.firstDurableDataAt), + ); + } + return BackupFreshnessStatus( + freshness: user!.lastExportedRevision == user.dataRevision + ? BackupFreshness.noChanges + : BackupFreshness.unexportedChanges, + lastExportedAt: user.lastExportedAt, + reminderDue: + user.lastExportedRevision != user.dataRevision && + _isOlderThanReminderBoundary(user.lastDurableDataAt), + ); + } + + bool _isOlderThanReminderBoundary(DateTime? value) => + value != null && DateTime.now().difference(value).inDays >= 30; + + Future<_BackupData> _captureSnapshot() async { + return _database.transaction(() async { + final cutoff = DateTime.now(); + final metadata = await _metadataProvider.getMetadata(); + final user = await (_database.select( + _database.users, + )..where((table) => table.id.equals(localProfileId))).getSingle(); + final scheduleRows = await _database.scheduleDao.getScheduleList(); + final schedulePreparations = {}; + for (final row in scheduleRows) { + schedulePreparations[row.schedule.id] = await _database + .preparationScheduleDao + .getPreparationSchedulesByScheduleId(row.schedule.id); + } + return _BackupData( + cutoff: cutoff, + sourceAppVersion: '${metadata.version}+${metadata.buildNumber}', + sourcePlatform: _sourcePlatform(), + dataRevision: user.dataRevision, + profile: user.toUserEntity(), + alarmsEnabled: user.alarmsEnabled, + alarmOffsetMinutes: user.alarmOffsetMinutes, + detailedNotificationContent: user.detailedNotificationContent, + schedules: scheduleRows.map((row) => row.toScheduleEntity()).toList(), + defaultPreparation: await _database.preparationUserDao + .getPreparationUsersByUserId(localProfileId), + schedulePreparations: schedulePreparations, + templates: await _database.preparationTemplateDao.getAll(), + ); + }); + } + + String _fileDate(DateTime value) => + '${value.year.toString().padLeft(4, '0')}' + '${value.month.toString().padLeft(2, '0')}' + '${value.day.toString().padLeft(2, '0')}'; + + String _sourcePlatform() { + try { + return DeviceInfoService.platformType.name; + } catch (_) { + return 'unknown'; + } + } +} + +class _BackupData { + const _BackupData({ + required this.cutoff, + required this.sourceAppVersion, + required this.sourcePlatform, + required this.dataRevision, + required this.profile, + required this.alarmsEnabled, + required this.alarmOffsetMinutes, + required this.detailedNotificationContent, + required this.schedules, + required this.defaultPreparation, + required this.schedulePreparations, + required this.templates, + }); + + final DateTime cutoff; + final String sourceAppVersion; + final String sourcePlatform; + final int dataRevision; + final UserEntity profile; + final bool alarmsEnabled; + final int alarmOffsetMinutes; + final bool detailedNotificationContent; + final List schedules; + final PreparationEntity defaultPreparation; + final Map schedulePreparations; + final List templates; + + Map toJson() => { + 'formatVersion': 1, + 'cutoff': cutoff.toIso8601String(), + 'sourceAppVersion': sourceAppVersion, + 'sourcePlatform': sourcePlatform, + 'dataRevision': dataRevision, + 'profile': { + 'spareTimeMinutes': profile.valueOrNull!.spareTime.inMinutes, + 'note': profile.valueOrNull!.note, + 'isOnboardingCompleted': profile.valueOrNull!.isOnboardingCompleted, + 'eligibleOutcomeCount': profile.valueOrNull!.eligibleOutcomeCount, + 'onTimeOutcomeCount': profile.valueOrNull!.onTimeOutcomeCount, + }, + 'preferences': { + 'alarmsEnabled': alarmsEnabled, + 'alarmOffsetMinutes': alarmOffsetMinutes, + 'detailedNotificationContent': detailedNotificationContent, + }, + 'schedules': schedules.map(_scheduleToJson).toList(), + 'defaultPreparation': _preparationToJson(defaultPreparation), + 'schedulePreparations': { + for (final entry in schedulePreparations.entries) + entry.key: _preparationToJson(entry.value), + }, + 'templates': templates + .map( + (template) => { + 'id': template.id, + 'name': template.name, + 'createdAt': template.createdAt.toIso8601String(), + 'updatedAt': template.updatedAt.toIso8601String(), + 'preparation': _preparationToJson(template.preparation), + }, + ) + .toList(), + }; + + factory _BackupData.fromJson(Map json) { + if (_asInt(json['formatVersion'], 'formatVersion') != 1) { + throw const FormatException('Unsupported backup data version.'); + } + final profile = _asMap(json['profile'], 'profile'); + final preferences = _asMap(json['preferences'], 'preferences'); + final schedulePreparations = _asMap( + json['schedulePreparations'], + 'schedulePreparations', + ); + final templates = _asList(json['templates'], 'templates'); + final result = _BackupData( + cutoff: _asDate(json['cutoff'], 'cutoff'), + sourceAppVersion: _asString(json['sourceAppVersion'], 'sourceAppVersion'), + sourcePlatform: _asString(json['sourcePlatform'], 'sourcePlatform'), + dataRevision: _asNonNegativeInt(json['dataRevision'], 'dataRevision'), + profile: UserEntity( + id: localProfileId, + spareTime: Duration( + minutes: _asNonNegativeInt( + profile['spareTimeMinutes'], + 'profile.spareTimeMinutes', + ), + ), + note: _asString(profile['note'], 'profile.note'), + isOnboardingCompleted: _asBool( + profile['isOnboardingCompleted'], + 'profile.isOnboardingCompleted', + ), + eligibleOutcomeCount: _asNonNegativeInt( + profile['eligibleOutcomeCount'], + 'profile.eligibleOutcomeCount', + ), + onTimeOutcomeCount: _asNonNegativeInt( + profile['onTimeOutcomeCount'], + 'profile.onTimeOutcomeCount', + ), + ), + alarmsEnabled: _asBool( + preferences['alarmsEnabled'], + 'preferences.alarmsEnabled', + ), + alarmOffsetMinutes: _asNonNegativeInt( + preferences['alarmOffsetMinutes'], + 'preferences.alarmOffsetMinutes', + ), + detailedNotificationContent: _asBool( + preferences['detailedNotificationContent'], + 'preferences.detailedNotificationContent', + ), + schedules: _asList( + json['schedules'], + 'schedules', + ).map((value) => _scheduleFromJson(_asMap(value, 'schedule'))).toList(), + defaultPreparation: _preparationFromJson( + _asList(json['defaultPreparation'], 'defaultPreparation'), + ), + schedulePreparations: { + for (final entry in schedulePreparations.entries) + entry.key: _preparationFromJson( + _asList(entry.value, 'schedulePreparations.${entry.key}'), + ), + }, + templates: templates.map((value) { + final map = _asMap(value, 'template'); + return PreparationTemplateEntity( + id: _asString(map['id'], 'template.id'), + name: _asString(map['name'], 'template.name'), + createdAt: _asDate(map['createdAt'], 'template.createdAt'), + updatedAt: _asDate(map['updatedAt'], 'template.updatedAt'), + preparation: _preparationFromJson( + _asList(map['preparation'], 'template.preparation'), + ), + ); + }).toList(), + ); + _validateBackupData(result); + return result; + } +} + +void _validateBackupData(_BackupData data) { + final profile = data.profile.valueOrNull!; + if (profile.onTimeOutcomeCount > profile.eligibleOutcomeCount) { + throw const FormatException( + 'On-time outcome count cannot exceed eligible outcome count.', + ); + } + if (data.alarmOffsetMinutes > 24 * 60) { + throw const FormatException('Alarm offset is outside the supported range.'); + } + final scheduleIds = {}; + for (final schedule in data.schedules) { + if (schedule.id.isEmpty || !scheduleIds.add(schedule.id)) { + throw const FormatException('Schedule identifiers must be unique.'); + } + if (schedule.timeZoneId.isEmpty || + (schedule.occurrenceOffsetSeconds?.abs() ?? 0) > 24 * 60 * 60) { + throw const FormatException('Schedule time-zone data is invalid.'); + } + } + if (!data.schedulePreparations.keys.every(scheduleIds.contains)) { + throw const FormatException( + 'Schedule preparation references an unknown schedule.', + ); + } + final templateIds = {}; + for (final template in data.templates) { + if (template.id.isEmpty || !templateIds.add(template.id)) { + throw const FormatException('Template identifiers must be unique.'); + } + } + _validatePreparation(data.defaultPreparation); + for (final preparation in data.schedulePreparations.values) { + _validatePreparation(preparation); + } + for (final template in data.templates) { + _validatePreparation(template.preparation); + } +} + +void _validatePreparation(PreparationEntity preparation) { + final ids = {}; + for (final step in preparation.preparationStepList) { + if (step.id.isEmpty || !ids.add(step.id)) { + throw const FormatException( + 'Preparation step identifiers must be unique.', + ); + } + } + for (final step in preparation.preparationStepList) { + final nextId = step.nextPreparationId; + if (nextId != null && !ids.contains(nextId)) { + throw const FormatException( + 'Preparation step references an unknown next step.', + ); + } + } +} + +Map _scheduleToJson(ScheduleEntity value) => { + 'id': value.id, + 'place': {'id': value.place.id, 'name': value.place.placeName}, + 'name': value.scheduleName, + 'civilTime': value.scheduleTime.toIso8601String(), + 'timeZoneId': value.timeZoneId, + 'occurrenceOffsetSeconds': value.occurrenceOffsetSeconds, + 'moveTimeMinutes': value.moveTime.inMinutes, + 'isChanged': value.isChanged, + 'spareTimeMinutes': value.scheduleSpareTime?.inMinutes, + 'note': value.scheduleNote, + 'latenessTime': value.latenessTime, + 'doneStatus': value.doneStatus.name, + 'finishedAt': value.finishedAt?.toIso8601String(), + 'preparationMode': value.preparationMode?.name, + 'preparationTemplateId': value.preparationTemplateId, + 'preparationTemplateName': value.preparationTemplateName, + 'preparationTemplateDeleted': value.preparationTemplateDeleted, + 'scoreContributionRecorded': value.scoreContributionRecorded, +}; + +ScheduleEntity _scheduleFromJson(Map json) { + final place = _asMap(json['place'], 'schedule.place'); + final eligibleCount = _nullableInt( + json['occurrenceOffsetSeconds'], + 'schedule.occurrenceOffsetSeconds', + ); + return ScheduleEntity( + id: _asString(json['id'], 'schedule.id'), + place: PlaceEntity( + id: _asString(place['id'], 'schedule.place.id'), + placeName: _asString(place['name'], 'schedule.place.name'), + ), + scheduleName: _asString(json['name'], 'schedule.name'), + scheduleTime: _asDate(json['civilTime'], 'schedule.civilTime'), + timeZoneId: _asString(json['timeZoneId'], 'schedule.timeZoneId'), + occurrenceOffsetSeconds: eligibleCount, + moveTime: Duration( + minutes: _asNonNegativeInt( + json['moveTimeMinutes'], + 'schedule.moveTimeMinutes', + ), + ), + isChanged: _asBool(json['isChanged'], 'schedule.isChanged'), + isStarted: false, + scheduleSpareTime: _nullableInt( + json['spareTimeMinutes'], + 'schedule.spareTimeMinutes', + )?.let((minutes) => Duration(minutes: minutes)), + scheduleNote: _asString(json['note'], 'schedule.note'), + latenessTime: _asInt(json['latenessTime'], 'schedule.latenessTime'), + doneStatus: ScheduleDoneStatus.values.byName( + _asString(json['doneStatus'], 'schedule.doneStatus'), + ), + startedAt: null, + finishedAt: _nullableDate(json['finishedAt'], 'schedule.finishedAt'), + preparationMode: json['preparationMode'] == null + ? null + : SchedulePreparationMode.values.byName( + _asString(json['preparationMode'], 'schedule.preparationMode'), + ), + preparationTemplateId: _nullableString( + json['preparationTemplateId'], + 'schedule.preparationTemplateId', + ), + preparationTemplateName: _nullableString( + json['preparationTemplateName'], + 'schedule.preparationTemplateName', + ), + preparationTemplateDeleted: _asBool( + json['preparationTemplateDeleted'], + 'schedule.preparationTemplateDeleted', + ), + preparationFrozen: false, + scoreContributionRecorded: _asBool( + json['scoreContributionRecorded'], + 'schedule.scoreContributionRecorded', + ), + ); +} + +List> _preparationToJson(PreparationEntity value) => [ + for (final step in value.ordered.preparationStepList) + { + 'id': step.id, + 'name': step.preparationName, + 'minutes': step.preparationTime.inMinutes, + 'nextId': step.nextPreparationId, + }, +]; + +PreparationEntity _preparationFromJson(List values) { + return PreparationEntity( + preparationStepList: values.map((value) { + final map = _asMap(value, 'preparationStep'); + return PreparationStepEntity( + id: _asString(map['id'], 'preparationStep.id'), + preparationName: _asString(map['name'], 'preparationStep.name'), + preparationTime: Duration( + minutes: _asNonNegativeInt(map['minutes'], 'preparationStep.minutes'), + ), + nextPreparationId: _nullableString( + map['nextId'], + 'preparationStep.nextId', + ), + ); + }).toList(), + ).ordered; +} + +Map _asMap(Object? value, String field) { + if (value is Map) return value; + throw FormatException('$field must be an object.'); +} + +List _asList(Object? value, String field) { + if (value is List) return value; + throw FormatException('$field must be a list.'); +} + +String _asString(Object? value, String field) { + if (value is String) return value; + throw FormatException('$field must be a string.'); +} + +String? _nullableString(Object? value, String field) => + value == null ? null : _asString(value, field); + +int _asInt(Object? value, String field) { + if (value is int) return value; + throw FormatException('$field must be an integer.'); +} + +int _asNonNegativeInt(Object? value, String field) { + final result = _asInt(value, field); + if (result < 0) throw FormatException('$field must not be negative.'); + return result; +} + +int? _nullableInt(Object? value, String field) => + value == null ? null : _asInt(value, field); + +bool _asBool(Object? value, String field) { + if (value is bool) return value; + throw FormatException('$field must be a boolean.'); +} + +DateTime _asDate(Object? value, String field) { + final parsed = DateTime.tryParse(_asString(value, field)); + if (parsed == null) throw FormatException('$field must be an ISO-8601 date.'); + return parsed; +} + +DateTime? _nullableDate(Object? value, String field) => + value == null ? null : _asDate(value, field); + +extension _Let on T { + R let(R Function(T value) action) => action(this); +} diff --git a/lib/core/constants/endpoint.dart b/lib/core/constants/endpoint.dart deleted file mode 100644 index 83b7b442..00000000 --- a/lib/core/constants/endpoint.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'dart:core'; - -class Endpoint { - //user - static const _signIn = '/login'; - static const _signUp = '/sign-up'; - static const _signInWithGoogle = '/oauth2/google/login'; - static const _signInWithApple = '/oauth2/apple/login'; - static const _getUser = '/users/me'; - static const _deleteGoogleMe = '/oauth2/google/me'; - static const _deleteAppleMe = '/oauth2/apple/me'; - static const _feedback = '/feedback'; - static const _deleteUser = '/users/me/delete'; - static const _analyticsPreference = '/users/me/analytics-preference'; - - static String get signIn => _signIn; - static String get signUp => _signUp; - static String get signInWithGoogle => _signInWithGoogle; - static String get signInWithApple => _signInWithApple; - static String get getUser => _getUser; - static String get deleteGoogleMe => _deleteGoogleMe; - static String get deleteAppleMe => _deleteAppleMe; - static String get feedback => _feedback; - static String get deleteUser => _deleteUser; - static String get analyticsPreference => _analyticsPreference; - - // schedule - static const _schedules = '/schedules'; - - static String getScheduleById(String scheduleId) => '$_schedules/$scheduleId'; - static String get getSchedulesByDate => _schedules; - - static String get createSchedule => _schedules; - static String updateSchedule(String scheduleId) => '$_schedules/$scheduleId'; - static String deleteScheduleById(String scheduleId) => - '$_schedules/$scheduleId'; - static String startSchedule(String scheduleId) => - '$_schedules/$scheduleId/start'; - static String finishSchedule(String scheduleId) => - '$_schedules/$scheduleId/finish'; - - // preparation - static const _createDefaultPreparation = - '$_getUser/onboarding'; // 사용자 준비과정 첫 세팅 - - static const _defaultPreparation = '/users/preparations'; // 사용자 기본 준비과정 조회 - - static String get createDefaultPreparation => _createDefaultPreparation; - - static String _prepartionByScheduleId(String scheduleId) => - '$_schedules/$scheduleId/preparations'; - - static String getCreateCustomPreparation(String scheduleId) => - _prepartionByScheduleId(scheduleId); - - static String getPreparationByScheduleId(String scheduleId) => - _prepartionByScheduleId(scheduleId); - - static String updatePreparationByScheduleId(String scheduleId) => - _prepartionByScheduleId(scheduleId); - - static String get getDefaultPreparation => _defaultPreparation; - - static String get updateDefaultPreparation => _defaultPreparation; - - static const _preparationTemplates = '/preparation-templates'; - - static String get preparationTemplates => _preparationTemplates; - - static String preparationTemplateById(String templateId) => - '$_preparationTemplates/$templateId'; - - static const _updateSpareTime = '/users/me/spare-time'; - static String get updateSpareTime => _updateSpareTime; - - static const _fcmToken = '/firebase-token'; // 사용자 fcm 토큰 등록 - static String get fcmTokenRegister => _fcmToken; - - // alarm - static const _alarmSettings = '/users/me/alarm-settings'; - static const _currentDevice = '/users/me/devices/current'; - static const _alarmWindow = '$_schedules/alarm-window'; - static const _alarmStatus = '/users/me/alarm-status'; - - static String get alarmSettings => _alarmSettings; - static String get currentDevice => _currentDevice; - static String get alarmWindow => _alarmWindow; - static String get alarmStatus => _alarmStatus; -} diff --git a/lib/core/constants/environment_variable.dart b/lib/core/constants/environment_variable.dart deleted file mode 100644 index f77254c5..00000000 --- a/lib/core/constants/environment_variable.dart +++ /dev/null @@ -1,5 +0,0 @@ -final class EnvironmentVariable { - static const appEnv = String.fromEnvironment('ENV', defaultValue: 'dev'); - static const restApiUrl = String.fromEnvironment('REST_API_URL'); - static const restAuthToken = String.fromEnvironment('REST_AUTH_TOKEN'); -} diff --git a/lib/core/constants/external_links.dart b/lib/core/constants/external_links.dart deleted file mode 100644 index e20c311d..00000000 --- a/lib/core/constants/external_links.dart +++ /dev/null @@ -1,5 +0,0 @@ -final class ExternalLinks { - static final privacyPolicyUri = Uri.parse( - 'https://ontime-back.duckdns.org/privacy-policy', - ); -} diff --git a/lib/core/constants/local_profile.dart b/lib/core/constants/local_profile.dart new file mode 100644 index 00000000..a8a66ae0 --- /dev/null +++ b/lib/core/constants/local_profile.dart @@ -0,0 +1 @@ +const localProfileId = 'local-profile'; diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 86406344..c3407b06 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -1,40 +1,53 @@ import 'package:drift/drift.dart'; -import 'package:drift_flutter/drift_flutter.dart'; import 'package:injectable/injectable.dart'; -import 'package:on_time_front/core/utils/json_converters/duration_json_converters.dart'; +import 'package:on_time_front/core/database/installation_key_store.dart'; +import 'package:on_time_front/core/database/open_database.dart'; import 'package:on_time_front/data/daos/place_dao.dart'; import 'package:on_time_front/data/daos/preparation_schedule_dao.dart'; +import 'package:on_time_front/data/daos/preparation_template_dao.dart'; import 'package:on_time_front/data/daos/preparation_user_dao.dart'; import 'package:on_time_front/data/daos/schedule_dao.dart'; import 'package:on_time_front/data/daos/user_dao.dart'; import 'package:on_time_front/data/tables/places_table.dart'; import 'package:on_time_front/data/tables/preparation_schedule_table.dart'; +import 'package:on_time_front/data/tables/preparation_template_step_table.dart'; +import 'package:on_time_front/data/tables/preparation_template_table.dart'; import 'package:on_time_front/data/tables/preparation_user_table.dart'; import 'package:on_time_front/data/tables/schedules_table.dart'; import 'package:on_time_front/data/tables/user_table.dart'; +import 'package:on_time_front/core/utils/json_converters/duration_json_converters.dart'; import 'package:uuid/uuid.dart'; - part 'database.g.dart'; @Singleton() @DriftDatabase( - tables: [Places, Schedules, Users, PreparationSchedules, PreparationUsers], + tables: [ + Places, + Schedules, + Users, + PreparationSchedules, + PreparationUsers, + PreparationTemplates, + PreparationTemplateSteps, + ], daos: [ ScheduleDao, PlaceDao, UserDao, PreparationScheduleDao, PreparationUserDao, + PreparationTemplateDao, ], ) class AppDatabase extends _$AppDatabase { - AppDatabase() : super(_openConnection()); + AppDatabase(InstallationKeyStore keyStore) + : super(openOnTimeDatabase(keyStore)); AppDatabase.forTesting(super.e); @override - int get schemaVersion => 4; + int get schemaVersion => 1; @override MigrationStrategy get migration => MigrationStrategy( @@ -42,35 +55,24 @@ class AppDatabase extends _$AppDatabase { await m.createAll(); }, onUpgrade: (Migrator m, int from, int to) async { - if (from < 3) { - await m.createTable(preparationSchedules); - await m.createTable(preparationUsers); - } - if (from < 4) { - await _createLookupIndexes(m); - } + throw StateError( + 'Encrypted local database migrations must be explicitly implemented.', + ); }, beforeOpen: (details) async { await customStatement('PRAGMA foreign_keys = ON'); }, ); - Future _createLookupIndexes(Migrator m) async { - await m.createIndex(schedulesScheduleTimeIdx); - await m.createIndex(schedulesPlaceIdIdx); - await m.createIndex(preparationSchedulesScheduleIdIdx); - await m.createIndex(preparationSchedulesNextPreparationIdIdx); - await m.createIndex(preparationUsersUserIdIdx); - await m.createIndex(preparationUsersNextPreparationIdIdx); - } - - static QueryExecutor _openConnection() { - return driftDatabase( - name: 'my_database', - web: DriftWebOptions( - sqlite3Wasm: Uri.parse('sqlite3.wasm'), - driftWorker: Uri.parse('drift_worker.dart.js'), - ), - ); + Future deleteAllDurableData() async { + await transaction(() async { + await delete(preparationTemplateSteps).go(); + await delete(preparationTemplates).go(); + await delete(preparationSchedules).go(); + await delete(preparationUsers).go(); + await delete(schedules).go(); + await delete(places).go(); + await delete(users).go(); + }); } } diff --git a/lib/core/database/installation_key_store.dart b/lib/core/database/installation_key_store.dart new file mode 100644 index 00000000..2f7f3b3f --- /dev/null +++ b/lib/core/database/installation_key_store.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:injectable/injectable.dart'; + +@lazySingleton +class InstallationKeyStore { + InstallationKeyStore({@ignoreParam FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + + static const _keyName = 'ontime_local_database_key_v1'; + static const _keyLength = 32; + static const _iosOptions = IOSOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + ); + + final FlutterSecureStorage _storage; + + Future getOrCreate() async { + final encoded = await _storage.read(key: _keyName, iOptions: _iosOptions); + if (encoded != null) { + final decoded = base64Url.decode(encoded); + if (decoded.length != _keyLength) { + throw const FormatException('Invalid installation data key length.'); + } + return Uint8List.fromList(decoded); + } + + final random = Random.secure(); + final key = Uint8List.fromList( + List.generate(_keyLength, (_) => random.nextInt(256)), + ); + await _storage.write( + key: _keyName, + value: base64UrlEncode(key), + iOptions: _iosOptions, + ); + return key; + } + + Future delete() => _storage.delete( + key: _keyName, + iOptions: _iosOptions, + ); +} diff --git a/lib/core/database/local_data_files.dart b/lib/core/database/local_data_files.dart new file mode 100644 index 00000000..c454bc1b --- /dev/null +++ b/lib/core/database/local_data_files.dart @@ -0,0 +1,2 @@ +export 'local_data_files_native.dart' + if (dart.library.html) 'local_data_files_web.dart'; diff --git a/lib/core/database/local_data_files_native.dart b/lib/core/database/local_data_files_native.dart new file mode 100644 index 00000000..c8f99e82 --- /dev/null +++ b/lib/core/database/local_data_files_native.dart @@ -0,0 +1,36 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +const localDatabaseFileName = 'ontime_local_v1.sqlite'; + +Future localDatabaseFile() async { + final directory = await getApplicationSupportDirectory(); + return File(p.join(directory.path, localDatabaseFileName)); +} + +Future excludeLocalDatabaseFromPlatformBackup(File file) async { + if (!Platform.isIOS) return; + const channel = MethodChannel('on_time_front/native_alarm'); + await channel.invokeMethod('excludeFromBackup', {'path': file.path}); +} + +Future deleteLocalDatabaseFiles({required bool includeLegacy}) async { + final current = await localDatabaseFile(); + await _deleteSqliteFamily(current.path); + if (!includeLegacy) return; + + final documents = await getApplicationDocumentsDirectory(); + for (final name in const ['my_database.sqlite', 'my_database']) { + await _deleteSqliteFamily(p.join(documents.path, name)); + } +} + +Future _deleteSqliteFamily(String path) async { + for (final suffix in const ['', '-wal', '-shm', '-journal']) { + final file = File('$path$suffix'); + if (await file.exists()) await file.delete(); + } +} diff --git a/lib/core/database/local_data_files_web.dart b/lib/core/database/local_data_files_web.dart new file mode 100644 index 00000000..c8ebf205 --- /dev/null +++ b/lib/core/database/local_data_files_web.dart @@ -0,0 +1,14 @@ +class LocalDevelopmentDatabaseFile { + const LocalDevelopmentDatabaseFile(); +} + +const localDatabaseFileName = 'ontime_local_v1.sqlite'; + +Future localDatabaseFile() async => + const LocalDevelopmentDatabaseFile(); + +Future excludeLocalDatabaseFromPlatformBackup( + LocalDevelopmentDatabaseFile file, +) async {} + +Future deleteLocalDatabaseFiles({required bool includeLegacy}) async {} diff --git a/lib/core/database/local_data_lifecycle.dart b/lib/core/database/local_data_lifecycle.dart new file mode 100644 index 00000000..b83edc9d --- /dev/null +++ b/lib/core/database/local_data_lifecycle.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:on_time_front/core/database/installation_key_store.dart'; +import 'package:on_time_front/core/database/local_data_files.dart'; +import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; +import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; +import 'package:on_time_front/core/services/notification_service.dart'; +import 'package:on_time_front/data/models/scheduled_alarm_record_model.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +final class LocalDataLifecycle { + static const _storage = FlutterSecureStorage(); + static const _cutoverMarker = 'ontime_local_only_cutover_v1'; + static const _resetMarker = 'ontime_local_reset_pending_v1'; + static const _alarmRegistryKey = 'scheduled_alarm_registry'; + static const _legacyTokenKeys = ['accessToken', 'refreshToken']; + static const _legacyIosOptions = IOSOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + ); + + static Future bootstrap() async { + if (await _storage.read(key: _resetMarker) != null) { + await _finishInterruptedReset(); + } + if (await _storage.read(key: _cutoverMarker) == null) { + await _performOneWayCutover(); + } + } + + static Future markResetPending() => + _storage.write(key: _resetMarker, value: 'pending'); + + static Future _performOneWayCutover() async { + await deleteLocalDatabaseFiles(includeLegacy: true); + await (await SharedPreferences.getInstance()).clear(); + for (final key in _legacyTokenKeys) { + await _storage.delete(key: key); + await _storage.delete(key: key, iOptions: _legacyIosOptions); + } + await _storage.write(key: _cutoverMarker, value: 'complete'); + } + + static Future _finishInterruptedReset() async { + final preferences = await SharedPreferences.getInstance(); + final records = _readAlarmRecords(preferences.getString(_alarmRegistryKey)); + final scheduler = AlarmSchedulerService(); + final fallback = FallbackAlarmNotificationServiceImpl(); + for (final record in records) { + try { + if (record.provider == AlarmProvider.localNotification) { + await fallback.cancelFallbackAlarm(record); + } else if (record.provider != AlarmProvider.none) { + await scheduler.cancelNativeAlarm(record); + } + } catch (_) { + // The marker remains until local files and credentials are removed. + } + } + await NotificationService.instance.cancelAll().catchError((_) {}); + await deleteLocalDatabaseFiles(includeLegacy: true); + await preferences.clear(); + await InstallationKeyStore().delete(); + for (final key in _legacyTokenKeys) { + await _storage.delete(key: key); + await _storage.delete(key: key, iOptions: _legacyIosOptions); + } + await _storage.delete(key: _resetMarker); + } + + static List _readAlarmRecords(String? raw) { + if (raw == null || raw.isEmpty) return const []; + try { + return (jsonDecode(raw) as List) + .map( + (item) => ScheduledAlarmRecordModel.fromJson( + item as Map, + ).record, + ) + .toList(); + } catch (_) { + return const []; + } + } +} diff --git a/lib/core/database/local_data_reset_service.dart b/lib/core/database/local_data_reset_service.dart new file mode 100644 index 00000000..1abe7350 --- /dev/null +++ b/lib/core/database/local_data_reset_service.dart @@ -0,0 +1,28 @@ +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/database/installation_key_store.dart'; +import 'package:on_time_front/core/database/local_data_files.dart'; +import 'package:on_time_front/core/database/local_data_lifecycle.dart'; +import 'package:on_time_front/core/services/notification_service.dart'; +import 'package:on_time_front/domain/use-cases/cancel_all_alarms_use_case.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +@lazySingleton +class LocalDataResetService { + LocalDataResetService(this._database, this._keyStore, this._cancelAllAlarms); + + final AppDatabase _database; + final InstallationKeyStore _keyStore; + final CancelAllAlarmsUseCase _cancelAllAlarms; + + Future reset() async { + await LocalDataLifecycle.markResetPending(); + await _cancelAllAlarms().catchError((_) {}); + await NotificationService.instance.cancelAll().catchError((_) {}); + await _database.close(); + await deleteLocalDatabaseFiles(includeLegacy: true); + await (await SharedPreferences.getInstance()).clear(); + await _keyStore.delete(); + // The pending marker is intentionally cleared by bootstrap on next launch. + } +} diff --git a/lib/core/database/open_database.dart b/lib/core/database/open_database.dart new file mode 100644 index 00000000..40ebaf32 --- /dev/null +++ b/lib/core/database/open_database.dart @@ -0,0 +1,3 @@ +export 'open_database_unsupported.dart' + if (dart.library.io) 'open_database_native.dart' + if (dart.library.js_interop) 'open_database_web.dart'; diff --git a/lib/core/database/open_database_native.dart b/lib/core/database/open_database_native.dart new file mode 100644 index 00000000..2739f5c4 --- /dev/null +++ b/lib/core/database/open_database_native.dart @@ -0,0 +1,25 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:on_time_front/core/database/installation_key_store.dart'; +import 'package:on_time_front/core/database/local_data_files.dart'; + +QueryExecutor openOnTimeDatabase(InstallationKeyStore keyStore) { + return LazyDatabase(() async { + final file = await localDatabaseFile(); + await file.parent.create(recursive: true); + if (!await file.exists()) await file.create(); + await excludeLocalDatabaseFromPlatformBackup(file); + final key = await keyStore.getOrCreate(); + final keyHex = key + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + + return NativeDatabase.createInBackground( + file, + setup: (rawDatabase) { + rawDatabase.execute('PRAGMA key = "x\'$keyHex\'"'); + rawDatabase.execute('PRAGMA cipher_memory_security = ON'); + }, + ); + }); +} diff --git a/lib/core/database/open_database_unsupported.dart b/lib/core/database/open_database_unsupported.dart new file mode 100644 index 00000000..e95aee2d --- /dev/null +++ b/lib/core/database/open_database_unsupported.dart @@ -0,0 +1,6 @@ +import 'package:drift/drift.dart'; +import 'package:on_time_front/core/database/installation_key_store.dart'; + +QueryExecutor openOnTimeDatabase(InstallationKeyStore keyStore) { + throw UnsupportedError('OnTime local data is supported on Android and iOS.'); +} diff --git a/lib/core/database/open_database_web.dart b/lib/core/database/open_database_web.dart new file mode 100644 index 00000000..a85e6cf4 --- /dev/null +++ b/lib/core/database/open_database_web.dart @@ -0,0 +1,13 @@ +import 'package:drift/drift.dart'; +import 'package:drift_flutter/drift_flutter.dart'; +import 'package:on_time_front/core/database/installation_key_store.dart'; + +QueryExecutor openOnTimeDatabase(InstallationKeyStore keyStore) { + return driftDatabase( + name: 'ontime_local_dev', + web: DriftWebOptions( + sqlite3Wasm: Uri.parse('sqlite3.wasm'), + driftWorker: Uri.parse('drift_worker.dart.js'), + ), + ); +} diff --git a/lib/core/dio/adapters/mobile_adapter.dart b/lib/core/dio/adapters/mobile_adapter.dart deleted file mode 100644 index 4c243668..00000000 --- a/lib/core/dio/adapters/mobile_adapter.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:dio/io.dart'; - -HttpClientAdapter getAdapter() { - return IOHttpClientAdapter(); -} diff --git a/lib/core/dio/adapters/shared.dart b/lib/core/dio/adapters/shared.dart deleted file mode 100644 index 69cbad67..00000000 --- a/lib/core/dio/adapters/shared.dart +++ /dev/null @@ -1,3 +0,0 @@ -export 'unsupported.dart' - if (dart.library.html) 'web_adapter.dart' - if (dart.library.io) 'mobile_adapter.dart'; diff --git a/lib/core/dio/adapters/unsupported.dart b/lib/core/dio/adapters/unsupported.dart deleted file mode 100644 index 14bbd682..00000000 --- a/lib/core/dio/adapters/unsupported.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:dio/dio.dart'; - -HttpClientAdapter getAdapter() { - throw 'unsupported platform'; -} diff --git a/lib/core/dio/adapters/web_adapter.dart b/lib/core/dio/adapters/web_adapter.dart deleted file mode 100644 index e150d9f3..00000000 --- a/lib/core/dio/adapters/web_adapter.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:dio/browser.dart'; -import 'package:dio/dio.dart'; - -HttpClientAdapter getAdapter() { - return BrowserHttpClientAdapter(); -} diff --git a/lib/core/dio/api_error_message.dart b/lib/core/dio/api_error_message.dart deleted file mode 100644 index ec55cfcd..00000000 --- a/lib/core/dio/api_error_message.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:dio/dio.dart'; - -class ApiErrorMessage { - const ApiErrorMessage._(); - - static String? fromException(Object error) { - if (error is DioException) { - return fromResponseData(error.response?.data); - } - return null; - } - - static String? fromResponseData(Object? data) { - if (data is! Map) { - return null; - } - - final message = data['message']; - if (message is String && message.trim().isNotEmpty) { - return message.trim(); - } - - final codeMessage = _messageFromCode(data['code']); - if (codeMessage != null) { - return codeMessage; - } - - final error = data['error']; - if (error is Map) { - final nestedCodeMessage = _messageFromCode(error['code']); - if (nestedCodeMessage != null) { - return nestedCodeMessage; - } - } - - final errors = data['data']; - if (errors is Map) { - final errorList = errors['errors']; - if (errorList is List && errorList.isNotEmpty) { - final firstError = errorList.first; - if (firstError is Map) { - final fieldMessage = firstError['message']; - if (fieldMessage is String && fieldMessage.trim().isNotEmpty) { - return fieldMessage.trim(); - } - } - } - } - - return null; - } - - static String? _messageFromCode(Object? code) { - if (code is! String) { - return null; - } - return switch (code) { - 'PREPARATION_TEMPLATE_NOT_FOUND' => 'Preparation template not found.', - 'PREPARATION_TEMPLATE_NAME_DUPLICATE' => - 'A preparation template with this name already exists.', - 'PREPARATION_TEMPLATE_LIMIT_EXCEEDED' => - 'You can create up to 20 active preparation templates.', - 'PREPARATION_TEMPLATE_DELETED' => - 'This preparation template has been deleted.', - 'PREPARATION_STEP_ID_CONFLICT' => - 'A preparation step ID is already used by another preparation.', - 'INVALID_INPUT' => 'Invalid input.', - _ => null, - }; - } -} diff --git a/lib/core/dio/api_response.dart b/lib/core/dio/api_response.dart deleted file mode 100644 index 0d9cbdf6..00000000 --- a/lib/core/dio/api_response.dart +++ /dev/null @@ -1,11 +0,0 @@ -class ApiResponse { - final String status; - final T data; - final String message; - - ApiResponse({ - required this.status, - required this.data, - this.message = '', - }); -} diff --git a/lib/core/dio/app_dio.dart b/lib/core/dio/app_dio.dart deleted file mode 100644 index 5af19696..00000000 --- a/lib/core/dio/app_dio.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/core/constants/environment_variable.dart'; -import 'package:on_time_front/core/dio/adapters/shared.dart'; -import 'package:on_time_front/core/dio/interceptors/logger_interceptor.dart'; -import 'package:on_time_front/core/dio/interceptors/token_interceptor.dart'; -import 'package:on_time_front/core/dio/interceptors/token_session_invalidator.dart'; -import 'package:on_time_front/core/dio/transformers/logging_transformer.dart'; -import 'package:on_time_front/data/data_sources/token_local_data_source.dart'; - -@LazySingleton(as: Dio) -class AppDio with DioMixin implements Dio { - AppDio( - TokenLocalDataSource tokenLocalDataSource, - TokenSessionInvalidator sessionInvalidator, - ) { - httpClientAdapter = getAdapter(); - transformer = LoggingTransformer(inner: BackgroundTransformer()); - options = BaseOptions( - contentType: Headers.jsonContentType, - baseUrl: EnvironmentVariable.restApiUrl, - connectTimeout: const Duration(milliseconds: 30000), - receiveTimeout: const Duration(milliseconds: 30000), - sendTimeout: const Duration(milliseconds: 30000), - receiveDataWhenStatusError: true, - followRedirects: false, - headers: { - "Accept": "application/json", - "Authorization": EnvironmentVariable.restAuthToken, - }, - ); - - interceptors.addAll([ - TokenInterceptor( - this, - tokenLocalDataSource: tokenLocalDataSource, - sessionInvalidator: sessionInvalidator, - ), - LoggerInterceptor(), - ]); - } -} diff --git a/lib/core/dio/interceptors/logger_interceptor.dart b/lib/core/dio/interceptors/logger_interceptor.dart deleted file mode 100644 index 4d08fb03..00000000 --- a/lib/core/dio/interceptors/logger_interceptor.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:on_time_front/core/logging/app_logger.dart'; - -class LoggerInterceptor implements Interceptor { - @override - void onError(DioException err, ErrorInterceptorHandler handler) { - AppLogger.debug( - 'Dio error ' - '${err.requestOptions.method} ' - '${AppLogger.redactUri(err.requestOptions.uri)} ' - 'status=${err.response?.statusCode} ' - 'type=${err.type} ' - 'message=${err.message}', - ); - AppLogger.debug( - 'Dio error headers=${AppLogger.redactValue(err.requestOptions.headers)}', - ); - return handler.next(err); - } - - @override - void onRequest(RequestOptions options, RequestInterceptorHandler handler) { - AppLogger.debug( - 'Dio request ${options.method} ${AppLogger.redactUri(options.uri)}', - ); - AppLogger.debug('Dio headers=${AppLogger.redactValue(options.headers)}'); - AppLogger.debug( - 'Dio query=${AppLogger.redactValue(options.queryParameters)}', - ); - if (options.data != null) { - AppLogger.debug( - 'Dio request body=${AppLogger.omitted} type=${options.data.runtimeType}', - ); - } - - return handler.next(options); - } - - @override - void onResponse( - Response response, - ResponseInterceptorHandler handler, - ) { - AppLogger.debug( - 'Dio response ${response.requestOptions.method} ' - '${AppLogger.redactUri(response.requestOptions.uri)} ' - 'status=${response.statusCode}', - ); - AppLogger.debug( - 'Dio query=${AppLogger.redactValue(response.requestOptions.queryParameters)}', - ); - if (response.data != null) { - AppLogger.debug( - 'Dio response body=${AppLogger.omitted} type=${response.data.runtimeType}', - ); - } - return handler.next(response); - } -} diff --git a/lib/core/dio/interceptors/token_interceptor.dart b/lib/core/dio/interceptors/token_interceptor.dart deleted file mode 100644 index 8a35b07d..00000000 --- a/lib/core/dio/interceptors/token_interceptor.dart +++ /dev/null @@ -1,209 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:on_time_front/core/constants/endpoint.dart'; -import 'package:on_time_front/core/dio/interceptors/token_session_invalidator.dart'; -import 'package:on_time_front/core/logging/app_logger.dart'; -import 'package:on_time_front/data/data_sources/token_local_data_source.dart'; -import 'package:on_time_front/domain/entities/token_entity.dart'; - -class TokenInterceptor implements InterceptorsWrapper { - static const _refreshTokenPath = '/refresh-token'; - static const _retryAfterRefreshKey = 'tokenInterceptor.retryAfterRefresh'; - static const _tokenUnavailableMessage = - 'Authentication token is unavailable for a protected request'; - - static bool _isRefreshing = false; - static final _requestsNeedRetry = <_RequestNeedingRetry>[]; - - final Dio dio; - final TokenLocalDataSource tokenLocalDataSource; - final TokenSessionInvalidator _sessionInvalidator; - - TokenInterceptor( - this.dio, { - required this.tokenLocalDataSource, - required TokenSessionInvalidator sessionInvalidator, - }) : _sessionInvalidator = sessionInvalidator; - - @override - void onRequest( - RequestOptions options, - RequestInterceptorHandler handler, - ) async { - try { - final token = await tokenLocalDataSource.getToken(); - - options.headers['Authorization'] = 'Bearer ${token.accessToken}'; - } catch (error) { - AppLogger.debug( - 'token load failed for request errorType=${error.runtimeType}', - ); - if (!_allowsMissingToken(options.path)) { - return handler.reject( - DioException( - requestOptions: options, - error: error, - message: _tokenUnavailableMessage, - ), - ); - } - } - handler.next(options); - } - - @override - void onError(DioException err, ErrorInterceptorHandler handler) async { - if (_shouldRefreshToken(err)) { - final response = err.response; - // if hasn't not refreshing yet, let's start it - _requestsNeedRetry.add( - _RequestNeedingRetry( - dio: dio, - options: err.requestOptions, - handler: handler, - ), - ); - - if (!_isRefreshing) { - _isRefreshing = true; - - // call api refresh token - final isRefreshSuccess = await _refreshToken(); - - try { - if (isRefreshSuccess) { - while (_requestsNeedRetry.isNotEmpty) { - final requestsNeedRetry = List.of(_requestsNeedRetry); - _requestsNeedRetry.clear(); - await _retryRequests(requestsNeedRetry); - } - } else { - for (final requestNeedRetry in _requestsNeedRetry) { - requestNeedRetry.handler.reject( - DioException( - requestOptions: requestNeedRetry.options, - response: response, - type: err.type, - error: err.error, - message: err.message, - ), - ); - } - _requestsNeedRetry.clear(); - // Force a local logout when the refresh token is rejected. The full - // sign-out use case may make authenticated cleanup calls, which can - // deadlock while the interceptor is already refreshing. - await _signOutLocally(); - } - } finally { - _isRefreshing = false; - } - } - } else { - // ignore other error is not unauthorized - return handler.next(err); - } - } - - bool _shouldRefreshToken(DioException err) { - final response = err.response; - if (response?.statusCode != 401) { - return false; - } - - final requestOptions = err.requestOptions; - return requestOptions.path != _refreshTokenPath && - requestOptions.extra[_retryAfterRefreshKey] != true; - } - - bool _allowsMissingToken(String path) { - return path == Endpoint.signIn || - path == Endpoint.signUp || - path == Endpoint.signInWithGoogle || - path == Endpoint.signInWithApple || - path == _refreshTokenPath; - } - - Future _refreshToken() async { - try { - final tokenEntity = await tokenLocalDataSource.getToken(); - final refreshToken = tokenEntity.refreshToken; - - final res = await dio.get( - _refreshTokenPath, - options: Options( - extra: {_retryAfterRefreshKey: true}, - headers: {'Authorization-refresh': 'Bearer $refreshToken'}, - ), - ); - if (res.statusCode == 200) { - AppLogger.debug('token refreshing success'); - final accessToken = res.headers.value('authorization'); - final refreshedRefreshToken = res.headers.value( - 'authorization-refresh', - ); - if (accessToken == null || refreshedRefreshToken == null) { - throw StateError( - 'Refresh response must include authorization and authorization-refresh headers', - ); - } - await tokenLocalDataSource.storeTokens( - TokenEntity( - accessToken: accessToken, - refreshToken: refreshedRefreshToken, - ), - ); - return true; - } else { - AppLogger.debug( - 'refresh token failed status=${res.statusCode} ' - 'message=${res.statusMessage}', - ); - return false; - } - } catch (error) { - AppLogger.debug('refresh token failed errorType=${error.runtimeType}'); - return false; - } - } - - Future _retryRequests(List<_RequestNeedingRetry> requests) async { - await Future.wait( - requests.map((requestNeedRetry) async { - final options = requestNeedRetry.options; - options.extra[_retryAfterRefreshKey] = true; - - try { - final response = await requestNeedRetry.dio.fetch(options); - requestNeedRetry.handler.resolve(response); - } on DioException catch (error) { - requestNeedRetry.handler.reject(error); - } catch (error) { - requestNeedRetry.handler.reject( - DioException(requestOptions: options, error: error), - ); - } - }), - ); - } - - Future _signOutLocally() async { - await _sessionInvalidator.signOutLocally(); - } - - @override - void onResponse(Response response, ResponseInterceptorHandler handler) { - handler.next(response); - } -} - -class _RequestNeedingRetry { - const _RequestNeedingRetry({ - required this.dio, - required this.options, - required this.handler, - }); - - final Dio dio; - final RequestOptions options; - final ErrorInterceptorHandler handler; -} diff --git a/lib/core/dio/interceptors/token_session_invalidator.dart b/lib/core/dio/interceptors/token_session_invalidator.dart deleted file mode 100644 index 1e07debc..00000000 --- a/lib/core/dio/interceptors/token_session_invalidator.dart +++ /dev/null @@ -1,3 +0,0 @@ -abstract interface class TokenSessionInvalidator { - Future signOutLocally(); -} diff --git a/lib/core/dio/transformers/logging_transformer.dart b/lib/core/dio/transformers/logging_transformer.dart deleted file mode 100644 index b5fe4714..00000000 --- a/lib/core/dio/transformers/logging_transformer.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:on_time_front/core/logging/app_logger.dart'; - -/// A wrapper transformer that logs the exact serialized request payload -/// produced by Dio's inner transformer. This reflects the precise string -/// sent over the wire (JSON, form-url-encoded, etc.). -class LoggingTransformer implements Transformer { - final Transformer _inner; - - LoggingTransformer({Transformer? inner}) - : _inner = inner ?? BackgroundTransformer(); - - @override - Future transformRequest(RequestOptions options) async { - final body = await _inner.transformRequest(options); - AppLogger.debug( - 'Serialized request body=${AppLogger.omitted} ' - '${options.method} ${AppLogger.redactUri(options.uri)} bytes=${body.length}', - ); - return body; - } - - @override - Future transformResponse( - RequestOptions options, ResponseBody responseBody) async { - return _inner.transformResponse(options, responseBody); - } -} diff --git a/lib/core/logging/app_logger.dart b/lib/core/logging/app_logger.dart index f76812ee..8b9fa6e5 100644 --- a/lib/core/logging/app_logger.dart +++ b/lib/core/logging/app_logger.dart @@ -93,23 +93,17 @@ final class AppLogger { if (value == null) continue; visibleEntries.add('$key=${redactValueForKey(key, value)}'); } - return [ - 'keys=${values.length}', - ...visibleEntries, - ].join(' '); + return ['keys=${values.length}', ...visibleEntries].join(' '); } static String redactText(String message) { var result = message.replaceAllMapped( - RegExp( - r'\bBearer\s+[A-Za-z0-9._~+/=-]+', - caseSensitive: false, - ), + RegExp(r'\bBearer\s+[A-Za-z0-9._~+/=-]+', caseSensitive: false), (_) => 'Bearer $redacted', ); result = result.replaceAllMapped( RegExp( - r'\b(authorization(?:-refresh)?|access[_-]?token|refresh[_-]?token|firebase[_-]?token|fcm[_-]?token|id[_-]?token|oauth[_-]?token|token)\b\s*[:=]\s*([^,\s}\]]+)', + r'\b(authorization(?:-refresh)?|access[_-]?token|refresh[_-]?token|id[_-]?token|oauth[_-]?token|token)\b\s*[:=]\s*([^,\s}\]]+)', caseSensitive: false, ), (match) => '${match.group(1)}=$redacted', @@ -123,8 +117,6 @@ final class AppLogger { normalized == 'authorizationrefresh' || normalized == 'accessToken'.toLowerCase() || normalized == 'refreshtoken' || - normalized == 'firebasetoken' || - normalized == 'fcmtoken' || normalized == 'idtoken' || normalized == 'oauthtoken' || normalized.endsWith('secret') || diff --git a/lib/core/services/alarm_scheduler_service.dart b/lib/core/services/alarm_scheduler_service.dart index 25ac04de..ced06eed 100644 --- a/lib/core/services/alarm_scheduler_service.dart +++ b/lib/core/services/alarm_scheduler_service.dart @@ -226,7 +226,9 @@ class AlarmSchedulerService { 'nativeAlarmId': record.nativeAlarmId ?? stableAlarmId(record.scheduleId), 'provider': record.provider.wireValue, 'title': record.scheduleTitle, - 'body': 'It is time to get ready.', + 'body': record.payload['notificationTimeZone'] == null + ? 'It is time to get ready.' + : 'Schedule time zone: ${record.payload['notificationTimeZone']}', 'payload': record.payload, }; } diff --git a/lib/core/services/detailed_notification_preference_service.dart b/lib/core/services/detailed_notification_preference_service.dart new file mode 100644 index 00000000..d3dc46b3 --- /dev/null +++ b/lib/core/services/detailed_notification_preference_service.dart @@ -0,0 +1,22 @@ +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/core/constants/local_profile.dart'; +import 'package:on_time_front/core/database/database.dart'; + +@lazySingleton +class DetailedNotificationPreferenceService { + DetailedNotificationPreferenceService(this._database); + + final AppDatabase _database; + + Future getEnabled() async { + final settings = await _database.userDao.getAlarmSettings(localProfileId); + return settings.detailedNotificationContent; + } + + Future setEnabled(bool enabled) { + return _database.userDao.updateDetailedNotificationContent( + userId: localProfileId, + enabled: enabled, + ); + } +} diff --git a/lib/core/services/device_info_service/device_info_service_mobile.dart b/lib/core/services/device_info_service/device_info_service_mobile.dart index cd052752..1fe0c0de 100644 --- a/lib/core/services/device_info_service/device_info_service_mobile.dart +++ b/lib/core/services/device_info_service/device_info_service_mobile.dart @@ -10,7 +10,8 @@ class DeviceInfoService { return PlatformType.ios; } else { throw UnimplementedError( - 'DeviceInfoService is not supported on this platform.'); + 'DeviceInfoService is not supported on this platform.', + ); } } diff --git a/lib/core/services/device_info_service/device_info_service_unsupported.dart b/lib/core/services/device_info_service/device_info_service_unsupported.dart index 532c0b00..760d3787 100644 --- a/lib/core/services/device_info_service/device_info_service_unsupported.dart +++ b/lib/core/services/device_info_service/device_info_service_unsupported.dart @@ -2,11 +2,14 @@ import 'package:on_time_front/core/services/device_info_service/shared.dart'; class DeviceInfoService { static PlatformType get platformType => throw UnimplementedError( - 'DeviceInfoService is not supported on this platform.'); + 'DeviceInfoService is not supported on this platform.', + ); static OsType get osType => throw UnimplementedError( - 'DeviceInfoService is not supported on this platform.'); + 'DeviceInfoService is not supported on this platform.', + ); static bool get isInStandaloneMode => throw UnimplementedError( - 'DeviceInfoService is not supported on this platform.'); + 'DeviceInfoService is not supported on this platform.', + ); } diff --git a/lib/core/services/device_info_service/shared.dart b/lib/core/services/device_info_service/shared.dart index 838862e2..663874aa 100644 --- a/lib/core/services/device_info_service/shared.dart +++ b/lib/core/services/device_info_service/shared.dart @@ -2,17 +2,6 @@ export 'device_info_service_unsupported.dart' if (dart.library.html) 'device_info_service_web.dart' if (dart.library.io) 'device_info_service_mobile.dart'; -enum PlatformType { - android, - ios, - web, -} +enum PlatformType { android, ios, web } -enum OsType { - android, - ios, - macos, - windows, - linux, - unknown, -} +enum OsType { android, ios, macos, windows, linux, unknown } diff --git a/lib/core/services/fallback_alarm_notification_service.dart b/lib/core/services/fallback_alarm_notification_service.dart index 1a691a9a..55d9a3ac 100644 --- a/lib/core/services/fallback_alarm_notification_service.dart +++ b/lib/core/services/fallback_alarm_notification_service.dart @@ -1,4 +1,3 @@ -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:injectable/injectable.dart'; import 'package:on_time_front/core/services/notification_service.dart'; import 'package:on_time_front/domain/entities/alarm_entities.dart'; diff --git a/lib/core/services/google_authentication_service.dart b/lib/core/services/google_authentication_service.dart deleted file mode 100644 index 5eabaa18..00000000 --- a/lib/core/services/google_authentication_service.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:google_sign_in/google_sign_in.dart'; -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/core/logging/app_logger.dart'; -import 'package:on_time_front/domain/entities/google_auth_credential.dart'; - -abstract interface class GoogleAuthenticationService { - Stream get authenticationCredentials; - - bool get supportsAuthenticate; - - Future initialize(); - - Future authenticate(); - - Future disconnect(); -} - -class GoogleAuthenticationCanceledException implements Exception { - const GoogleAuthenticationCanceledException(); -} - -@Singleton(as: GoogleAuthenticationService) -class GoogleSignInAuthenticationService implements GoogleAuthenticationService { - GoogleSignInAuthenticationService({@ignoreParam GoogleSignIn? googleSignIn}) - : _googleSignIn = googleSignIn ?? GoogleSignIn.instance; - - static const _googleIosClientId = - '456571312261-r35ah9qi0qaq7al007e2db0e0jmjcmb4.apps.googleusercontent.com'; - static const _googleServerClientId = - '456571312261-5kuf2r6i5i7lqjr7qealv06sdgkn3hcp.apps.googleusercontent.com'; - static const _googleScopes = ['email', 'profile']; - - final GoogleSignIn _googleSignIn; - Future? _initialization; - - @override - Stream get authenticationCredentials => _googleSignIn - .authenticationEvents - .where((event) => event is GoogleSignInAuthenticationEventSignIn) - .cast() - .map((event) => _credentialFromAccount(event.user)); - - @override - bool get supportsAuthenticate => _googleSignIn.supportsAuthenticate(); - - @override - Future initialize() { - return _initialization ??= _initialize(); - } - - Future _initialize() async { - await _googleSignIn.initialize( - clientId: _googleClientId, - serverClientId: _googleServerClientId, - ); - } - - @override - Future authenticate() async { - try { - await initialize(); - final account = await _googleSignIn.authenticate( - scopeHint: _googleScopes, - ); - return _credentialFromAccount(account); - } on GoogleSignInException catch (error) { - if (error.code == GoogleSignInExceptionCode.canceled) { - throw const GoogleAuthenticationCanceledException(); - } - rethrow; - } - } - - @override - Future disconnect() async { - try { - await _googleSignIn.disconnect(); - AppLogger.debug('Google Sign-In disconnected'); - } catch (error) { - AppLogger.debug( - 'Google Sign-In disconnect failed errorType=${error.runtimeType}', - ); - } - } - - GoogleAuthCredential _credentialFromAccount(GoogleSignInAccount account) { - final idToken = account.authentication.idToken; - if (idToken == null) { - throw Exception('Google ID Token is null'); - } - return GoogleAuthCredential(idToken: idToken); - } - - String? get _googleClientId { - if (kIsWeb) return null; - return defaultTargetPlatform == TargetPlatform.iOS - ? _googleIosClientId - : null; - } -} diff --git a/lib/core/services/local_time_zone_service.dart b/lib/core/services/local_time_zone_service.dart new file mode 100644 index 00000000..3e362016 --- /dev/null +++ b/lib/core/services/local_time_zone_service.dart @@ -0,0 +1,20 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +abstract final class LocalTimeZoneService { + static const _channel = MethodChannel('on_time_front/native_alarm'); + + static Future current() async { + if (kIsWeb) return 'UTC'; + try { + final identifier = await _channel.invokeMethod( + 'getLocalTimeZone', + ); + return identifier == null || identifier.isEmpty ? 'UTC' : identifier; + } catch (_) { + // A missing platform binding/plugin must not prevent local scheduling or + // recovery mode from starting. UTC is the deterministic safe fallback. + return 'UTC'; + } + } +} diff --git a/lib/core/services/notification_content.dart b/lib/core/services/notification_content.dart index 3f963004..c50d411f 100644 --- a/lib/core/services/notification_content.dart +++ b/lib/core/services/notification_content.dart @@ -3,42 +3,6 @@ import 'dart:convert'; import 'package:on_time_front/core/services/notification_routing.dart'; import 'package:on_time_front/domain/entities/alarm_entities.dart'; -class NotificationDisplayContent { - const NotificationDisplayContent({ - required this.title, - required this.body, - required this.payload, - }); - - final String title; - final String body; - final String payload; -} - -NotificationDisplayContent? remoteNotificationDisplayContent({ - required Map data, - String? notificationTitle, - String? notificationBody, -}) { - final title = notificationTitle ?? data['title'] ?? data['Title']; - final body = - notificationBody ?? - data['content'] ?? - data['body'] ?? - data['Content'] ?? - data['Body']; - - if (title == null && body == null) { - return null; - } - - return NotificationDisplayContent( - title: title?.toString() ?? '알림', - body: body?.toString() ?? '', - payload: jsonEncode(data), - ); -} - String? encodeLocalNotificationPayload(Map? payload) { return payload == null ? null : jsonEncode(payload); } diff --git a/lib/core/services/notification_request_service/notification_request_mobile_service.dart b/lib/core/services/notification_request_service/notification_request_mobile_service.dart deleted file mode 100644 index ccb013e2..00000000 --- a/lib/core/services/notification_request_service/notification_request_mobile_service.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:firebase_messaging/firebase_messaging.dart'; - -Future requestNotificationPermission() { - final settings = FirebaseMessaging.instance.requestPermission( - alert: true, - badge: true, - sound: true, - provisional: false, - announcement: false, - carPlay: false, - criticalAlert: false, - ); - return settings.then((value) => value.authorizationStatus.toString()); -} diff --git a/lib/core/services/notification_request_service/notification_request_web_service.dart b/lib/core/services/notification_request_service/notification_request_web_service.dart deleted file mode 100644 index 03bdbe10..00000000 --- a/lib/core/services/notification_request_service/notification_request_web_service.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:on_time_front/core/services/js_interop_service.dart'; - -Future requestNotificationPermission() { - return JsInteropService.requestNotificationPermission(); -} diff --git a/lib/core/services/notification_request_service/shared.dart b/lib/core/services/notification_request_service/shared.dart deleted file mode 100644 index 1fed1f39..00000000 --- a/lib/core/services/notification_request_service/shared.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'notification_request_mobile_service.dart' - if (dart.library.html) 'notification_request_web_service.dart'; diff --git a/lib/core/services/notification_routing.dart b/lib/core/services/notification_routing.dart index 093e702b..94c837bf 100644 --- a/lib/core/services/notification_routing.dart +++ b/lib/core/services/notification_routing.dart @@ -28,15 +28,6 @@ bool isScheduleAlarmPayload(Map? payload) { (promptVariant == 'alarm' && payload['scheduleId'] != null); } -bool isScheduleAlarmMessagePayload({ - required Map data, - String? title, -}) { - return isScheduleAlarmPayload(data) || - title == '약속 알림' || - title == 'Schedule alarm'; -} - NotificationRouteTarget? notificationRouteForPayloadString(String? payload) { if (payload == null) return null; diff --git a/lib/core/services/notification_service.dart b/lib/core/services/notification_service.dart index 3aa48dc2..c3a3b461 100644 --- a/lib/core/services/notification_service.dart +++ b/lib/core/services/notification_service.dart @@ -1,83 +1,47 @@ -import 'dart:async'; import 'dart:io' show Platform; import 'dart:ui' as ui; + import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; -import 'package:firebase_core/firebase_core.dart'; - -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:on_time_front/core/logging/app_logger.dart'; -import 'package:on_time_front/core/services/js_interop_service.dart'; import 'package:on_time_front/core/services/notification_content.dart'; -import 'package:on_time_front/core/services/notification_routing.dart'; import 'package:on_time_front/core/services/notification_tap_router.dart'; -import 'package:on_time_front/core/services/notification_token_registrar.dart'; import 'package:on_time_front/domain/entities/alarm_entities.dart'; import 'package:permission_handler/permission_handler.dart' as permission_handler; import 'package:timezone/data/latest.dart' as tz_data; import 'package:timezone/timezone.dart' as tz; -@pragma('vm:entry-point') -Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { - AppLogger.configureFlutterDebugPrint(); - AppLogger.debug('[FCM Background Handler] message received'); - - try { - await Firebase.initializeApp(); - } catch (e) { - AppLogger.debug('[FCM Background Handler] Firebase init failed: $e'); - } - await NotificationService.instance.setupFlutterNotifications(); - await NotificationService.instance.showNotification(message); -} +enum AuthorizationStatus { authorized, denied, notDetermined, provisional } class NotificationService { NotificationService._({ - FirebaseMessaging? messaging, FlutterLocalNotificationsPlugin? localNotifications, + NotificationTapRouter? notificationTapRouter, String Function()? localeProvider, bool? isIOSOverride, - FcmTokenRegistrar? fcmTokenRegistrar, - NotificationTapRouter? notificationTapRouter, - Stream? onMessage, - Stream? onMessageOpenedApp, - }) : _messaging = messaging ?? FirebaseMessaging.instance, - _localNotifications = + }) : _localNotifications = localNotifications ?? FlutterLocalNotificationsPlugin(), - _localeProvider = localeProvider, - _isIOSOverride = isIOSOverride, - _fcmTokenRegistrar = fcmTokenRegistrar ?? const NoopFcmTokenRegistrar(), _notificationTapRouter = notificationTapRouter ?? const NoopNotificationTapRouter(), - _onMessage = onMessage ?? FirebaseMessaging.onMessage, - _onMessageOpenedApp = - onMessageOpenedApp ?? FirebaseMessaging.onMessageOpenedApp; + _localeProvider = localeProvider, + _isIOSOverride = isIOSOverride; @visibleForTesting NotificationService.test({ - required FirebaseMessaging messaging, required FlutterLocalNotificationsPlugin localNotifications, + NotificationTapRouter? notificationTapRouter, String Function()? localeProvider, bool isFlutterLocalNotificationsInitialized = false, bool isTimezoneInitialized = false, bool? isIOSOverride, - FcmTokenRegistrar? fcmTokenRegistrar, - NotificationTapRouter? notificationTapRouter, - Stream? onMessage, - Stream? onMessageOpenedApp, - }) : _messaging = messaging, - _localNotifications = localNotifications, - _localeProvider = localeProvider, - _isIOSOverride = isIOSOverride, - _fcmTokenRegistrar = fcmTokenRegistrar ?? const NoopFcmTokenRegistrar(), + }) : _localNotifications = localNotifications, _notificationTapRouter = notificationTapRouter ?? const NoopNotificationTapRouter(), - _onMessage = onMessage ?? FirebaseMessaging.onMessage, - _onMessageOpenedApp = - onMessageOpenedApp ?? FirebaseMessaging.onMessageOpenedApp, + _localeProvider = localeProvider, + _isIOSOverride = isIOSOverride, _isFlutterLocalNotificationsInitialized = isFlutterLocalNotificationsInitialized, _isTimezoneInitialized = isTimezoneInitialized; @@ -87,391 +51,148 @@ class NotificationService { 'on_time_front/native_alarm', ); - final FirebaseMessaging _messaging; final FlutterLocalNotificationsPlugin _localNotifications; + NotificationTapRouter _notificationTapRouter; final String Function()? _localeProvider; final bool? _isIOSOverride; - FcmTokenRegistrar _fcmTokenRegistrar; - NotificationTapRouter _notificationTapRouter; - final Stream _onMessage; - final Stream _onMessageOpenedApp; bool _isFlutterLocalNotificationsInitialized = false; bool _isTimezoneInitialized = false; - bool _isInitialized = false; - bool _initialMessageHandled = false; Future? _initializationFuture; - StreamSubscription? _foregroundMessageSubscription; - StreamSubscription? _openedAppMessageSubscription; - StreamSubscription? _tokenRefreshSubscription; bool get _isIOS => !kIsWeb && (_isIOSOverride ?? Platform.isIOS); - String get _locale { - final localeProvider = _localeProvider; - if (localeProvider != null) { - return localeProvider(); - } - try { - final locale = ui.PlatformDispatcher.instance.locale; - return locale.languageCode; - } catch (e) { - return 'ko'; - } - } + String get _locale => + _localeProvider?.call() ?? + ui.PlatformDispatcher.instance.locale.languageCode; - void configureDelegates({ - required FcmTokenRegistrar fcmTokenRegistrar, + void configureDelegate({ required NotificationTapRouter notificationTapRouter, }) { - _fcmTokenRegistrar = fcmTokenRegistrar; _notificationTapRouter = notificationTapRouter; } Future initialize() { - if (_isInitialized) { - return Future.value(); - } - - final initializationFuture = _initializationFuture; - if (initializationFuture != null) { - return initializationFuture; - } - - final future = _initialize(); - _initializationFuture = future; - return future; + return _initializationFuture ??= _initialize().whenComplete(() { + _initializationFuture = null; + }); } Future _initialize() async { - try { - try { - FirebaseMessaging.onBackgroundMessage( - _firebaseMessagingBackgroundHandler, - ); - AppLogger.debug('[FCM] Background message handler 등록 완료'); - } catch (e) { - AppLogger.debug('[FCM] Background message handler 등록 실패: $e'); - } - - await _requestPermission(); - await setupFlutterNotifications(); - await _setupMessageHandlers(); - - await requestNotificationToken(); - - if (_isIOS) { - await _messaging.setForegroundNotificationPresentationOptions( - alert: true, - badge: true, - sound: true, - ); - AppLogger.debug('[FCM] iOS 포그라운드 알림 표시 옵션 설정 완료'); - } - - _isInitialized = true; - } finally { - _initializationFuture = null; - } + await setupFlutterNotifications(); + await _ensureTimezoneInitialized(); } Future checkNotificationPermission() async { if (_isIOS) { - final localPermission = await _checkDarwinLocalNotificationPermission(); - if (localPermission != null) { - return localPermission - ? AuthorizationStatus.authorized - : AuthorizationStatus.denied; - } - } - final settings = await _messaging.getNotificationSettings(); - return settings.authorizationStatus; - } - - Future requestPermission() async { - if (kIsWeb) { - await JsInteropService.requestNotificationPermission(); - final settings = await _messaging.getNotificationSettings(); - return settings.authorizationStatus; - } else if (_isIOS) { - final settings = await _messaging.requestPermission( - alert: true, - badge: true, - sound: true, - provisional: false, - announcement: false, - carPlay: false, - criticalAlert: false, - ); - - AppLogger.debug( - '[FCM] Permission status: ${settings.authorizationStatus}', - ); - final localPermission = await _requestDarwinLocalNotificationPermission(); - AppLogger.debug( - '[FallbackAlarm] iOS local notification permission=$localPermission', - ); - if (localPermission == false) { - return AuthorizationStatus.denied; - } - return settings.authorizationStatus; - } else { - final settings = await _messaging.requestPermission( - alert: true, - badge: true, - sound: true, - provisional: false, - announcement: false, - carPlay: false, - criticalAlert: false, - ); - - AppLogger.debug( - '[FCM] Permission status: ${settings.authorizationStatus}', - ); - return settings.authorizationStatus; + final permissions = await _localNotifications + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >() + ?.checkPermissions(); + if (permissions == null) return AuthorizationStatus.notDetermined; + return permissions.isEnabled + ? AuthorizationStatus.authorized + : AuthorizationStatus.denied; } - } - - Future openNotificationSettings() async { - try { - final opened = await permission_handler.openAppSettings(); - AppLogger.debug('[FCM] 앱 설정 열기: $opened'); - return opened; - } catch (e) { - AppLogger.debug('[FCM] 앱 설정 열기 실패: $e'); - return false; + if (kIsWeb) return AuthorizationStatus.denied; + final status = await permission_handler.Permission.notification.status; + if (status.isGranted || status.isLimited || status.isProvisional) { + return AuthorizationStatus.authorized; } + if (status.isDenied) return AuthorizationStatus.notDetermined; + return AuthorizationStatus.denied; } - Future _requestPermission() async { - if (kIsWeb) { - await JsInteropService.requestNotificationPermission(); - } else { - final settings = await _messaging.requestPermission( - alert: true, - badge: true, - sound: true, - provisional: false, - announcement: false, - carPlay: false, - criticalAlert: false, - ); - - AppLogger.debug( - '[FCM] Permission status: ${settings.authorizationStatus}', - ); + Future requestPermission() async { + if (_isIOS) { + final granted = await _localNotifications + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >() + ?.requestPermissions(alert: true, badge: true, sound: true); + return granted == true + ? AuthorizationStatus.authorized + : AuthorizationStatus.denied; } + if (kIsWeb) return AuthorizationStatus.denied; + final granted = await _localNotifications + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.requestNotificationsPermission(); + return granted == false + ? AuthorizationStatus.denied + : AuthorizationStatus.authorized; } - Future requestNotificationToken() async { - try { - final token = await _messaging.getToken(); - AppLogger.debug( - '[FCM] FCM token acquired token=${AppLogger.redactToken(token)}', - ); - - if (token != null) { - try { - await _fcmTokenRegistrar.registerToken(token); - AppLogger.debug('[FCM] FCM Token 서버 등록 완료'); - } catch (e) { - AppLogger.debug('[FCM] FCM Token 서버 등록 실패: $e'); - } - } - - _tokenRefreshSubscription ??= _messaging.onTokenRefresh.listen(( - newToken, - ) { - AppLogger.debug( - '[FCM] token refreshed token=${AppLogger.redactToken(newToken)}', - ); - _fcmTokenRegistrar.registerToken(newToken).catchError((e) { - AppLogger.debug( - '[FCM] refreshed token server registration failed: $e', - ); - }); - }); - } catch (e) { - AppLogger.debug('[FCM] Token 요청 오류: $e'); - } - } + Future openNotificationSettings() => + permission_handler.openAppSettings(); Future setupFlutterNotifications() async { - if (_isFlutterLocalNotificationsInitialized) { - return; - } + if (_isFlutterLocalNotificationsInitialized) return; - // android setup - const channel = AndroidNotificationChannel( + const generalChannel = AndroidNotificationChannel( 'high_importance_channel', - 'High Importance Notifications', - description: 'This channel is used for important notifications.', + 'Important notifications', + description: 'OnTime local notifications.', importance: Importance.high, ); - - await _localNotifications - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >() - ?.createNotificationChannel(channel); - - const alarmChannel = AndroidNotificationChannel( + const scheduleChannel = AndroidNotificationChannel( 'scheduled_notification_channel', 'Schedule notifications', - description: 'Schedule preparation notifications.', + description: 'OnTime schedule preparation notifications.', importance: Importance.max, ); - - await _localNotifications + final android = _localNotifications .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin - >() - ?.createNotificationChannel(alarmChannel); - - const initializationSettingsAndroid = AndroidInitializationSettings( - '@mipmap/ic_launcher', - ); - - // ios setup - const initializationSettingsDarwin = DarwinInitializationSettings( - requestAlertPermission: false, - requestBadgePermission: false, - requestSoundPermission: false, - ); + >(); + await android?.createNotificationChannel(generalChannel); + await android?.createNotificationChannel(scheduleChannel); - final initializationSettings = InitializationSettings( - android: initializationSettingsAndroid, - iOS: initializationSettingsDarwin, - ); - - // flutter notification setup await _localNotifications.initialize( - settings: initializationSettings, - onDidReceiveNotificationResponse: (details) { - _handleLocalNotificationTap(details.payload); + settings: const InitializationSettings( + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + iOS: DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + ), + ), + onDidReceiveNotificationResponse: (response) { + _notificationTapRouter.routeLocalNotificationTap(response.payload); }, ); - _isFlutterLocalNotificationsInitialized = true; } - Future showNotification(RemoteMessage message) async { - if (_isScheduleAlarmMessage(message)) { - AppLogger.debug( - '[FCM] schedule_alarm push suppressed; native/system alarm handles alarm UI', - ); - return; - } - - try { - await setupFlutterNotifications(); - } catch (e) { - AppLogger.debug('[FCM] setupFlutterNotifications 오류: $e'); - return; - } - - final content = remoteNotificationDisplayContent( - data: message.data, - notificationTitle: message.notification?.title, - notificationBody: message.notification?.body, - ); - - if (content == null) { - return; - } - - try { - final notificationId = - (content.title + - content.body + - DateTime.now().millisecondsSinceEpoch.toString()) - .hashCode; - - await _localNotifications.show( - id: notificationId, - title: content.title, - body: content.body, - notificationDetails: NotificationDetails( - android: const AndroidNotificationDetails( - 'high_importance_channel', - 'High Importance Notifications', - channelDescription: - 'This channel is used for important notifications.', - importance: Importance.high, - priority: Priority.high, - icon: '@mipmap/ic_launcher', - playSound: true, - enableVibration: true, - ), - iOS: const DarwinNotificationDetails( - presentAlert: true, - presentBadge: true, - presentSound: true, - ), - ), - payload: content.payload, - ); - } catch (error) { - AppLogger.debug( - '[FCM] local notification display failed ' - 'errorType=${error.runtimeType}', - ); - } - } - Future showLocalNotification({ required String title, required String body, Map? payload, }) async { - if (isScheduleAlarmPayload(payload)) { - AppLogger.debug( - '[FCM] schedule_alarm local notification suppressed; native/system alarm handles alarm UI', - ); - return; - } - - try { - await setupFlutterNotifications(); - } catch (e) { - AppLogger.debug('[FCM] setupFlutterNotifications 오류: $e'); - return; - } - - try { - final notificationId = - (title + body + DateTime.now().millisecondsSinceEpoch.toString()) - .hashCode; - await _localNotifications.show( - id: notificationId, - title: title, - body: body, - notificationDetails: NotificationDetails( - android: const AndroidNotificationDetails( - 'high_importance_channel', - 'High Importance Notifications', - channelDescription: - 'This channel is used for important notifications.', - importance: Importance.high, - priority: Priority.high, - icon: '@mipmap/ic_launcher', - playSound: true, - enableVibration: true, - ), - iOS: const DarwinNotificationDetails( - presentAlert: true, - presentBadge: true, - presentSound: true, - ), + await setupFlutterNotifications(); + await _localNotifications.show( + id: Object.hash(title, body, DateTime.now().microsecondsSinceEpoch), + title: title, + body: body, + notificationDetails: const NotificationDetails( + android: AndroidNotificationDetails( + 'high_importance_channel', + 'Important notifications', + channelDescription: 'OnTime local notifications.', + importance: Importance.high, + priority: Priority.high, + icon: '@mipmap/ic_launcher', ), - payload: encodeLocalNotificationPayload(payload), - ); - } catch (error) { - AppLogger.debug( - '[FCM] local notification display failed ' - 'errorType=${error.runtimeType}', - ); - } + iOS: DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ), + ), + payload: encodeLocalNotificationPayload(payload), + ); } Future showPreparationStepNotification({ @@ -480,20 +201,14 @@ class NotificationService { required String scheduleId, required String stepId, }) async { - // Disable case-3 alerts while app is in foreground. if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) { - AppLogger.debug( - '[FCM] preparation step notification skipped in foreground', - ); return; } - await showLocalNotification( - title: preparationStepNotificationTitle( - scheduleName: scheduleName, - preparationName: preparationName, - ), - body: preparationStepNotificationBody(languageCode: _locale), + title: _locale == 'ko' ? '준비 단계가 바뀌었어요' : 'Preparation updated', + body: _locale == 'ko' + ? 'OnTime을 열어 다음 단계를 확인하세요.' + : 'Open OnTime to see the next step.', payload: preparationStepNotificationPayload( scheduleId: scheduleId, stepId: stepId, @@ -507,12 +222,6 @@ class NotificationService { permission == AuthorizationStatus.provisional; } - bool _isScheduleAlarmMessage(RemoteMessage message) { - final data = message.data; - final title = message.notification?.title ?? data['title'] ?? data['Title']; - return isScheduleAlarmMessagePayload(data: data, title: title?.toString()); - } - Future scheduleFallbackAlarm(ScheduledAlarmRecord record) async { if (!await hasNotificationPermission()) { throw const AlarmSchedulingException( @@ -521,34 +230,32 @@ class NotificationService { message: 'Notification permission denied', ); } - await setupFlutterNotifications(); await _ensureTimezoneInitialized(); - - final notificationId = fallbackNotificationIdForRecord(record); - final scheduledAt = tz.TZDateTime.from(record.alarmTime, tz.local); - AppLogger.debug( - '[FallbackAlarm] schedule notificationId=$notificationId ' - 'scheduleId=${record.scheduleId} ' - 'scheduledAt=${scheduledAt.toIso8601String()} ' - 'mode=${AndroidScheduleMode.inexactAllowWhileIdle}', - ); + final detailed = record.payload['detailedNotificationContent'] == 'true'; + final notificationTimeZone = record.payload['notificationTimeZone']; await _localNotifications.zonedSchedule( - id: notificationId, - title: record.scheduleTitle, - body: fallbackAlarmNotificationBody(languageCode: _locale), - scheduledDate: scheduledAt, + id: fallbackNotificationIdForRecord(record), + title: detailed + ? record.scheduleTitle + : (_locale == 'ko' ? '일정 준비 시간이에요' : 'Time to prepare'), + body: detailed && notificationTimeZone != null + ? (_locale == 'ko' + ? '일정 시간대: $notificationTimeZone' + : 'Schedule time zone: $notificationTimeZone') + : (_locale == 'ko' + ? 'OnTime을 열어 일정을 확인하세요.' + : 'Open OnTime to review your schedule.'), + scheduledDate: tz.TZDateTime.from(record.alarmTime, tz.local), notificationDetails: const NotificationDetails( android: AndroidNotificationDetails( 'scheduled_notification_channel', 'Schedule notifications', - channelDescription: 'Schedule preparation notifications.', + channelDescription: 'OnTime schedule preparation notifications.', importance: Importance.max, priority: Priority.max, category: AndroidNotificationCategory.reminder, icon: '@mipmap/ic_launcher', - playSound: true, - enableVibration: true, ), iOS: DarwinNotificationDetails( presentAlert: true, @@ -560,7 +267,6 @@ class NotificationService { androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, payload: encodeLocalNotificationPayload(record.payload), ); - await _logPendingNotificationCount(); } Future cancelFallbackNotification(int notificationId) async { @@ -568,51 +274,9 @@ class NotificationService { await _localNotifications.cancel(id: notificationId); } - Future _checkDarwinLocalNotificationPermission() async { - try { - final plugin = _localNotifications - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin - >(); - if (plugin == null) return null; - final enabled = await plugin.checkPermissions(); - AppLogger.debug('[FallbackAlarm] iOS local notification check=$enabled'); - return enabled?.isEnabled; - } catch (error) { - AppLogger.debug( - '[FallbackAlarm] iOS local notification check failed ' - 'errorType=${error.runtimeType}', - ); - return null; - } - } - - Future _requestDarwinLocalNotificationPermission() async { - try { - return await _localNotifications - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin - >() - ?.requestPermissions(alert: true, badge: true, sound: true); - } catch (error) { - AppLogger.debug( - '[FallbackAlarm] iOS local notification permission request failed ' - 'errorType=${error.runtimeType}', - ); - return null; - } - } - - Future _logPendingNotificationCount() async { - if (!_isIOS) return; - try { - final pending = await _localNotifications.pendingNotificationRequests(); - AppLogger.debug('[FallbackAlarm] pendingCount=${pending.length}'); - } catch (error) { - AppLogger.debug( - '[FallbackAlarm] pending count failed errorType=${error.runtimeType}', - ); - } + Future cancelAll() async { + await setupFlutterNotifications(); + await _localNotifications.cancelAll(); } Future _ensureTimezoneInitialized() async { @@ -625,72 +289,15 @@ class NotificationService { ); if (identifier != null && identifier.isNotEmpty) { tz.setLocalLocation(tz.getLocation(identifier)); - AppLogger.debug('[FallbackAlarm] timezone=$identifier'); } } on MissingPluginException { - AppLogger.debug('[FallbackAlarm] timezone plugin unavailable'); + AppLogger.debug('[LocalNotification] timezone plugin unavailable'); } on PlatformException catch (error) { AppLogger.debug( - '[FallbackAlarm] timezone lookup failed ' - 'code=${error.code} message=${error.message}', - ); - } catch (error) { - AppLogger.debug( - '[FallbackAlarm] timezone lookup failed ' - 'errorType=${error.runtimeType}', + '[LocalNotification] timezone failed code=${error.code}', ); } } _isTimezoneInitialized = true; } - - Future _setupMessageHandlers() async { - //foreground message - if (_foregroundMessageSubscription == null) { - _foregroundMessageSubscription = _onMessage.listen( - (message) { - try { - showNotification(message); - } catch (error) { - AppLogger.debug( - '[FCM Foreground] notification display failed ' - 'errorType=${error.runtimeType}', - ); - } - }, - onError: (error) { - AppLogger.debug('[FCM Foreground] 리스너 오류: $error'); - }, - cancelOnError: false, - ); - AppLogger.debug('[FCM] Foreground message handler 등록 완료'); - } - - // background message - if (_openedAppMessageSubscription == null) { - _openedAppMessageSubscription = _onMessageOpenedApp.listen((message) { - _handleBackgroundMessage(message); - }); - AppLogger.debug('[FCM] Background message handler 등록 완료'); - } - - // opened app - if (!_initialMessageHandled) { - final initialMessage = await _messaging.getInitialMessage(); - _initialMessageHandled = true; - if (initialMessage != null) { - _handleBackgroundMessage(initialMessage); - } - } - } - - void _handleLocalNotificationTap(String? payload) { - AppLogger.debug('[FCM] 알림 탭'); - _notificationTapRouter.routeLocalNotificationTap(payload); - } - - Future _handleBackgroundMessage(RemoteMessage message) async { - AppLogger.debug('[FCM] 백그라운드 메시지 처리'); - _notificationTapRouter.routeRemoteNotificationData(message.data); - } } diff --git a/lib/core/services/notification_tap_router.dart b/lib/core/services/notification_tap_router.dart index 176bee4b..13de8aa8 100644 --- a/lib/core/services/notification_tap_router.dart +++ b/lib/core/services/notification_tap_router.dart @@ -4,8 +4,6 @@ import 'package:on_time_front/core/services/notification_routing.dart'; abstract interface class NotificationTapRouter { void routeLocalNotificationTap(String? payload); - - void routeRemoteNotificationData(Map data); } class NoopNotificationTapRouter implements NotificationTapRouter { @@ -13,9 +11,6 @@ class NoopNotificationTapRouter implements NotificationTapRouter { @override void routeLocalNotificationTap(String? payload) {} - - @override - void routeRemoteNotificationData(Map data) {} } @Singleton(as: NotificationTapRouter) @@ -30,12 +25,6 @@ class NavigationNotificationTapRouter implements NotificationTapRouter { _pushTarget(target); } - @override - void routeRemoteNotificationData(Map data) { - final target = notificationRouteForData(data); - _pushTarget(target); - } - void _pushTarget(NotificationRouteTarget? target) { if (target == null) return; _navigationService.push(target.path, extra: target.extra); diff --git a/lib/core/services/notification_token_registrar.dart b/lib/core/services/notification_token_registrar.dart deleted file mode 100644 index 40acb189..00000000 --- a/lib/core/services/notification_token_registrar.dart +++ /dev/null @@ -1,10 +0,0 @@ -abstract interface class FcmTokenRegistrar { - Future registerToken(String firebaseToken); -} - -class NoopFcmTokenRegistrar implements FcmTokenRegistrar { - const NoopFcmTokenRegistrar(); - - @override - Future registerToken(String firebaseToken) async {} -} diff --git a/lib/core/services/product_analytics_service.dart b/lib/core/services/product_analytics_service.dart deleted file mode 100644 index 2384db46..00000000 --- a/lib/core/services/product_analytics_service.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:firebase_analytics/firebase_analytics.dart'; -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/core/services/app_metadata_service.dart'; -import 'package:on_time_front/core/services/device_info_service/shared.dart'; -import 'package:on_time_front/domain/entities/analytics_preference.dart'; -import 'package:on_time_front/domain/entities/product_usage_event.dart'; - -abstract interface class AnalyticsProviderClient { - Future setAnalyticsCollectionEnabled(bool enabled); - - Future logEvent({ - required String name, - required Map parameters, - }); - - Future setUserId(String? userId); -} - -@Singleton(as: AnalyticsProviderClient) -class FirebaseAnalyticsProviderClient implements AnalyticsProviderClient { - FirebaseAnalyticsProviderClient({@ignoreParam FirebaseAnalytics? analytics}) - : _analytics = analytics ?? FirebaseAnalytics.instance; - - final FirebaseAnalytics _analytics; - - @override - Future setAnalyticsCollectionEnabled(bool enabled) { - return _analytics.setAnalyticsCollectionEnabled(enabled); - } - - @override - Future logEvent({ - required String name, - required Map parameters, - }) { - return _analytics.logEvent(name: name, parameters: parameters); - } - - @override - Future setUserId(String? userId) { - return _analytics.setUserId(id: userId); - } -} - -@Singleton() -class ProductAnalyticsService { - ProductAnalyticsService({ - required AnalyticsProviderClient client, - required AppMetadataProvider appMetadataProvider, - @ignoreParam - bool collectionAllowedInBuild = const bool.fromEnvironment( - 'ONTIME_ANALYTICS_ENABLED', - ), - }) : _client = client, - _appMetadataProvider = appMetadataProvider, - _collectionAllowedInBuild = collectionAllowedInBuild; - - final AnalyticsProviderClient _client; - final AppMetadataProvider _appMetadataProvider; - final bool _collectionAllowedInBuild; - bool _collectionEnabled = false; - - Future applyPreference(AnalyticsPreference preference) async { - _collectionEnabled = - _collectionAllowedInBuild && - preference.isConfirmed && - preference.enabled; - await _client.setAnalyticsCollectionEnabled(_collectionEnabled); - } - - Future track(ProductUsageEvent event) async { - if (!_collectionEnabled) return; - final metadata = await _appMetadataProvider.getMetadata(); - await _client.logEvent( - name: event.name, - parameters: event.toAnalyticsParameters( - platform: _platformWireValue(), - appVersion: metadata.version, - ), - ); - } - - Future setUserAssociation(String? userId) { - return _client.setUserId(userId); - } - - String _platformWireValue() { - try { - switch (DeviceInfoService.platformType) { - case PlatformType.android: - return 'android'; - case PlatformType.ios: - return 'ios'; - case PlatformType.web: - return 'web'; - } - } catch (_) { - return 'unknown'; - } - } -} diff --git a/lib/core/time/civil_time_resolver.dart b/lib/core/time/civil_time_resolver.dart new file mode 100644 index 00000000..09d195e3 --- /dev/null +++ b/lib/core/time/civil_time_resolver.dart @@ -0,0 +1,105 @@ +import 'package:equatable/equatable.dart'; +import 'package:timezone/data/latest.dart' as tz_data; +import 'package:timezone/timezone.dart' as tz; + +class CivilTimeOccurrence extends Equatable { + const CivilTimeOccurrence({ + required this.offsetSeconds, + required this.instantUtc, + }); + + final int offsetSeconds; + final DateTime instantUtc; + + @override + List get props => [offsetSeconds, instantUtc]; +} + +abstract final class CivilTimeResolver { + static bool _initialized = false; + + /// Resolves a wall-clock selection in [timeZoneId] to every valid instant. + /// + /// A normal civil time has one occurrence, a spring-forward gap has none, + /// and a fall-back overlap has two. Results are ordered by absolute time so + /// the UI can present an unambiguous first/second occurrence choice. + static List resolve( + DateTime civilTime, + String timeZoneId, + ) { + _ensureInitialized(); + final location = _locationOrUtc(timeZoneId); + final civilAsUtc = DateTime.utc( + civilTime.year, + civilTime.month, + civilTime.day, + civilTime.hour, + civilTime.minute, + civilTime.second, + civilTime.millisecond, + civilTime.microsecond, + ); + + // Collect offsets used around the selected date, then round-trip each + // candidate instant. This avoids TZDateTime normalizing a nonexistent + // civil time and correctly preserves both sides of an overlap. + final candidateOffsets = {}; + for (var hours = -36; hours <= 36; hours++) { + final nearbyInstant = civilAsUtc.add(Duration(hours: hours)); + candidateOffsets.add( + tz.TZDateTime.from(nearbyInstant, location).timeZoneOffset.inSeconds, + ); + } + + final occurrences = []; + for (final offsetSeconds in candidateOffsets) { + final instant = civilAsUtc.subtract(Duration(seconds: offsetSeconds)); + final roundTrip = tz.TZDateTime.from(instant, location); + if (_hasSameCivilFields(civilTime, roundTrip)) { + occurrences.add( + CivilTimeOccurrence( + offsetSeconds: offsetSeconds, + instantUtc: instant, + ), + ); + } + } + occurrences.sort( + (left, right) => left.instantUtc.compareTo(right.instantUtc), + ); + return occurrences; + } + + static String formatUtcOffset(int offsetSeconds) { + final sign = offsetSeconds < 0 ? '-' : '+'; + final totalMinutes = offsetSeconds.abs() ~/ 60; + final hours = (totalMinutes ~/ 60).toString().padLeft(2, '0'); + final minutes = (totalMinutes % 60).toString().padLeft(2, '0'); + return 'UTC$sign$hours:$minutes'; + } + + static void _ensureInitialized() { + if (_initialized) return; + tz_data.initializeTimeZones(); + _initialized = true; + } + + static tz.Location _locationOrUtc(String timeZoneId) { + try { + return tz.getLocation(timeZoneId); + } catch (_) { + return tz.UTC; + } + } + + static bool _hasSameCivilFields(DateTime source, DateTime candidate) { + return source.year == candidate.year && + source.month == candidate.month && + source.day == candidate.day && + source.hour == candidate.hour && + source.minute == candidate.minute && + source.second == candidate.second && + source.millisecond == candidate.millisecond && + source.microsecond == candidate.microsecond; + } +} diff --git a/lib/core/utils/json_converters/duration_json_converters.dart b/lib/core/utils/json_converters/duration_json_converters.dart index 0ed1273a..2ff5c9a9 100644 --- a/lib/core/utils/json_converters/duration_json_converters.dart +++ b/lib/core/utils/json_converters/duration_json_converters.dart @@ -14,3 +14,35 @@ class DurationSqlConverter extends TypeConverter return value.inMilliseconds; } } + +class CivilDateTimeSqlConverter extends TypeConverter + with JsonTypeConverter { + const CivilDateTimeSqlConverter(); + + @override + DateTime fromSql(String fromDb) { + final parsed = DateTime.parse(fromDb); + return DateTime( + parsed.year, + parsed.month, + parsed.day, + parsed.hour, + parsed.minute, + parsed.second, + parsed.millisecond, + parsed.microsecond, + ); + } + + @override + String toSql(DateTime value) => DateTime( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.millisecond, + value.microsecond, + ).toIso8601String(); +} diff --git a/lib/core/validation/backend_constraints.dart b/lib/core/validation/backend_constraints.dart deleted file mode 100644 index b7f15e05..00000000 --- a/lib/core/validation/backend_constraints.dart +++ /dev/null @@ -1,58 +0,0 @@ -class BackendConstraints { - const BackendConstraints._(); - - static const int maxScheduleNameLength = 30; - static const int maxLongTextLength = 1000; - static const int maxMinuteValue = 1440; - static const int minPasswordLength = 8; - static const int maxPasswordLength = 64; - - static final RegExp deviceIdPattern = RegExp(r'^[A-Za-z0-9._:-]{16,128}$'); - - static String trimToMaxLength(String value, int maxLength) { - final trimmedValue = value.trim(); - if (trimmedValue.length <= maxLength) { - return trimmedValue; - } - return trimmedValue.substring(0, maxLength); - } -} - -enum PasswordPolicyError { - tooShort, - tooLong, - missingLetter, - missingNumber, - missingSpecialCharacter, -} - -class PasswordPolicy { - const PasswordPolicy._(); - - static final RegExp _letterPattern = RegExp(r'[A-Za-z]'); - static final RegExp _numberPattern = RegExp(r'[0-9]'); - static final RegExp _specialCharacterPattern = RegExp( - r'''[!@#$%^&*(),.?":{}|<>\[\]\\;'/`~_+=\-]''', - ); - - static PasswordPolicyError? validate(String value) { - if (value.length < BackendConstraints.minPasswordLength) { - return PasswordPolicyError.tooShort; - } - if (value.length > BackendConstraints.maxPasswordLength) { - return PasswordPolicyError.tooLong; - } - if (!_letterPattern.hasMatch(value)) { - return PasswordPolicyError.missingLetter; - } - if (!_numberPattern.hasMatch(value)) { - return PasswordPolicyError.missingNumber; - } - if (!_specialCharacterPattern.hasMatch(value)) { - return PasswordPolicyError.missingSpecialCharacter; - } - return null; - } - - static bool isValid(String value) => validate(value) == null; -} diff --git a/lib/core/validation/local_input_limits.dart b/lib/core/validation/local_input_limits.dart new file mode 100644 index 00000000..ab4ee40e --- /dev/null +++ b/lib/core/validation/local_input_limits.dart @@ -0,0 +1,4 @@ +abstract final class LocalInputLimits { + static const int maxScheduleNameLength = 30; + static const int maxMinuteValue = 1440; +} diff --git a/lib/data/daos/place_dao.dart b/lib/data/daos/place_dao.dart index 084ab181..2084e146 100644 --- a/lib/data/daos/place_dao.dart +++ b/lib/data/daos/place_dao.dart @@ -11,9 +11,7 @@ class PlaceDao extends DatabaseAccessor with _$PlaceDaoMixin { PlaceDao(this.db) : super(db); Future createPlace(Place placeModel) async { - return await into(db.places).insertReturning( - placeModel.toCompanion(false), - ); + return await into(db.places).insertReturning(placeModel.toCompanion(false)); } Future> getAllPlaces() async { diff --git a/lib/data/daos/preparation_schedule_dao.dart b/lib/data/daos/preparation_schedule_dao.dart index 259771db..99f98d4e 100644 --- a/lib/data/daos/preparation_schedule_dao.dart +++ b/lib/data/daos/preparation_schedule_dao.dart @@ -1,5 +1,4 @@ import 'package:drift/drift.dart'; -import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; import '/core/database/database.dart'; @@ -23,28 +22,41 @@ class PreparationScheduleDao extends DatabaseAccessor PreparationEntity preparationEntity, String scheduleId, ) async { - String? previousStepId; - - for (var step in preparationEntity.preparationStepList) { - // Step 1: Insert the current preparation step - final insertedStep = await into(db.preparationSchedules).insertReturning( - step.toPreparationScheduleRow(scheduleId).toCompanion(false), - ); - - // Step 2: Update the `nextPreparationId` of the previous step - if (previousStepId != null) { - await (update( - db.preparationSchedules, - )..where((tbl) => tbl.id.equals(previousStepId!))).write( - PreparationSchedulesCompanion( - nextPreparationId: Value(insertedStep.id), - ), - ); + await transaction(() async { + await (delete( + db.preparationSchedules, + )..where((table) => table.scheduleId.equals(scheduleId))).go(); + String? previousStepId; + for (final step in preparationEntity.preparationStepList) { + // Step 1: Insert the current preparation step + // Ignore incoming links while inserting. Otherwise an already-linked + // replacement can reference a row that has not been inserted yet. + final insertedStep = await into(db.preparationSchedules) + .insertReturning( + PreparationSchedulesCompanion.insert( + id: Value(step.id), + scheduleId: scheduleId, + preparationName: step.preparationName, + preparationTime: step.preparationTime.inMinutes, + nextPreparationId: const Value(null), + ), + ); + + // Step 2: Update the `nextPreparationId` of the previous step + if (previousStepId != null) { + await (update( + db.preparationSchedules, + )..where((tbl) => tbl.id.equals(previousStepId!))).write( + PreparationSchedulesCompanion( + nextPreparationId: Value(insertedStep.id), + ), + ); + } + + // Step 3: Set the current step's ID as the previous step ID for the next iteration + previousStepId = insertedStep.id; } - - // Step 3: Set the current step's ID as the previous step ID for the next iteration - previousStepId = insertedStep.id; - } + }); } Future getPreparationSchedulesByScheduleId( diff --git a/lib/data/daos/preparation_template_dao.dart b/lib/data/daos/preparation_template_dao.dart new file mode 100644 index 00000000..f08d7401 --- /dev/null +++ b/lib/data/daos/preparation_template_dao.dart @@ -0,0 +1,97 @@ +import 'package:drift/drift.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/tables/preparation_template_step_table.dart'; +import 'package:on_time_front/data/tables/preparation_template_table.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/preparation_template_entity.dart'; + +part 'preparation_template_dao.g.dart'; + +@DriftAccessor(tables: [PreparationTemplates, PreparationTemplateSteps]) +class PreparationTemplateDao extends DatabaseAccessor + with _$PreparationTemplateDaoMixin { + PreparationTemplateDao(super.db); + + Future> getAll() async { + final templates = await (select( + preparationTemplates, + )..orderBy([(table) => OrderingTerm.asc(table.createdAt)])).get(); + return Future.wait(templates.map(_toEntity)); + } + + Future getById(String id) async { + final template = await (select( + preparationTemplates, + )..where((table) => table.id.equals(id))).getSingle(); + return _toEntity(template); + } + + Future put({ + required String id, + required String name, + required PreparationEntity preparation, + required DateTime now, + }) async { + await transaction(() async { + final existing = await (select( + preparationTemplates, + )..where((table) => table.id.equals(id))).getSingleOrNull(); + await into(preparationTemplates).insertOnConflictUpdate( + PreparationTemplatesCompanion.insert( + id: Value(id), + templateName: name, + createdAt: Value(existing?.createdAt ?? now), + updatedAt: Value(now), + ), + ); + await (delete( + preparationTemplateSteps, + )..where((table) => table.templateId.equals(id))).go(); + for (final (position, step) in preparation.preparationStepList.indexed) { + await into(preparationTemplateSteps).insert( + PreparationTemplateStepsCompanion.insert( + id: Value(step.id), + templateId: id, + preparationName: step.preparationName, + preparationTime: step.preparationTime.inMinutes, + position: position, + ), + ); + } + }); + } + + Future deleteById(String id) async { + await (delete( + preparationTemplates, + )..where((table) => table.id.equals(id))).go(); + } + + Future _toEntity(PreparationTemplate row) async { + final steps = + await (select(preparationTemplateSteps) + ..where((table) => table.templateId.equals(row.id)) + ..orderBy([(table) => OrderingTerm.asc(table.position)])) + .get(); + return PreparationTemplateEntity( + id: row.id, + name: row.templateName, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + preparation: PreparationEntity( + preparationStepList: [ + for (final (position, step) in steps.indexed) + PreparationStepEntity( + id: step.id, + preparationName: step.preparationName, + preparationTime: Duration(minutes: step.preparationTime), + nextPreparationId: position + 1 < steps.length + ? steps[position + 1].id + : null, + ), + ], + ), + ); + } +} diff --git a/lib/data/daos/preparation_user_dao.dart b/lib/data/daos/preparation_user_dao.dart index 6df5f702..19c885c4 100644 --- a/lib/data/daos/preparation_user_dao.dart +++ b/lib/data/daos/preparation_user_dao.dart @@ -1,5 +1,4 @@ import 'package:drift/drift.dart'; -import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; import '/core/database/database.dart'; import 'package:on_time_front/data/tables/preparation_user_table.dart'; @@ -20,23 +19,35 @@ class PreparationUserDao extends DatabaseAccessor PreparationEntity preparationEntity, String userId, ) async { - String? previousStepId; - - for (var step in preparationEntity.preparationStepList) { - final insertedStep = await into( + await transaction(() async { + await (delete( db.preparationUsers, - ).insertReturning(step.toPreparationUserRow(userId).toCompanion(false)); - - if (previousStepId != null) { - await (update( - db.preparationUsers, - )..where((tbl) => tbl.id.equals(previousStepId!))).write( - PreparationUsersCompanion(nextPreparationId: Value(insertedStep.id)), + )..where((table) => table.userId.equals(userId))).go(); + String? previousStepId; + for (final step in preparationEntity.preparationStepList) { + final insertedStep = await into(db.preparationUsers).insertReturning( + PreparationUsersCompanion.insert( + id: Value(step.id), + userId: userId, + preparationName: step.preparationName, + preparationTime: step.preparationTime.inMinutes, + nextPreparationId: const Value(null), + ), ); - } - previousStepId = insertedStep.id; - } + if (previousStepId != null) { + await (update( + db.preparationUsers, + )..where((tbl) => tbl.id.equals(previousStepId!))).write( + PreparationUsersCompanion( + nextPreparationId: Value(insertedStep.id), + ), + ); + } + + previousStepId = insertedStep.id; + } + }); } Future getPreparationUsersByUserId(String userId) async { diff --git a/lib/data/daos/schedule_dao.dart b/lib/data/daos/schedule_dao.dart index 3a6859ea..c4b6ea07 100644 --- a/lib/data/daos/schedule_dao.dart +++ b/lib/data/daos/schedule_dao.dart @@ -2,6 +2,7 @@ import 'package:drift/drift.dart'; import 'package:on_time_front/data/tables/places_table.dart'; import 'package:on_time_front/data/tables/schedule_with_place_model.dart'; import '/core/database/database.dart'; +import 'package:on_time_front/core/utils/json_converters/duration_json_converters.dart'; import 'package:on_time_front/data/tables/schedules_table.dart'; part 'schedule_dao.g.dart'; @@ -16,12 +17,18 @@ class ScheduleDao extends DatabaseAccessor Future createSchedule( ScheduleWithPlace scheduleWithPlace, ) async { - final placeModel = await db.placeDao.createPlace(scheduleWithPlace.place); - final scheduleModel = await into( - db.schedules, - ).insertReturning(scheduleWithPlace.schedule.toCompanion(false)); - - return ScheduleWithPlace(schedule: scheduleModel, place: placeModel); + return transaction(() async { + await into( + db.places, + ).insertOnConflictUpdate(scheduleWithPlace.place.toCompanion(false)); + final scheduleModel = await into( + db.schedules, + ).insertReturning(scheduleWithPlace.schedule.toCompanion(false)); + return ScheduleWithPlace( + schedule: scheduleModel, + place: scheduleWithPlace.place, + ); + }); } Future deleteSchedule(Schedule scheduleModel) async { @@ -53,6 +60,20 @@ class ScheduleDao extends DatabaseAccessor return scheduleList.first; } + Future updateScheduleWithPlace( + ScheduleWithPlace value, + ) async { + return transaction(() async { + await into( + db.places, + ).insertOnConflictUpdate(value.place.toCompanion(false)); + await (update(db.schedules) + ..where((table) => table.id.equals(value.schedule.id))) + .write(value.schedule.toCompanion(true)); + return getScheduleById(value.schedule.id); + }); + } + Future> getSchedulesByDate( DateTime startDate, DateTime? endDate, @@ -64,10 +85,14 @@ class ScheduleDao extends DatabaseAccessor db.places.id.equalsExp(db.schedules.placeId), ), ])..where( - db.schedules.scheduleTime.isBiggerOrEqualValue(startDate) & + db.schedules.scheduleTime.isBiggerOrEqualValue( + const CivilDateTimeSqlConverter().toSql(startDate), + ) & (endDate == null ? Constant(true) - : db.schedules.scheduleTime.isSmallerThanValue(endDate)), + : db.schedules.scheduleTime.isSmallerThanValue( + const CivilDateTimeSqlConverter().toSql(endDate), + )), ); final result = await query.get(); final List scheduleList = []; @@ -100,4 +125,19 @@ class ScheduleDao extends DatabaseAccessor }); return scheduleList; } + + Stream> watchScheduleList() { + final query = select(db.schedules).join([ + leftOuterJoin(db.places, db.places.id.equalsExp(db.schedules.placeId)), + ])..orderBy([OrderingTerm.asc(db.schedules.scheduleTime)]); + return query.watch().map( + (rows) => [ + for (final row in rows) + ScheduleWithPlace( + schedule: row.readTable(db.schedules), + place: row.readTable(db.places), + ), + ], + ); + } } diff --git a/lib/data/daos/user_dao.dart b/lib/data/daos/user_dao.dart index 8ddba04b..aefa6030 100644 --- a/lib/data/daos/user_dao.dart +++ b/lib/data/daos/user_dao.dart @@ -12,10 +12,14 @@ class UserDao extends DatabaseAccessor with _$UserDaoMixin { UserDao(this.db) : super(db); - Future createUser(UserEntity userEntity) async { - await into(db.users).insert(userEntity.toUserRow().toCompanion(false)); + Future putUser(UserEntity userEntity) async { + await into( + db.users, + ).insertOnConflictUpdate(userEntity.toUserRow().toCompanion(false)); } + Future createUser(UserEntity userEntity) => putUser(userEntity); + Future getUserById(String userId) async { final user = await (select( db.users, @@ -30,4 +34,88 @@ class UserDao extends DatabaseAccessor with _$UserDaoMixin { final query = await select(db.users).get(); return query.map((user) => user.toUserEntity()).toList(); } + + Stream watchUserById(String userId) { + return (select(db.users)..where((table) => table.id.equals(userId))) + .watchSingleOrNull() + .map((row) => row?.toUserEntity()); + } + + Future markDurableDataChanged(String userId) async { + final now = DateTime.now(); + await customStatement( + ''' + UPDATE users + SET data_revision = data_revision + 1, + first_durable_data_at = COALESCE(first_durable_data_at, ?), + last_durable_data_at = ? + WHERE id = ? + ''', + [ + now.millisecondsSinceEpoch ~/ 1000, + now.millisecondsSinceEpoch ~/ 1000, + userId, + ], + ); + } + + Future updateAlarmSettings({ + required String userId, + required bool enabled, + }) async { + await (update(users)..where((table) => table.id.equals(userId))).write( + UsersCompanion(alarmsEnabled: Value(enabled)), + ); + await markDurableDataChanged(userId); + } + + Future<({ + bool enabled, + int offsetMinutes, + bool detailedNotificationContent, + })> getAlarmSettings( + String userId, + ) async { + final row = await (select( + users, + )..where((table) => table.id.equals(userId))).getSingleOrNull(); + return ( + enabled: row?.alarmsEnabled ?? true, + offsetMinutes: row?.alarmOffsetMinutes ?? 0, + detailedNotificationContent: row?.detailedNotificationContent ?? false, + ); + } + + Future updateDetailedNotificationContent({ + required String userId, + required bool enabled, + }) async { + await (update(users)..where((table) => table.id.equals(userId))).write( + UsersCompanion(detailedNotificationContent: Value(enabled)), + ); + await markDurableDataChanged(userId); + } + + Future resetScore(String userId) async { + await (update(users)..where((table) => table.id.equals(userId))).write( + const UsersCompanion( + eligibleOutcomeCount: Value(0), + onTimeOutcomeCount: Value(0), + ), + ); + await markDurableDataChanged(userId); + } + + Future markExported({ + required String userId, + required int revision, + required DateTime cutoff, + }) async { + await (update(users)..where((table) => table.id.equals(userId))).write( + UsersCompanion( + lastExportedRevision: Value(revision), + lastExportedAt: Value(cutoff), + ), + ); + } } diff --git a/lib/data/data_sources/alarm_remote_data_source.dart b/lib/data/data_sources/alarm_remote_data_source.dart deleted file mode 100644 index 8fb0bacd..00000000 --- a/lib/data/data_sources/alarm_remote_data_source.dart +++ /dev/null @@ -1,169 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/core/constants/endpoint.dart'; -import 'package:on_time_front/data/models/alarm_device_model.dart'; -import 'package:on_time_front/data/models/alarm_settings_model.dart'; -import 'package:on_time_front/data/models/alarm_status_report_model.dart'; -import 'package:on_time_front/data/models/alarm_window_schedule_model.dart'; -import 'package:on_time_front/domain/entities/alarm_entities.dart'; -import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; - -abstract interface class AlarmRemoteDataSource { - Future getAlarmSettings(); - - Future updateAlarmSettings({ - required bool alarmsEnabled, - }); - - Future registerCurrentDevice(AlarmDeviceInfo deviceInfo); - - Future unregisterCurrentDevice(String deviceId); - - Future> getAlarmWindow( - DateTime startDate, - DateTime endDate, - ); - - Future postAlarmStatus(AlarmStatusReport report); -} - -@Injectable(as: AlarmRemoteDataSource) -class AlarmRemoteDataSourceImpl implements AlarmRemoteDataSource { - final Dio dio; - - AlarmRemoteDataSourceImpl(this.dio); - - @override - Future getAlarmSettings() async { - final result = await dio.get(Endpoint.alarmSettings); - if (result.statusCode == 200) { - return AlarmSettingsModel.fromJson( - result.data['data'] as Map, - ).toEntity(); - } - throw Exception('Error getting alarm settings'); - } - - @override - Future updateAlarmSettings({ - required bool alarmsEnabled, - }) async { - final result = await dio.patch( - Endpoint.alarmSettings, - data: UpdateAlarmSettingsRequestModel( - alarmsEnabled: alarmsEnabled, - ).toJson(), - ); - if (result.statusCode == 200) { - return AlarmSettingsModel.fromJson( - result.data['data'] as Map, - ).toEntity(); - } - throw Exception('Error updating alarm settings'); - } - - @override - Future registerCurrentDevice(AlarmDeviceInfo deviceInfo) async { - final result = await dio.put( - Endpoint.currentDevice, - data: AlarmDeviceInfoModel.fromEntity(deviceInfo).toJson(), - ); - if (result.statusCode != 200) { - throw Exception('Error registering current device'); - } - } - - @override - Future unregisterCurrentDevice(String deviceId) async { - final result = await dio.delete( - Endpoint.currentDevice, - data: {'deviceId': deviceId}, - ); - if (result.statusCode != 200) { - throw Exception('Error unregistering current device'); - } - } - - @override - Future> getAlarmWindow( - DateTime startDate, - DateTime endDate, - ) async { - final result = await dio.get( - Endpoint.alarmWindow, - queryParameters: { - 'startDate': startDate.toIso8601String(), - 'endDate': endDate.toIso8601String(), - }, - ); - if (result.statusCode == 200) { - return (result.data['data'] as List) - .map( - (item) => AlarmWindowScheduleModel.fromJson( - item as Map, - ).toEntity(), - ) - .toList(); - } - throw Exception('Error getting alarm window'); - } - - @override - Future postAlarmStatus(AlarmStatusReport report) async { - try { - final model = AlarmStatusReportModel(report); - var result = await _postAlarmStatus(model.toJson()); - if (result.statusCode == 400 && _responseCode(result.data) == '400') { - result = await _postAlarmStatus( - model.toJson(wireFormat: AlarmStatusReportWireFormat.upperSnake), - ); - } - if (result.statusCode == 409 && - _errorCode(result.data) == 'DEVICE_SESSION_NOT_ACTIVE') { - throw const DeviceSessionNotActiveException(); - } - if (result.statusCode != 200) { - throw Exception('Error posting alarm status: ${result.statusCode}'); - } - } on DioException catch (error) { - if (error.response?.statusCode == 409 && - _errorCode(error.response?.data) == 'DEVICE_SESSION_NOT_ACTIVE') { - throw const DeviceSessionNotActiveException(); - } - rethrow; - } - } - - Future> _postAlarmStatus(Map data) { - return dio.post( - Endpoint.alarmStatus, - data: data, - options: Options( - validateStatus: (status) => status != null && status < 500, - ), - ); - } - - String? _errorCode(Object? data) { - if (data is Map) { - final error = data['error']; - final code = _responseCode(data); - if (code != null) { - return code; - } - if (error is Map && error['code'] is String) { - return error['code'] as String; - } - } - return null; - } - - String? _responseCode(Object? data) { - if (data is Map) { - final code = data['code']; - if (code is String) return code; - if (code is num) return code.toInt().toString(); - } - return null; - } -} diff --git a/lib/data/data_sources/analytics_preference_local_data_source.dart b/lib/data/data_sources/analytics_preference_local_data_source.dart deleted file mode 100644 index df4686af..00000000 --- a/lib/data/data_sources/analytics_preference_local_data_source.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/domain/entities/analytics_preference.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -abstract interface class AnalyticsPreferenceLocalDataSource { - Future loadPreference(); - - Future savePreference(bool enabled); -} - -@Injectable(as: AnalyticsPreferenceLocalDataSource) -class AnalyticsPreferenceLocalDataSourceImpl - implements AnalyticsPreferenceLocalDataSource { - static const _enabledKey = 'analytics_preference_enabled'; - - @override - Future loadPreference() async { - final prefs = await SharedPreferences.getInstance(); - return AnalyticsPreference(enabled: prefs.getBool(_enabledKey) ?? false); - } - - @override - Future savePreference(bool enabled) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_enabledKey, enabled); - } -} diff --git a/lib/data/data_sources/analytics_preference_remote_data_source.dart b/lib/data/data_sources/analytics_preference_remote_data_source.dart deleted file mode 100644 index e5d1b261..00000000 --- a/lib/data/data_sources/analytics_preference_remote_data_source.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/core/constants/endpoint.dart'; -import 'package:on_time_front/domain/entities/analytics_preference.dart'; - -abstract interface class AnalyticsPreferenceRemoteDataSource { - Future getAnalyticsPreference(); - - Future updateAnalyticsPreference({ - required bool enabled, - }); -} - -@Injectable(as: AnalyticsPreferenceRemoteDataSource) -class AnalyticsPreferenceRemoteDataSourceImpl - implements AnalyticsPreferenceRemoteDataSource { - AnalyticsPreferenceRemoteDataSourceImpl(this.dio); - - final Dio dio; - - @override - Future getAnalyticsPreference() async { - final result = await dio.get(Endpoint.analyticsPreference); - if (result.statusCode == 200) { - return _preferenceFromResponse(result.data); - } - throw Exception('Error getting analytics preference'); - } - - @override - Future updateAnalyticsPreference({ - required bool enabled, - }) async { - final result = await dio.put( - Endpoint.analyticsPreference, - data: {'enabled': enabled}, - ); - if (result.statusCode == 200) { - return _preferenceFromResponse(result.data); - } - throw Exception('Error updating analytics preference'); - } - - AnalyticsPreference _preferenceFromResponse(Object? data) { - final envelope = data as Map; - final payload = envelope['data'] as Map; - return AnalyticsPreference( - enabled: payload['enabled'] as bool, - updatedAt: DateTime.parse(payload['updatedAt'] as String), - ); - } -} diff --git a/lib/data/data_sources/authentication_remote_data_source.dart b/lib/data/data_sources/authentication_remote_data_source.dart deleted file mode 100644 index b2481841..00000000 --- a/lib/data/data_sources/authentication_remote_data_source.dart +++ /dev/null @@ -1,256 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/core/constants/endpoint.dart'; -import 'package:on_time_front/core/validation/backend_constraints.dart'; -import 'package:on_time_front/data/models/get_user_response_model.dart'; -import 'package:on_time_front/data/models/sign_in_user_response_model.dart'; -import 'package:on_time_front/data/models/sign_in_with_google_request_model.dart'; -import 'package:on_time_front/data/models/sign_in_with_apple_request_model.dart'; -import 'package:on_time_front/domain/entities/token_entity.dart'; -import 'package:on_time_front/domain/entities/user_entity.dart'; -import 'package:uuid/uuid.dart'; - -abstract interface class AuthenticationRemoteDataSource { - Future<(UserEntity, TokenEntity)> signIn(String email, String password); - - Future<(UserEntity, TokenEntity)> signUp( - String email, - String password, - String name, - ); - - Future<(UserEntity, TokenEntity)> signInWithGoogle( - SignInWithGoogleRequestModel signInWithGoogleRequestModel, - ); - - Future<(UserEntity, TokenEntity)> signInWithApple( - SignInWithAppleRequestModel signInWithAppleRequestModel, - ); - - Future getUser(); - - Future deleteGoogleMe({String? feedbackMessage}); - - Future deleteAppleMe({String? feedbackMessage}); - - Future postFeedback(String message); - - Future deleteUser({String? feedbackMessage}); - - Future getUserSocialType(); -} - -@Injectable(as: AuthenticationRemoteDataSource) -class AuthenticationRemoteDataSourceImpl - implements AuthenticationRemoteDataSource { - final Dio dio; - AuthenticationRemoteDataSourceImpl(this.dio); - - @override - Future<(UserEntity, TokenEntity)> signIn( - String email, - String password, - ) async { - try { - final result = await dio.post( - Endpoint.signIn, - data: {'email': email, 'password': password}, - ); - if (result.statusCode == 200) { - final user = SignInUserResponseModel.fromJson(result.data['data']); - final token = TokenEntity.fromHeaders(result.headers); - return (user.toEntity(), token); - } else { - throw Exception('Error signing in'); - } - } catch (e) { - rethrow; - } - } - - @override - Future<(UserEntity, TokenEntity)> signUp( - String email, - String password, - String name, - ) async { - try { - final result = await dio.post( - Endpoint.signUp, - data: {'email': email, 'password': password, 'name': name}, - ); - if (result.statusCode == 200) { - final user = SignInUserResponseModel.fromJson(result.data['data']); - final token = TokenEntity.fromHeaders(result.headers); - return (user.toEntity(), token); - } else { - throw Exception('Error signing up'); - } - } catch (e) { - rethrow; - } - } - - @override - Future<(UserEntity, TokenEntity)> signInWithGoogle( - SignInWithGoogleRequestModel signInWithGoogleRequestModel, - ) async { - try { - final result = await dio.post( - Endpoint.signInWithGoogle, - data: signInWithGoogleRequestModel.toJson(), - ); - if (result.statusCode == 200) { - final user = SignInUserResponseModel.fromJson(result.data['data']); - final token = TokenEntity.fromHeaders(result.headers); - return (user.toEntity(), token); - } else { - throw Exception('Error signing in with Google'); - } - } catch (e) { - rethrow; - } - } - - @override - Future<(UserEntity, TokenEntity)> signInWithApple( - SignInWithAppleRequestModel signInWithAppleRequestModel, - ) async { - try { - final result = await dio.post( - Endpoint.signInWithApple, - data: signInWithAppleRequestModel.toJson(), - ); - if (result.statusCode == 200) { - final user = SignInUserResponseModel.fromJson(result.data['data']); - final token = TokenEntity.fromHeaders(result.headers); - return (user.toEntity(), token); - } else { - throw Exception('Error signing in with Apple'); - } - } catch (e) { - rethrow; - } - } - - @override - Future getUser() async { - try { - final result = await dio.get(Endpoint.getUser); - if (result.statusCode == 200) { - final user = GetUserResponseModel.fromJson(result.data['data']); - return user.toEntity(); - } else { - throw Exception('Error getting user'); - } - } catch (e) { - rethrow; - } - } - - @override - Future deleteGoogleMe({String? feedbackMessage}) async { - try { - final result = await dio.delete( - Endpoint.deleteGoogleMe, - data: _buildDeleteFeedbackData(feedbackMessage), - ); - if (result.statusCode == 200) { - return; - } else { - throw Exception('Error deleting Google user'); - } - } catch (e) { - rethrow; - } - } - - @override - Future deleteAppleMe({String? feedbackMessage}) async { - try { - final result = await dio.delete( - Endpoint.deleteAppleMe, - data: _buildDeleteFeedbackData(feedbackMessage), - ); - if (result.statusCode == 200) { - return; - } else { - throw Exception('Error deleting Apple user'); - } - } catch (e) { - rethrow; - } - } - - @override - Future postFeedback(String message) async { - try { - final feedbackId = const Uuid().v4(); - final trimmedMessage = _trimLongText(message); - final result = await dio.post( - Endpoint.feedback, - data: {'feedbackId': feedbackId, 'message': trimmedMessage}, - ); - if (result.statusCode == 200) { - return; - } else { - throw Exception('Error posting feedback'); - } - } catch (e) { - rethrow; - } - } - - @override - Future deleteUser({String? feedbackMessage}) async { - try { - final result = await dio.delete( - Endpoint.deleteUser, - data: _buildDeleteFeedbackData(feedbackMessage), - ); - if (result.statusCode == 200) { - return; - } else { - throw Exception('Error deleting user'); - } - } catch (e) { - rethrow; - } - } - - @override - Future getUserSocialType() async { - try { - final result = await dio.get(Endpoint.getUser); - if (result.statusCode == 200) { - final data = result.data['data'] as Map; - return data['socialType'] as String?; - } else { - throw Exception('Error getting user social type'); - } - } catch (e) { - rethrow; - } - } - - Map _buildDeleteFeedbackData(String? feedbackMessage) { - final trimmedMessage = feedbackMessage == null - ? null - : _trimLongText(feedbackMessage); - if (trimmedMessage == null || trimmedMessage.isEmpty) { - return {}; - } - - return { - 'feedbackId': const Uuid().v4(), - 'message': trimmedMessage, - }; - } - - String _trimLongText(String value) { - return BackendConstraints.trimToMaxLength( - value, - BackendConstraints.maxLongTextLength, - ); - } -} diff --git a/lib/data/data_sources/early_start_session_local_data_source.dart b/lib/data/data_sources/early_start_session_local_data_source.dart index cdd87646..c960ab32 100644 --- a/lib/data/data_sources/early_start_session_local_data_source.dart +++ b/lib/data/data_sources/early_start_session_local_data_source.dart @@ -26,9 +26,7 @@ class EarlyStartSessionLocalDataSourceImpl }) async { final prefs = await SharedPreferences.getInstance(); final key = '$_prefsKeyPrefix$scheduleId'; - final payload = jsonEncode({ - 'startedAt': startedAt.millisecondsSinceEpoch, - }); + final payload = jsonEncode({'startedAt': startedAt.millisecondsSinceEpoch}); await prefs.setString(key, payload); } diff --git a/lib/data/data_sources/notification_remote_data_source.dart b/lib/data/data_sources/notification_remote_data_source.dart deleted file mode 100644 index 80940c05..00000000 --- a/lib/data/data_sources/notification_remote_data_source.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/core/constants/endpoint.dart'; -import 'package:on_time_front/data/models/fcm_token_register_request_model.dart'; - -abstract interface class NotificationRemoteDataSource { - Future fcmTokenRegister(FcmTokenRegisterRequestModel model); -} - -@Injectable(as: NotificationRemoteDataSource) -class NotificationRemoteDataSourceImpl implements NotificationRemoteDataSource { - final Dio dio; - - NotificationRemoteDataSourceImpl(this.dio); - - @override - Future fcmTokenRegister(FcmTokenRegisterRequestModel model) async { - try { - final result = await dio.post( - Endpoint.fcmTokenRegister, - data: model.toJson(), - ); - - if (result.statusCode != 200) { - throw Exception('Error registering FCM token'); - } - } catch (e) { - rethrow; - } - } -} diff --git a/lib/data/data_sources/preparation_local_data_source.dart b/lib/data/data_sources/preparation_local_data_source.dart index 04154cc0..36c0f493 100644 --- a/lib/data/data_sources/preparation_local_data_source.dart +++ b/lib/data/data_sources/preparation_local_data_source.dart @@ -4,20 +4,37 @@ import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; abstract interface class PreparationLocalDataSource { - Future createDefaultPreparation(PreparationEntity preparationEntity); + Future createDefaultPreparation( + PreparationEntity preparationEntity, { + required String userId, + }); + + Future getDefaultPreparation(String userId); Future createCustomPreparation( - PreparationEntity preparationEntity, String scheduleId); + PreparationEntity preparationEntity, + String scheduleId, + ); + + Future replaceDefaultPreparation( + PreparationEntity preparationEntity, { + required String userId, + }); - Future updatePreparation(PreparationStepEntity preparationStepEntity); + Future replaceSchedulePreparation( + PreparationEntity preparationEntity, { + required String scheduleId, + }); Future deletePreparation( - PreparationEntity preparationEntity); + PreparationEntity preparationEntity, + ); Future getPreparationByScheduleId(String scheduleId); Future getPreparationStepById( - String preparationStepId); + String preparationStepId, + ); } @Injectable(as: PreparationLocalDataSource) @@ -28,35 +45,52 @@ class PreparationLocalDataSourceImpl implements PreparationLocalDataSource { @override Future createDefaultPreparation( - PreparationEntity preparationEntity) async { - await appDatabase.preparationUserDao - .createPreparationUser(preparationEntity, 'userId'); + PreparationEntity preparationEntity, { + required String userId, + }) async { + await appDatabase.preparationUserDao.createPreparationUser( + preparationEntity, + userId, + ); + } + + @override + Future getDefaultPreparation(String userId) { + return appDatabase.preparationUserDao.getPreparationUsersByUserId(userId); } @override Future createCustomPreparation( - PreparationEntity preparationEntity, String scheduleId) async { - await appDatabase.preparationScheduleDao - .createPreparationSchedule(preparationEntity, scheduleId); + PreparationEntity preparationEntity, + String scheduleId, + ) async { + await appDatabase.preparationScheduleDao.createPreparationSchedule( + preparationEntity, + scheduleId, + ); } @override Future getPreparationByScheduleId( - String scheduleId) async { + String scheduleId, + ) async { return await appDatabase.preparationScheduleDao .getPreparationSchedulesByScheduleId(scheduleId); } @override Future getPreparationStepById( - String preparationStepId) async { - return await appDatabase.preparationScheduleDao - .getPreparationStepById(preparationStepId); + String preparationStepId, + ) async { + return await appDatabase.preparationScheduleDao.getPreparationStepById( + preparationStepId, + ); } @override Future deletePreparation( - PreparationEntity preparationEntity) async { + PreparationEntity preparationEntity, + ) async { if (preparationEntity.preparationStepList.isEmpty) { throw Exception("No preparation steps to delete."); } @@ -65,26 +99,36 @@ class PreparationLocalDataSourceImpl implements PreparationLocalDataSource { if (firstStep.nextPreparationId != null) { // 스케줄 기반 삭제 - return await appDatabase.preparationScheduleDao - .deletePreparationSchedule(firstStep.id); + return await appDatabase.preparationScheduleDao.deletePreparationSchedule( + firstStep.id, + ); } else { // 사용자 기반 삭제 - return await appDatabase.preparationUserDao - .deletePreparationUser(firstStep.id); + return await appDatabase.preparationUserDao.deletePreparationUser( + firstStep.id, + ); } } @override - Future updatePreparation( - PreparationStepEntity preparationStepEntity) async { - if (preparationStepEntity.nextPreparationId != null) { - // 스케줄 기반 업데이트 - await appDatabase.preparationScheduleDao - .updatePreparationSchedule(preparationStepEntity, 'scheduleId'); - } else { - // 사용자 기반 업데이트 - await appDatabase.preparationUserDao - .updatePreparationUser(preparationStepEntity, 'userId'); - } + Future replaceDefaultPreparation( + PreparationEntity preparationEntity, { + required String userId, + }) { + return appDatabase.preparationUserDao.createPreparationUser( + preparationEntity, + userId, + ); + } + + @override + Future replaceSchedulePreparation( + PreparationEntity preparationEntity, { + required String scheduleId, + }) { + return appDatabase.preparationScheduleDao.createPreparationSchedule( + preparationEntity, + scheduleId, + ); } } diff --git a/lib/data/data_sources/preparation_remote_data_source.dart b/lib/data/data_sources/preparation_remote_data_source.dart deleted file mode 100644 index 0cb762fc..00000000 --- a/lib/data/data_sources/preparation_remote_data_source.dart +++ /dev/null @@ -1,176 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:injectable/injectable.dart'; - -import 'package:on_time_front/core/constants/endpoint.dart'; -import 'package:on_time_front/data/models/update_preparation_schedule_request_model.dart'; -import 'package:on_time_front/data/models/update_preparation_user_request_model.dart'; -import 'package:on_time_front/domain/entities/preparation_entity.dart'; -import 'package:on_time_front/data/models/create_preparation_schedule_request_model.dart'; -import 'package:on_time_front/data/models/create_defualt_preparation_request_model.dart'; -import 'package:on_time_front/data/models/get_preparation_step_response_model.dart'; -import 'package:on_time_front/data/models/update_spare_time_request_model.dart'; - -abstract interface class PreparationRemoteDataSource { - Future createDefaultPreparation( - CreateDefaultPreparationRequestModel model); - - Future createCustomPreparation( - PreparationEntity preparationEntity, String scheduleId); - - Future updateDefaultPreparation(PreparationEntity preparationEntity); - - Future updatePreparationByScheduleId( - PreparationEntity preparationEntity, String scheduleId); - - Future getPreparationByScheduleId(String scheduleId); - - Future getDefualtPreparation(); - - Future updateSpareTime(Duration newSpareTime); -} - -@Injectable(as: PreparationRemoteDataSource) -class PreparationRemoteDataSourceImpl implements PreparationRemoteDataSource { - final Dio dio; - - PreparationRemoteDataSourceImpl(this.dio); - - @override - Future createCustomPreparation( - PreparationEntity preparationEntity, String scheduleId) async { - try { - final requestModels = - PreparationScheduleCreateRequestModelListExtension.fromEntityList( - preparationEntity.preparationStepList); - - final result = await dio.post( - Endpoint.getCreateCustomPreparation(scheduleId), - data: requestModels.map((model) => model.toJson()).toList(), - ); - - if (result.statusCode != 200) { - throw Exception('Error creating custom preparation'); - } - } catch (e) { - rethrow; - } - } - - @override - Future createDefaultPreparation( - CreateDefaultPreparationRequestModel model) async { - try { - final result = await dio.put( - Endpoint.createDefaultPreparation, - data: model.toJson(), - ); - - if (result.statusCode != 200) { - throw Exception('Error creating default preparation'); - } - } catch (e) { - rethrow; - } - } - - @override - Future getPreparationByScheduleId( - String scheduleId) async { - try { - final result = await dio.get( - Endpoint.getPreparationByScheduleId(scheduleId), - ); - - if (result.statusCode == 200) { - final responseModels = (result.data['data'] as List) - .map((json) => GetPreparationStepResponseModel.fromJson( - json as Map)) - .toList(); - - return responseModels.toPreparationEntity(); - } else { - throw Exception('Error fetching preparation by schedule ID'); - } - } catch (e) { - rethrow; - } - } - - @override - Future getDefualtPreparation() async { - try { - final result = await dio.get(Endpoint.getDefaultPreparation); - - if (result.statusCode == 200) { - final responseModels = (result.data['data'] as List) - .map((json) => GetPreparationStepResponseModel.fromJson( - json as Map)) - .toList(); - - return responseModels.toPreparationEntity(); - } else { - throw Exception('Error fetching default preparation'); - } - } catch (e) { - rethrow; - } - } - - @override - Future updateDefaultPreparation( - PreparationEntity preparationEntity) async { - try { - final updateModel = - PreparationUserModifyRequestModelListExtension.fromEntityList( - preparationEntity.preparationStepList); - - final result = await dio.put( - Endpoint.updateDefaultPreparation, - data: updateModel.map((model) => model.toJson()).toList(), - ); - - if (result.statusCode != 200) { - throw Exception('Error updating preparation'); - } - } catch (e) { - rethrow; - } - } - - @override - Future updatePreparationByScheduleId( - PreparationEntity preparationEntity, String scheduleId) async { - try { - final updateModel = - PreparationScheduleModifyRequestModelListExtension.fromEntityList( - preparationEntity.preparationStepList); - - final result = await dio.put( - Endpoint.updatePreparationByScheduleId(scheduleId), - data: updateModel.map((model) => model.toJson()).toList(), - ); - - if (result.statusCode != 200) { - throw Exception('Error updating preparation'); - } - } catch (e) { - rethrow; - } - } - - @override - Future updateSpareTime(Duration newSpareTime) async { - try { - final body = UpdateSpareTimeRequestModel.fromDuration(newSpareTime); - final result = await dio.put( - Endpoint.updateSpareTime, - data: body.toJson(), - ); - if (result.statusCode != 200) { - throw Exception('Error updating spare time'); - } - } catch (e) { - rethrow; - } - } -} diff --git a/lib/data/data_sources/preparation_template_remote_data_source.dart b/lib/data/data_sources/preparation_template_remote_data_source.dart deleted file mode 100644 index 9ee71640..00000000 --- a/lib/data/data_sources/preparation_template_remote_data_source.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/core/constants/endpoint.dart'; -import 'package:on_time_front/data/models/preparation_template_model.dart'; -import 'package:on_time_front/domain/entities/preparation_entity.dart'; -import 'package:on_time_front/domain/entities/preparation_template_entity.dart'; - -abstract interface class PreparationTemplateRemoteDataSource { - Future> getPreparationTemplates(); - - Future getPreparationTemplate(String templateId); - - Future createPreparationTemplate({ - required String templateId, - required String templateName, - required PreparationEntity preparation, - }); - - Future updatePreparationTemplate({ - required String templateId, - required String templateName, - required PreparationEntity preparation, - }); - - Future deletePreparationTemplate(String templateId); -} - -@Injectable(as: PreparationTemplateRemoteDataSource) -class PreparationTemplateRemoteDataSourceImpl - implements PreparationTemplateRemoteDataSource { - final Dio dio; - - PreparationTemplateRemoteDataSourceImpl(this.dio); - - @override - Future> getPreparationTemplates() async { - final result = await dio.get(Endpoint.preparationTemplates); - if (result.statusCode == 200) { - return (result.data['data'] as List) - .map( - (item) => PreparationTemplateModel.fromJson( - item as Map, - ).toEntity(), - ) - .toList(); - } - throw Exception('Error getting preparation templates'); - } - - @override - Future getPreparationTemplate( - String templateId, - ) async { - final result = await dio.get(Endpoint.preparationTemplateById(templateId)); - if (result.statusCode == 200) { - return PreparationTemplateModel.fromJson( - result.data['data'] as Map, - ).toEntity(); - } - throw Exception('Error getting preparation template'); - } - - @override - Future createPreparationTemplate({ - required String templateId, - required String templateName, - required PreparationEntity preparation, - }) async { - final request = UpsertPreparationTemplateRequestModel.fromValues( - templateId: templateId, - templateName: templateName, - preparation: preparation, - ); - final result = await dio.post( - Endpoint.preparationTemplates, - data: request.toJson(), - ); - if (result.statusCode != 200) { - throw Exception('Error creating preparation template'); - } - } - - @override - Future updatePreparationTemplate({ - required String templateId, - required String templateName, - required PreparationEntity preparation, - }) async { - final request = UpsertPreparationTemplateRequestModel.fromValues( - templateId: templateId, - templateName: templateName, - preparation: preparation, - ); - final result = await dio.put( - Endpoint.preparationTemplateById(templateId), - data: request.toJson(), - ); - if (result.statusCode != 200) { - throw Exception('Error updating preparation template'); - } - } - - @override - Future deletePreparationTemplate(String templateId) async { - final result = await dio.delete( - Endpoint.preparationTemplateById(templateId), - ); - if (result.statusCode != 200) { - throw Exception('Error deleting preparation template'); - } - } -} diff --git a/lib/data/data_sources/schedule_remote_data_source.dart b/lib/data/data_sources/schedule_remote_data_source.dart deleted file mode 100644 index 4c80538a..00000000 --- a/lib/data/data_sources/schedule_remote_data_source.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/core/constants/endpoint.dart'; - -import 'package:on_time_front/data/models/create_schedule_request_model.dart'; -import 'package:on_time_front/data/models/get_schedule_response_model.dart'; -import 'package:on_time_front/data/models/update_schedule_request_model.dart'; - -abstract interface class ScheduleRemoteDataSource { - Future createSchedule(CreateScheduleRequestModel schedule); - - Future> getSchedulesByDate( - DateTime startDate, - DateTime? endDate, - ); - - Future getScheduleById(String id); - - Future updateSchedule(UpdateScheduleRequestModel schedule); - - Future deleteSchedule(String scheduleId); - - Future startSchedule(String scheduleId); - - Future finishSchedule(String scheduleId, int latenessTime); -} - -@Injectable(as: ScheduleRemoteDataSource) -class ScheduleRemoteDataSourceImpl implements ScheduleRemoteDataSource { - final Dio dio; - ScheduleRemoteDataSourceImpl(this.dio); - - @override - Future createSchedule(CreateScheduleRequestModel schedule) async { - try { - final result = await dio.post( - Endpoint.createSchedule, - data: schedule.toJson(), - ); - if (result.statusCode == 200) { - return; - } else { - throw Exception('Error creating schedule worng status code'); - } - } catch (e) { - rethrow; - } - } - - @override - Future updateSchedule(UpdateScheduleRequestModel schedule) async { - try { - final result = await dio.put( - Endpoint.updateSchedule(schedule.scheduleId), - data: schedule.toJson(), - ); - if (result.statusCode == 200) { - return; - } else { - throw Exception('Error updating schedule'); - } - } catch (e) { - rethrow; - } - } - - @override - Future deleteSchedule(String scheduleId) async { - try { - final result = await dio.delete(Endpoint.deleteScheduleById(scheduleId)); - if (result.statusCode == 200) { - return; - } else { - throw Exception('Error deleting schedule'); - } - } catch (e) { - rethrow; - } - } - - @override - Future startSchedule(String scheduleId) async { - try { - final result = await dio.post(Endpoint.startSchedule(scheduleId)); - if (result.statusCode == 200) { - return; - } else { - throw Exception('Error starting schedule'); - } - } catch (e) { - rethrow; - } - } - - @override - Future finishSchedule(String scheduleId, int latenessTime) async { - try { - final result = await dio.put( - Endpoint.finishSchedule(scheduleId), - data: {'scheduleId': scheduleId, 'latenessTime': latenessTime}, - ); - if (result.statusCode == 200) { - return; - } else { - throw Exception('Error finishing schedule'); - } - } catch (e) { - rethrow; - } - } - - @override - Future getScheduleById(String id) async { - try { - final result = await dio.get(Endpoint.getScheduleById(id)); - if (result.statusCode == 200) { - final GetScheduleResponseModel schedule = - GetScheduleResponseModel.fromJson(result.data["data"]); - return schedule; - } else { - throw Exception('Error getting schedules'); - } - } catch (e) { - rethrow; - } - } - - @override - Future> getSchedulesByDate( - DateTime startDate, - DateTime? endDate, - ) async { - try { - final result = await dio.get( - Endpoint.getSchedulesByDate, - queryParameters: { - 'startDate': startDate.toIso8601String(), - 'endDate': endDate?.toIso8601String() ?? '', - }, - ); - if (result.statusCode == 200) { - final List schedules = result.data["data"] - .map( - (e) => GetScheduleResponseModel.fromJson(e), - ) - .toList(); - return schedules; - } else { - throw Exception('Error getting schedules'); - } - } catch (e) { - rethrow; - } - } -} diff --git a/lib/data/data_sources/token_local_data_source.dart b/lib/data/data_sources/token_local_data_source.dart deleted file mode 100644 index 165ebf53..00000000 --- a/lib/data/data_sources/token_local_data_source.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/domain/entities/token_entity.dart'; - -abstract class TokenLocalDataSource { - Future storeTokens(TokenEntity token); - - Future storeAuthToken(String token); - - Future getToken(); - - Future deleteToken(); -} - -@Injectable(as: TokenLocalDataSource) -class TokenLocalDataSourceImpl implements TokenLocalDataSource { - static const _appleOptions = IOSOptions( - accessibility: KeychainAccessibility.first_unlock_this_device, - ); - - TokenLocalDataSourceImpl() - : storage = const FlutterSecureStorage(iOptions: _appleOptions); - - @visibleForTesting - TokenLocalDataSourceImpl.withStorage(this.storage); - - final FlutterSecureStorage storage; - - final accessTokenKey = 'accessToken'; - final refreshTokenKey = 'refreshToken'; - TokenEntity? _cachedToken; - - @override - Future storeTokens(TokenEntity token) async { - await storage.write( - key: accessTokenKey, - value: token.accessToken, - iOptions: _appleOptions, - ); - await storage.write( - key: refreshTokenKey, - value: token.refreshToken, - iOptions: _appleOptions, - ); - _cachedToken = token; - } - - @override - Future getToken() async { - final cachedToken = _cachedToken; - if (cachedToken != null) { - return cachedToken; - } - - final token = await _readToken(_appleOptions); - if (token != null) { - _cachedToken = token; - return token; - } - - final legacyToken = await _readToken(IOSOptions.defaultOptions); - if (legacyToken != null) { - await storeTokens(legacyToken); - return legacyToken; - } - - throw Exception('Token not found'); - } - - @override - Future deleteToken() async { - await storage.delete(key: accessTokenKey, iOptions: _appleOptions); - await storage.delete(key: refreshTokenKey, iOptions: _appleOptions); - await storage.delete( - key: accessTokenKey, - iOptions: IOSOptions.defaultOptions, - ); - await storage.delete( - key: refreshTokenKey, - iOptions: IOSOptions.defaultOptions, - ); - _cachedToken = null; - } - - @override - Future storeAuthToken(String token) async { - await storage.write( - key: accessTokenKey, - value: token, - iOptions: _appleOptions, - ); - final cachedToken = _cachedToken; - final refreshToken = - cachedToken?.refreshToken ?? - await storage.read(key: refreshTokenKey, iOptions: _appleOptions); - if (refreshToken != null) { - _cachedToken = TokenEntity( - accessToken: token, - refreshToken: refreshToken, - ); - } - } - - Future _readToken(IOSOptions options) async { - final accessToken = await storage.read( - key: accessTokenKey, - iOptions: options, - ); - final refreshToken = await storage.read( - key: refreshTokenKey, - iOptions: options, - ); - if (accessToken == null || refreshToken == null) { - return null; - } - return TokenEntity(accessToken: accessToken, refreshToken: refreshToken); - } -} diff --git a/lib/data/mappers/domain_persistence_mappers.dart b/lib/data/mappers/domain_persistence_mappers.dart index bbbccbf5..18dda2b9 100644 --- a/lib/data/mappers/domain_persistence_mappers.dart +++ b/lib/data/mappers/domain_persistence_mappers.dart @@ -3,6 +3,7 @@ import 'package:on_time_front/data/tables/schedule_with_place_model.dart'; import 'package:on_time_front/domain/entities/place_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/entities/schedule_preparation_mode.dart'; import 'package:on_time_front/domain/entities/user_entity.dart'; extension PlacePersistenceMapper on PlaceEntity { @@ -45,6 +46,8 @@ extension SchedulePersistenceMapper on ScheduleEntity { id: id, placeId: place.id, scheduleName: scheduleName, + timeZoneId: timeZoneId, + occurrenceOffsetSeconds: occurrenceOffsetSeconds, scheduleTime: scheduleTime, moveTime: moveTime, isChanged: isChanged, @@ -52,6 +55,15 @@ extension SchedulePersistenceMapper on ScheduleEntity { scheduleSpareTime: scheduleSpareTime, scheduleNote: scheduleNote, latenessTime: latenessTime, + doneStatus: doneStatus.name, + startedAt: startedAt, + finishedAt: finishedAt, + preparationMode: preparationMode?.name, + preparationTemplateId: preparationTemplateId, + preparationTemplateName: preparationTemplateName, + preparationTemplateDeleted: preparationTemplateDeleted, + preparationFrozen: preparationFrozen, + scoreContributionRecorded: scoreContributionRecorded, ); } @@ -69,6 +81,8 @@ extension ScheduleWithPlacePersistenceMapper on ScheduleWithPlace { id: schedule.id, place: place.toPlaceEntity(), scheduleName: schedule.scheduleName, + timeZoneId: schedule.timeZoneId, + occurrenceOffsetSeconds: schedule.occurrenceOffsetSeconds, scheduleTime: schedule.scheduleTime, moveTime: schedule.moveTime, isChanged: schedule.isChanged, @@ -76,8 +90,17 @@ extension ScheduleWithPlacePersistenceMapper on ScheduleWithPlace { scheduleSpareTime: schedule.scheduleSpareTime, scheduleNote: schedule.scheduleNote ?? '', latenessTime: schedule.latenessTime, - doneStatus: ScheduleDoneStatus.notEnded, - preparationFrozen: schedule.isStarted, + doneStatus: ScheduleDoneStatus.values.byName(schedule.doneStatus), + startedAt: schedule.startedAt, + finishedAt: schedule.finishedAt, + preparationMode: schedule.preparationMode == null + ? null + : SchedulePreparationMode.values.byName(schedule.preparationMode!), + preparationTemplateId: schedule.preparationTemplateId, + preparationTemplateName: schedule.preparationTemplateName, + preparationTemplateDeleted: schedule.preparationTemplateDeleted, + preparationFrozen: schedule.preparationFrozen, + scoreContributionRecorded: schedule.scoreContributionRecorded, ); } } @@ -87,11 +110,16 @@ extension UserPersistenceMapper on UserEntity { return map( (userEntity) => User( id: userEntity.id, - email: userEntity.email, - name: userEntity.name, spareTime: userEntity.spareTime.inMinutes, note: userEntity.note, - score: userEntity.score, + eligibleOutcomeCount: userEntity.eligibleOutcomeCount, + onTimeOutcomeCount: userEntity.onTimeOutcomeCount, + isOnboardingCompleted: userEntity.isOnboardingCompleted, + alarmsEnabled: true, + alarmOffsetMinutes: 0, + detailedNotificationContent: false, + dataRevision: 0, + lastDurableDataAt: null, ), empty: (_) => throw Exception('Cannot convert empty UserEntity to User'), ); @@ -102,11 +130,11 @@ extension UserRowPersistenceMapper on User { UserEntity toUserEntity() { return UserEntity( id: id, - email: email, - name: name, spareTime: Duration(minutes: spareTime), note: note, - score: score, + eligibleOutcomeCount: eligibleOutcomeCount, + onTimeOutcomeCount: onTimeOutcomeCount, + isOnboardingCompleted: isOnboardingCompleted, ); } } diff --git a/lib/data/models/alarm_device_model.dart b/lib/data/models/alarm_device_model.dart deleted file mode 100644 index 434ec58a..00000000 --- a/lib/data/models/alarm_device_model.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:on_time_front/domain/entities/alarm_entities.dart'; - -class AlarmDeviceInfoModel { - final String deviceId; - final String platform; - final String appVersion; - final String osVersion; - final bool supportsNativeAlarm; - final AlarmProvider nativeAlarmProvider; - final AlarmProvider fallbackProvider; - - const AlarmDeviceInfoModel({ - required this.deviceId, - required this.platform, - required this.appVersion, - required this.osVersion, - required this.supportsNativeAlarm, - required this.nativeAlarmProvider, - required this.fallbackProvider, - }); - - factory AlarmDeviceInfoModel.fromEntity(AlarmDeviceInfo entity) { - return AlarmDeviceInfoModel( - deviceId: entity.deviceId, - platform: entity.platform, - appVersion: entity.appVersion, - osVersion: entity.osVersion, - supportsNativeAlarm: entity.supportsNativeAlarm, - nativeAlarmProvider: entity.nativeAlarmProvider, - fallbackProvider: entity.fallbackProvider, - ); - } - - Map toJson() { - return { - 'deviceId': deviceId, - 'platform': platform, - 'appVersion': appVersion, - 'osVersion': osVersion, - 'supportsNativeAlarm': supportsNativeAlarm, - 'nativeAlarmProvider': nativeAlarmProvider.wireValue, - 'fallbackProvider': fallbackProvider.wireValue, - }; - } -} diff --git a/lib/data/models/alarm_settings_model.dart b/lib/data/models/alarm_settings_model.dart deleted file mode 100644 index 5a1f00ab..00000000 --- a/lib/data/models/alarm_settings_model.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:on_time_front/domain/entities/alarm_entities.dart'; - -class AlarmSettingsModel { - final bool alarmsEnabled; - final int defaultAlarmOffsetMinutes; - final DateTime? updatedAt; - - const AlarmSettingsModel({ - required this.alarmsEnabled, - required this.defaultAlarmOffsetMinutes, - this.updatedAt, - }); - - factory AlarmSettingsModel.fromJson(Map json) { - return AlarmSettingsModel( - alarmsEnabled: json['alarmsEnabled'] as bool? ?? true, - defaultAlarmOffsetMinutes: - (json['defaultAlarmOffsetMinutes'] as num?)?.toInt() ?? 5, - updatedAt: json['updatedAt'] == null - ? null - : DateTime.parse(json['updatedAt'] as String), - ); - } - - Map toJson() { - return { - 'alarmsEnabled': alarmsEnabled, - 'defaultAlarmOffsetMinutes': defaultAlarmOffsetMinutes, - if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(), - }; - } - - AlarmSettings toEntity() { - return AlarmSettings( - alarmsEnabled: alarmsEnabled, - defaultAlarmOffsetMinutes: defaultAlarmOffsetMinutes, - updatedAt: updatedAt, - ); - } - - factory AlarmSettingsModel.fromEntity(AlarmSettings entity) { - return AlarmSettingsModel( - alarmsEnabled: entity.alarmsEnabled, - defaultAlarmOffsetMinutes: entity.defaultAlarmOffsetMinutes, - updatedAt: entity.updatedAt, - ); - } -} - -class UpdateAlarmSettingsRequestModel { - final bool alarmsEnabled; - - const UpdateAlarmSettingsRequestModel({required this.alarmsEnabled}); - - Map toJson() { - return { - 'alarmsEnabled': alarmsEnabled, - }; - } -} diff --git a/lib/data/models/alarm_status_report_model.dart b/lib/data/models/alarm_status_report_model.dart deleted file mode 100644 index 79dc093b..00000000 --- a/lib/data/models/alarm_status_report_model.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:on_time_front/domain/entities/alarm_entities.dart'; - -enum AlarmStatusReportWireFormat { - lowerCamel, - upperSnake, -} - -class AlarmStatusReportModel { - final AlarmStatusReport report; - - const AlarmStatusReportModel(this.report); - - Map toJson({ - AlarmStatusReportWireFormat wireFormat = - AlarmStatusReportWireFormat.lowerCamel, - }) { - return { - 'deviceId': report.deviceId, - 'reconciledAt': _toBackendInstantString(report.reconciledAt), - 'scheduleWindowStart': - _toBackendDateTimeString(report.scheduleWindowStart), - 'scheduleWindowEnd': _toBackendDateTimeString(report.scheduleWindowEnd), - 'alarmCoverageStart': _toBackendDateTimeString(report.alarmCoverageStart), - 'alarmCoverageEnd': _toBackendDateTimeString(report.alarmCoverageEnd), - 'status': _statusWireValue(report.status, wireFormat), - if (report.permissionIssue != null) - 'permissionIssue': - _permissionIssueWireValue(report.permissionIssue!, wireFormat), - 'nativeAlarmProvider': - _providerWireValue(report.nativeAlarmProvider, wireFormat), - 'fallbackProvider': _providerWireValue( - report.fallbackProvider, - wireFormat, - ), - 'armedScheduleCount': report.armedScheduleCount, - 'armedScheduleIds': report.armedScheduleIds, - 'skippedScheduleCount': report.skippedScheduleCount, - 'failures': report.failures - .map( - (failure) => { - if (failure.scheduleId != null) 'scheduleId': failure.scheduleId, - 'reason': _failureReasonWireValue(failure.reason, wireFormat), - if (failure.message != null) 'message': failure.message, - }, - ) - .toList(), - }; - } - - String _toBackendInstantString(DateTime value) { - final utc = value.toUtc(); - return '${_formatDateTime(utc)}Z'; - } - - String _toBackendDateTimeString(DateTime value) { - return _formatDateTime(value.toLocal()); - } - - String _formatDateTime(DateTime value) { - String twoDigits(int value) => value.toString().padLeft(2, '0'); - String threeDigits(int value) => value.toString().padLeft(3, '0'); - - return '${value.year.toString().padLeft(4, '0')}-' - '${twoDigits(value.month)}-' - '${twoDigits(value.day)}T' - '${twoDigits(value.hour)}:' - '${twoDigits(value.minute)}:' - '${twoDigits(value.second)}.' - '${threeDigits(value.millisecond)}'; - } - - String _providerWireValue( - AlarmProvider provider, - AlarmStatusReportWireFormat wireFormat, - ) { - if (wireFormat == AlarmStatusReportWireFormat.lowerCamel) { - return provider.wireValue; - } - switch (provider) { - case AlarmProvider.androidAlarmManager: - return 'ANDROID_ALARM_MANAGER'; - case AlarmProvider.iosAlarmKit: - return 'IOS_ALARM_KIT'; - case AlarmProvider.localNotification: - return 'LOCAL_NOTIFICATION'; - case AlarmProvider.none: - return 'NONE'; - } - } - - String _statusWireValue( - AlarmReconciliationStatus status, - AlarmStatusReportWireFormat wireFormat, - ) { - if (wireFormat == AlarmStatusReportWireFormat.lowerCamel) { - return status.wireValue; - } - switch (status) { - case AlarmReconciliationStatus.armed: - return 'ARMED'; - case AlarmReconciliationStatus.partial: - return 'PARTIAL'; - case AlarmReconciliationStatus.disabled: - return 'DISABLED'; - case AlarmReconciliationStatus.permissionNeeded: - return 'PERMISSION_NEEDED'; - case AlarmReconciliationStatus.unsupported: - return 'UNSUPPORTED'; - case AlarmReconciliationStatus.settingsUnavailable: - return 'SETTINGS_UNAVAILABLE'; - } - } - - String _permissionIssueWireValue( - AlarmPermissionIssue issue, - AlarmStatusReportWireFormat wireFormat, - ) { - if (wireFormat == AlarmStatusReportWireFormat.lowerCamel) { - return issue.wireValue; - } - switch (issue) { - case AlarmPermissionIssue.nativePermissionDenied: - return 'NATIVE_PERMISSION_DENIED'; - case AlarmPermissionIssue.notificationPermissionDenied: - return 'NOTIFICATION_PERMISSION_DENIED'; - } - } - - String _failureReasonWireValue( - AlarmFailureReason reason, - AlarmStatusReportWireFormat wireFormat, - ) { - if (wireFormat == AlarmStatusReportWireFormat.lowerCamel) { - return reason.wireValue; - } - switch (reason) { - case AlarmFailureReason.preparationLoadFailed: - return 'PREPARATION_LOAD_FAILED'; - case AlarmFailureReason.scheduleInvalid: - return 'SCHEDULE_INVALID'; - case AlarmFailureReason.platformError: - return 'PLATFORM_ERROR'; - case AlarmFailureReason.unknown: - return 'UNKNOWN'; - } - } -} diff --git a/lib/data/models/alarm_window_schedule_model.dart b/lib/data/models/alarm_window_schedule_model.dart deleted file mode 100644 index 6e052dff..00000000 --- a/lib/data/models/alarm_window_schedule_model.dart +++ /dev/null @@ -1,188 +0,0 @@ -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/preparation_with_time_entity.dart'; -import 'package:on_time_front/domain/entities/schedule_entity.dart'; -import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; -import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; - -class AlarmWindowScheduleModel { - final String scheduleId; - final String scheduleName; - final PlaceEntity place; - final DateTime scheduleTime; - final int moveTime; - final int scheduleSpareTime; - final String doneStatus; - final List preparations; - final DateTime? startedAt; - final DateTime? finishedAt; - final SchedulePreparationMode? preparationMode; - final String? preparationTemplateId; - final String? preparationTemplateName; - final bool preparationTemplateDeleted; - final bool preparationFrozen; - - const AlarmWindowScheduleModel({ - required this.scheduleId, - required this.scheduleName, - required this.place, - required this.scheduleTime, - required this.moveTime, - required this.scheduleSpareTime, - required this.doneStatus, - required this.preparations, - this.startedAt, - this.finishedAt, - this.preparationMode, - this.preparationTemplateId, - this.preparationTemplateName, - this.preparationTemplateDeleted = false, - this.preparationFrozen = false, - }); - - factory AlarmWindowScheduleModel.fromJson(Map json) { - final placeJson = json['place'] as Map? ?? const {}; - final preparationJson = - (json['preparations'] as List? ?? const []); - return AlarmWindowScheduleModel( - scheduleId: json['scheduleId'] as String, - scheduleName: json['scheduleName'] as String? ?? '', - place: PlaceEntity( - id: placeJson['placeId'] as String? ?? '', - placeName: placeJson['placeName'] as String? ?? '', - ), - scheduleTime: DateTime.parse(json['scheduleTime'] as String), - moveTime: (json['moveTime'] as num?)?.toInt() ?? 0, - scheduleSpareTime: (json['scheduleSpareTime'] as num?)?.toInt() ?? 0, - doneStatus: json['doneStatus'] as String? ?? 'NOT_ENDED', - preparations: preparationJson - .map( - (item) => AlarmWindowPreparationStepModel.fromJson( - item as Map, - ), - ) - .toList(), - startedAt: _optionalDateTime(json['startedAt']), - finishedAt: _optionalDateTime(json['finishedAt']), - preparationMode: _preparationModeFromJson(json['preparationMode']), - preparationTemplateId: json['preparationTemplateId'] as String?, - preparationTemplateName: json['preparationTemplateName'] as String?, - preparationTemplateDeleted: - json['preparationTemplateDeleted'] as bool? ?? false, - preparationFrozen: - json['preparationFrozen'] as bool? ?? json['startedAt'] != null, - ); - } - - ScheduleWithPreparationEntity toEntity() { - final hasOrderedShape = preparations.every( - (preparation) => preparation.orderIndex != null, - ); - final sortedPreparations = [...preparations]; - if (hasOrderedShape) { - sortedPreparations.sort((a, b) => a.orderIndex!.compareTo(b.orderIndex!)); - } - return ScheduleWithPreparationEntity( - id: scheduleId, - place: place, - scheduleName: scheduleName, - scheduleTime: scheduleTime, - moveTime: Duration(minutes: moveTime), - isChanged: false, - isStarted: preparationFrozen || startedAt != null, - scheduleSpareTime: Duration(minutes: scheduleSpareTime), - scheduleNote: '', - doneStatus: _mapDoneStatus(doneStatus), - startedAt: startedAt, - finishedAt: finishedAt, - preparationMode: preparationMode, - preparationTemplateId: preparationTemplateId, - preparationTemplateName: preparationTemplateName, - preparationTemplateDeleted: preparationTemplateDeleted, - preparationFrozen: preparationFrozen, - preparation: PreparationWithTimeEntity.fromPreparation( - PreparationEntity( - preparationStepList: [ - for (var index = 0; index < sortedPreparations.length; index++) - hasOrderedShape - ? sortedPreparations[index].toEntity( - nextPreparationId: index + 1 < sortedPreparations.length - ? sortedPreparations[index + 1].id - : null, - ) - : sortedPreparations[index].toEntity(), - ], - ), - ), - ); - } -} - -class AlarmWindowPreparationStepModel { - final String id; - final String preparationName; - final int preparationTime; - final String? nextPreparationId; - final int? orderIndex; - - const AlarmWindowPreparationStepModel({ - required this.id, - required this.preparationName, - required this.preparationTime, - this.nextPreparationId, - this.orderIndex, - }); - - factory AlarmWindowPreparationStepModel.fromJson(Map json) { - return AlarmWindowPreparationStepModel( - id: json['preparationId'] as String, - preparationName: json['preparationName'] as String? ?? '', - preparationTime: (json['preparationTime'] as num?)?.toInt() ?? 0, - nextPreparationId: json['nextPreparationId'] as String?, - orderIndex: (json['orderIndex'] as num?)?.toInt(), - ); - } - - PreparationStepEntity toEntity({String? nextPreparationId}) { - return PreparationStepEntity( - id: id, - preparationName: preparationName, - preparationTime: Duration(minutes: preparationTime), - nextPreparationId: nextPreparationId ?? this.nextPreparationId, - ); - } -} - -ScheduleDoneStatus _mapDoneStatus(String? serverValue) { - switch (serverValue) { - case 'LATE': - return ScheduleDoneStatus.lateEnd; - case 'NORMAL': - return ScheduleDoneStatus.normalEnd; - case 'ABNORMAL': - return ScheduleDoneStatus.abnormalEnd; - case 'NOT_ENDED': - default: - return ScheduleDoneStatus.notEnded; - } -} - -DateTime? _optionalDateTime(Object? value) { - if (value is String && value.isNotEmpty) { - return DateTime.parse(value); - } - return null; -} - -SchedulePreparationMode? _preparationModeFromJson(Object? value) { - switch (value) { - case 'DEFAULT': - return SchedulePreparationMode.defaultPreparation; - case 'TEMPLATE': - return SchedulePreparationMode.template; - case 'CUSTOM': - return SchedulePreparationMode.custom; - } - return null; -} diff --git a/lib/data/models/create_defualt_preparation_request_model.dart b/lib/data/models/create_defualt_preparation_request_model.dart deleted file mode 100644 index 7a5b986a..00000000 --- a/lib/data/models/create_defualt_preparation_request_model.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:on_time_front/data/models/create_preparation_step_request_model.dart'; -import 'package:on_time_front/domain/entities/preparation_entity.dart'; - -part 'create_defualt_preparation_request_model.g.dart'; - -@JsonSerializable() -class CreateDefaultPreparationRequestModel { - final int spareTime; - final String note; - final List preparationList; - - CreateDefaultPreparationRequestModel({ - required this.spareTime, - required this.note, - required this.preparationList, - }); - - factory CreateDefaultPreparationRequestModel.fromJson( - Map json, - ) => _$CreateDefaultPreparationRequestModelFromJson(json); - - Map toJson() => - _$CreateDefaultPreparationRequestModelToJson(this); - - static CreateDefaultPreparationRequestModel fromEntity({ - required PreparationEntity preparationEntity, - required Duration spareTime, - required String note, - }) { - return CreateDefaultPreparationRequestModel( - spareTime: spareTime.inMinutes, - note: note, - preparationList: preparationEntity.preparationStepList - .map((e) => CreatePreparationStepRequestModel.fromEntity(e)) - .toList(), - ); - } -} diff --git a/lib/data/models/create_preparation_schedule_request_model.dart b/lib/data/models/create_preparation_schedule_request_model.dart deleted file mode 100644 index dbf89c76..00000000 --- a/lib/data/models/create_preparation_schedule_request_model.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; - -part 'create_preparation_schedule_request_model.g.dart'; - -@JsonSerializable() -class PreparationScheduleCreateRequestModel { - @JsonKey(name: 'preparationId') - final String id; - final String preparationName; - final int preparationTime; - final String? nextPreparationId; - - PreparationScheduleCreateRequestModel({ - required this.id, - required this.preparationName, - required this.preparationTime, - required this.nextPreparationId, - }); - - factory PreparationScheduleCreateRequestModel.fromJson( - Map json) => - _$PreparationScheduleCreateRequestModelFromJson(json); - - Map toJson() => - _$PreparationScheduleCreateRequestModelToJson(this); - - static PreparationScheduleCreateRequestModel fromEntity( - PreparationStepEntity entity) { - return PreparationScheduleCreateRequestModel( - id: entity.id, - preparationName: entity.preparationName, - preparationTime: entity.preparationTime.inMinutes, - nextPreparationId: entity.nextPreparationId, - ); - } - - PreparationStepEntity toEntity() { - return PreparationStepEntity( - id: id, - preparationName: preparationName, - preparationTime: Duration(minutes: preparationTime), - nextPreparationId: nextPreparationId, - ); - } -} - -extension PreparationScheduleCreateRequestModelListExtension - on List { - List toEntityList() { - return map((model) => model.toEntity()).toList(); - } - - static List fromEntityList( - List entities) { - return entities - .map((entity) => - PreparationScheduleCreateRequestModel.fromEntity(entity)) - .toList(); - } -} diff --git a/lib/data/models/create_preparation_step_request_model.dart b/lib/data/models/create_preparation_step_request_model.dart deleted file mode 100644 index 6ecfb6cb..00000000 --- a/lib/data/models/create_preparation_step_request_model.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; - -part 'create_preparation_step_request_model.g.dart'; - -@JsonSerializable() -class CreatePreparationStepRequestModel { - @JsonKey(name: 'preparationId') - final String id; - final String preparationName; - final int preparationTime; - final String? nextPreparationId; - - CreatePreparationStepRequestModel({ - required this.id, - required this.preparationName, - required this.preparationTime, - required this.nextPreparationId, - }); - - factory CreatePreparationStepRequestModel.fromJson( - Map json) => - _$CreatePreparationStepRequestModelFromJson(json); - - Map toJson() => - _$CreatePreparationStepRequestModelToJson(this); - - static CreatePreparationStepRequestModel fromEntity( - PreparationStepEntity entity) { - return CreatePreparationStepRequestModel( - id: entity.id, - preparationName: entity.preparationName, - preparationTime: entity.preparationTime.inMinutes, - nextPreparationId: entity.nextPreparationId, - ); - } - - PreparationStepEntity toEntity() { - return PreparationStepEntity( - id: id, - preparationName: preparationName, - preparationTime: Duration(minutes: preparationTime), - nextPreparationId: nextPreparationId, - ); - } -} diff --git a/lib/data/models/create_schedule_request_model.dart b/lib/data/models/create_schedule_request_model.dart deleted file mode 100644 index f9bb7507..00000000 --- a/lib/data/models/create_schedule_request_model.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:on_time_front/core/validation/backend_constraints.dart'; -import 'package:on_time_front/data/models/ordered_preparation_step_model.dart'; -import 'package:on_time_front/domain/entities/preparation_entity.dart'; -import 'package:on_time_front/domain/entities/schedule_entity.dart'; -import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; - -part 'create_schedule_request_model.g.dart'; - -@JsonSerializable(includeIfNull: false, explicitToJson: true) -class CreateScheduleRequestModel { - final String scheduleId; - final String placeId; - final String placeName; - final String scheduleName; - final DateTime scheduleTime; - final int moveTime; - final bool isChange; - final bool isStarted; - final int? scheduleSpareTime; - final String scheduleNote; - final String? preparationTemplateId; - final List? customPreparations; - - const CreateScheduleRequestModel({ - required this.scheduleId, - required this.placeId, - required this.placeName, - required this.scheduleName, - required this.scheduleTime, - required this.moveTime, - required this.isChange, - required this.isStarted, - required this.scheduleSpareTime, - required this.scheduleNote, - this.preparationTemplateId, - this.customPreparations, - }); - - factory CreateScheduleRequestModel.fromJson(Map json) => - _$CreateScheduleRequestModelFromJson(json); - - Map toJson() => _$CreateScheduleRequestModelToJson(this); - - static CreateScheduleRequestModel fromEntity(ScheduleEntity entity) { - final mode = _resolveCreateMode(entity); - final preparationTemplateId = mode == SchedulePreparationMode.template - ? _requireTemplateId(entity) - : null; - final customPreparations = mode == SchedulePreparationMode.custom - ? OrderedPreparationStepModel.fromPreparationEntity( - _requireCustomPreparations(entity), - ) - : null; - - return CreateScheduleRequestModel( - scheduleId: entity.id, - placeId: entity.place.id, - placeName: entity.place.placeName, - scheduleName: BackendConstraints.trimToMaxLength( - entity.scheduleName, - BackendConstraints.maxScheduleNameLength, - ), - scheduleTime: entity.scheduleTime, - moveTime: entity.moveTime.inMinutes, - isChange: entity.isChanged, - isStarted: entity.isStarted, - scheduleSpareTime: entity.scheduleSpareTime?.inMinutes, - scheduleNote: BackendConstraints.trimToMaxLength( - entity.scheduleNote, - BackendConstraints.maxLongTextLength, - ), - preparationTemplateId: preparationTemplateId, - customPreparations: customPreparations, - ); - } -} - -SchedulePreparationMode _resolveCreateMode(ScheduleEntity entity) { - if (entity.preparationMode != null) { - return entity.preparationMode!; - } - if (entity.preparationTemplateId != null) { - return SchedulePreparationMode.template; - } - if (entity.customPreparations != null) { - return SchedulePreparationMode.custom; - } - return SchedulePreparationMode.defaultPreparation; -} - -String _requireTemplateId(ScheduleEntity entity) { - final templateId = entity.preparationTemplateId; - if (templateId == null || templateId.isEmpty) { - throw ArgumentError('TEMPLATE schedules require preparationTemplateId'); - } - if (entity.customPreparations != null) { - throw ArgumentError('TEMPLATE schedules cannot include customPreparations'); - } - return templateId; -} - -PreparationEntity _requireCustomPreparations(ScheduleEntity entity) { - final preparation = entity.customPreparations; - if (preparation == null) { - throw ArgumentError('CUSTOM schedules require customPreparations'); - } - if (entity.preparationTemplateId != null) { - throw ArgumentError( - 'CUSTOM schedules cannot include preparationTemplateId', - ); - } - return preparation; -} diff --git a/lib/data/models/fcm_token_register_request_model.dart b/lib/data/models/fcm_token_register_request_model.dart deleted file mode 100644 index 17842b90..00000000 --- a/lib/data/models/fcm_token_register_request_model.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'fcm_token_register_request_model.g.dart'; - -@JsonSerializable() -class FcmTokenRegisterRequestModel { - final String firebaseToken; - final String? deviceId; - - FcmTokenRegisterRequestModel({ - required this.firebaseToken, - this.deviceId, - }); - - factory FcmTokenRegisterRequestModel.fromJson(Map json) => - _$FcmTokenRegisterRequestModelFromJson(json); - Map toJson() => _$FcmTokenRegisterRequestModelToJson(this); -} diff --git a/lib/data/models/get_place_response_model.dart b/lib/data/models/get_place_response_model.dart deleted file mode 100644 index fee97e9f..00000000 --- a/lib/data/models/get_place_response_model.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:on_time_front/domain/entities/place_entity.dart'; - -part 'get_place_response_model.g.dart'; - -@JsonSerializable() -class GetPlaceResponseModel { - final String placeId; - final String placeName; - - const GetPlaceResponseModel({ - required this.placeId, - required this.placeName, - }); - - factory GetPlaceResponseModel.fromJson(Map json) => - _$GetPlaceResponseModelFromJson(json); - - Map toJson() => _$GetPlaceResponseModelToJson(this); - - static GetPlaceResponseModel fromEntity(PlaceEntity entity) { - return GetPlaceResponseModel( - placeId: entity.id, - placeName: entity.placeName, - ); - } - - PlaceEntity toEntity() { - return PlaceEntity( - id: placeId, - placeName: placeName, - ); - } -} diff --git a/lib/data/models/get_preparation_step_response_model.dart b/lib/data/models/get_preparation_step_response_model.dart deleted file mode 100644 index 55d6814c..00000000 --- a/lib/data/models/get_preparation_step_response_model.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; -import 'package:on_time_front/domain/entities/preparation_entity.dart'; - -part 'get_preparation_step_response_model.g.dart'; - -@JsonSerializable() -class GetPreparationStepResponseModel { - @JsonKey(name: 'preparationId') - final String id; - final String preparationName; - final int preparationTime; - final String? nextPreparationId; - - GetPreparationStepResponseModel({ - required this.id, - required this.preparationName, - required this.preparationTime, - required this.nextPreparationId, - }); - - factory GetPreparationStepResponseModel.fromJson(Map json) => - _$GetPreparationStepResponseModelFromJson(json); - - Map toJson() => - _$GetPreparationStepResponseModelToJson(this); - - PreparationStepEntity toEntity() { - return PreparationStepEntity( - id: id, - preparationName: preparationName, - preparationTime: Duration(minutes: preparationTime), - nextPreparationId: nextPreparationId, - ); - } - - static GetPreparationStepResponseModel fromEntity( - PreparationStepEntity entity) { - return GetPreparationStepResponseModel( - id: entity.id, - preparationName: entity.preparationName, - preparationTime: entity.preparationTime.inMinutes, - nextPreparationId: entity.nextPreparationId, - ); - } -} - -extension PreparationResponseModelListExtension - on List { - PreparationEntity toPreparationEntity() { - final steps = map((model) => model.toEntity()).toList(); - return PreparationEntity(preparationStepList: steps).ordered; - } -} diff --git a/lib/data/models/get_preparation_user_response_model.dart b/lib/data/models/get_preparation_user_response_model.dart deleted file mode 100644 index 5bea8872..00000000 --- a/lib/data/models/get_preparation_user_response_model.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:on_time_front/domain/entities/preparation_entity.dart'; -import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; - -part 'get_preparation_user_response_model.g.dart'; - -@JsonSerializable() -class PreparationUserResponseModel { - @JsonKey(name: 'preparationId') - final String id; - final String preparationName; - final int preparationTime; - final String? nextPreparationId; - - PreparationUserResponseModel({ - required this.id, - required this.preparationName, - required this.preparationTime, - required this.nextPreparationId, - }); - - factory PreparationUserResponseModel.fromJson(Map json) => - _$PreparationUserResponseModelFromJson(json); - - Map toJson() => _$PreparationUserResponseModelToJson(this); - - PreparationStepEntity toEntity() { - return PreparationStepEntity( - id: id, - preparationName: preparationName, - preparationTime: Duration(minutes: preparationTime), - nextPreparationId: nextPreparationId, - ); - } - - static PreparationUserResponseModel fromEntity(PreparationStepEntity entity) { - return PreparationUserResponseModel( - id: entity.id, - preparationName: entity.preparationName, - preparationTime: entity.preparationTime.inMinutes, - nextPreparationId: entity.nextPreparationId, - ); - } -} - -@JsonSerializable() -class PreparationUserResponse { - final String status; - final String code; - final String message; - final List data; - - PreparationUserResponse({ - required this.status, - required this.code, - required this.message, - required this.data, - }); - - factory PreparationUserResponse.fromJson(Map json) => - _$PreparationUserResponseFromJson(json); - - Map toJson() => _$PreparationUserResponseToJson(this); - - PreparationEntity toEntity() { - return PreparationEntity( - preparationStepList: data.map((model) => model.toEntity()).toList(), - ); - } -} diff --git a/lib/data/models/get_schedule_response_model.dart b/lib/data/models/get_schedule_response_model.dart deleted file mode 100644 index 5604df2f..00000000 --- a/lib/data/models/get_schedule_response_model.dart +++ /dev/null @@ -1,140 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:on_time_front/data/models/get_place_response_model.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/domain/entities/schedule_preparation_mode.dart'; - -part 'get_schedule_response_model.g.dart'; - -@JsonSerializable(createFactory: false) -class GetScheduleResponseModel { - final String scheduleId; - final GetPlaceResponseModel place; - final String scheduleName; - final DateTime scheduleTime; - final int moveTime; - final int scheduleSpareTime; - final String scheduleNote; - final int? latenessTime; - final String? doneStatus; - final DateTime? startedAt; - final DateTime? finishedAt; - final SchedulePreparationMode? preparationMode; - final String? preparationTemplateId; - final String? preparationTemplateName; - final bool preparationTemplateDeleted; - final bool preparationFrozen; - - const GetScheduleResponseModel({ - required this.scheduleId, - required this.place, - required this.scheduleName, - required this.scheduleTime, - required this.moveTime, - required this.scheduleSpareTime, - required this.scheduleNote, - this.latenessTime = 0, - this.doneStatus = 'NOT_ENDED', - this.startedAt, - this.finishedAt, - this.preparationMode, - this.preparationTemplateId, - this.preparationTemplateName, - this.preparationTemplateDeleted = false, - this.preparationFrozen = false, - }); - - ScheduleEntity toEntity() { - return ScheduleEntity( - id: scheduleId, - place: place.toEntity(), - scheduleName: scheduleName, - scheduleTime: scheduleTime, - moveTime: Duration(minutes: moveTime), - isChanged: false, - isStarted: preparationFrozen || startedAt != null, - scheduleSpareTime: Duration(minutes: scheduleSpareTime), - scheduleNote: scheduleNote, - latenessTime: latenessTime ?? -1, - doneStatus: _mapDoneStatus(doneStatus), - startedAt: startedAt, - finishedAt: finishedAt, - preparationMode: preparationMode, - preparationTemplateId: preparationTemplateId, - preparationTemplateName: preparationTemplateName, - preparationTemplateDeleted: preparationTemplateDeleted, - preparationFrozen: preparationFrozen, - ); - } - - factory GetScheduleResponseModel.fromJson(Map json) { - return GetScheduleResponseModel( - scheduleId: json['scheduleId'] as String, - place: _placeFromJson(json), - scheduleName: json['scheduleName'] as String? ?? '', - scheduleTime: DateTime.parse(json['scheduleTime'] as String), - moveTime: (json['moveTime'] as num?)?.toInt() ?? 0, - scheduleSpareTime: (json['scheduleSpareTime'] as num?)?.toInt() ?? 0, - scheduleNote: json['scheduleNote'] as String? ?? '', - latenessTime: (json['latenessTime'] as num?)?.toInt() ?? 0, - doneStatus: json['doneStatus'] as String? ?? 'NOT_ENDED', - startedAt: _optionalDateTime(json['startedAt']), - finishedAt: _optionalDateTime(json['finishedAt']), - preparationMode: _preparationModeFromJson(json['preparationMode']), - preparationTemplateId: json['preparationTemplateId'] as String?, - preparationTemplateName: json['preparationTemplateName'] as String?, - preparationTemplateDeleted: - json['preparationTemplateDeleted'] as bool? ?? false, - preparationFrozen: - json['preparationFrozen'] as bool? ?? json['startedAt'] != null, - ); - } - - Map toJson() => _$GetScheduleResponseModelToJson(this); -} - -GetPlaceResponseModel _placeFromJson(Map json) { - final placeJson = json['place']; - if (placeJson is Map) { - return GetPlaceResponseModel.fromJson(placeJson); - } - return GetPlaceResponseModel.fromEntity( - PlaceEntity( - id: json['placeId'] as String? ?? '', - placeName: json['placeName'] as String? ?? '', - ), - ); -} - -DateTime? _optionalDateTime(Object? value) { - if (value is String && value.isNotEmpty) { - return DateTime.parse(value); - } - return null; -} - -SchedulePreparationMode? _preparationModeFromJson(Object? value) { - switch (value) { - case 'DEFAULT': - return SchedulePreparationMode.defaultPreparation; - case 'TEMPLATE': - return SchedulePreparationMode.template; - case 'CUSTOM': - return SchedulePreparationMode.custom; - } - return null; -} - -ScheduleDoneStatus _mapDoneStatus(String? serverValue) { - switch (serverValue) { - case 'LATE': - return ScheduleDoneStatus.lateEnd; - case 'NORMAL': - return ScheduleDoneStatus.normalEnd; - case 'ABNORMAL': - return ScheduleDoneStatus.abnormalEnd; - case 'NOT_ENDED': - default: - return ScheduleDoneStatus.notEnded; - } -} diff --git a/lib/data/models/get_user_response_model.dart b/lib/data/models/get_user_response_model.dart deleted file mode 100644 index bca1df78..00000000 --- a/lib/data/models/get_user_response_model.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:on_time_front/domain/entities/user_entity.dart'; - -part 'get_user_response_model.g.dart'; - -@JsonSerializable() -class GetUserResponseModel { - final int userId; - final String email; - final String name; - final int? spareTime; - final String? note; - final double? punctualityScore; - final String? role; - - const GetUserResponseModel({ - required this.userId, - required this.email, - required this.name, - required this.spareTime, - required this.punctualityScore, - this.role, - this.note, - }); - - UserEntity toEntity() { - return UserEntity( - id: userId.toString(), - email: email, - name: name, - spareTime: Duration(minutes: spareTime ?? 0), - score: punctualityScore ?? -1, - isOnboardingCompleted: role == 'GUEST' ? false : true, - note: note ?? '', - ); - } - - factory GetUserResponseModel.fromJson(Map json) => - _$GetUserResponseModelFromJson(json); - - Map toJson() => _$GetUserResponseModelToJson(this); -} diff --git a/lib/data/models/ordered_preparation_step_model.dart b/lib/data/models/ordered_preparation_step_model.dart deleted file mode 100644 index a7458634..00000000 --- a/lib/data/models/ordered_preparation_step_model.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:on_time_front/domain/entities/preparation_entity.dart'; -import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; - -class OrderedPreparationStepModel { - @JsonKey(name: 'preparationId') - final String id; - final String preparationName; - final int preparationTime; - final int orderIndex; - - const OrderedPreparationStepModel({ - required this.id, - required this.preparationName, - required this.preparationTime, - required this.orderIndex, - }); - - factory OrderedPreparationStepModel.fromJson(Map json) { - return OrderedPreparationStepModel( - id: json['preparationId'] as String, - preparationName: json['preparationName'] as String, - preparationTime: (json['preparationTime'] as num).toInt(), - orderIndex: (json['orderIndex'] as num).toInt(), - ); - } - - Map toJson() => { - 'preparationId': id, - 'preparationName': preparationName, - 'preparationTime': preparationTime, - 'orderIndex': orderIndex, - }; - - static List fromPreparationEntity( - PreparationEntity preparation, - ) { - final orderedSteps = preparation.ordered.preparationStepList; - return [ - for (var index = 0; index < orderedSteps.length; index++) - OrderedPreparationStepModel.fromEntity( - orderedSteps[index], - orderIndex: index, - ), - ]; - } - - static OrderedPreparationStepModel fromEntity( - PreparationStepEntity entity, { - required int orderIndex, - }) { - return OrderedPreparationStepModel( - id: entity.id, - preparationName: entity.preparationName, - preparationTime: entity.preparationTime.inMinutes, - orderIndex: orderIndex, - ); - } - - PreparationStepEntity toEntity({String? nextPreparationId}) { - return PreparationStepEntity( - id: id, - preparationName: preparationName, - preparationTime: Duration(minutes: preparationTime), - nextPreparationId: nextPreparationId, - ); - } -} - -extension OrderedPreparationStepModelListExtension - on List { - PreparationEntity toPreparationEntity() { - final sorted = [...this] - ..sort((a, b) => a.orderIndex.compareTo(b.orderIndex)); - return PreparationEntity( - preparationStepList: [ - for (var index = 0; index < sorted.length; index++) - sorted[index].toEntity( - nextPreparationId: index + 1 < sorted.length - ? sorted[index + 1].id - : null, - ), - ], - ); - } -} diff --git a/lib/data/models/preparation_template_model.dart b/lib/data/models/preparation_template_model.dart deleted file mode 100644 index 7af4d77b..00000000 --- a/lib/data/models/preparation_template_model.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:on_time_front/data/models/ordered_preparation_step_model.dart'; -import 'package:on_time_front/domain/entities/preparation_entity.dart'; -import 'package:on_time_front/domain/entities/preparation_template_entity.dart'; - -class PreparationTemplateModel { - final String templateId; - final String templateName; - final DateTime createdAt; - final DateTime updatedAt; - final DateTime? deletedAt; - final List preparations; - - const PreparationTemplateModel({ - required this.templateId, - required this.templateName, - required this.createdAt, - required this.updatedAt, - this.deletedAt, - required this.preparations, - }); - - factory PreparationTemplateModel.fromJson(Map json) { - return PreparationTemplateModel( - templateId: json['templateId'] as String, - templateName: json['templateName'] as String, - createdAt: DateTime.parse(json['createdAt'] as String), - updatedAt: DateTime.parse(json['updatedAt'] as String), - deletedAt: _optionalDateTime(json['deletedAt']), - preparations: (json['preparations'] as List? ?? const []) - .map( - (item) => OrderedPreparationStepModel.fromJson( - item as Map, - ), - ) - .toList(), - ); - } - - Map toJson() => { - 'templateId': templateId, - 'templateName': templateName, - 'createdAt': createdAt.toIso8601String(), - 'updatedAt': updatedAt.toIso8601String(), - 'deletedAt': deletedAt?.toIso8601String(), - 'preparations': preparations.map((step) => step.toJson()).toList(), - }; - - PreparationTemplateEntity toEntity() { - return PreparationTemplateEntity( - id: templateId, - name: templateName, - createdAt: createdAt, - updatedAt: updatedAt, - deletedAt: deletedAt, - preparation: preparations.toPreparationEntity(), - ); - } -} - -class UpsertPreparationTemplateRequestModel { - final String templateId; - final String templateName; - final List preparations; - - const UpsertPreparationTemplateRequestModel({ - required this.templateId, - required this.templateName, - required this.preparations, - }); - - factory UpsertPreparationTemplateRequestModel.fromJson( - Map json, - ) { - return UpsertPreparationTemplateRequestModel( - templateId: json['templateId'] as String, - templateName: json['templateName'] as String, - preparations: (json['preparations'] as List) - .map( - (item) => OrderedPreparationStepModel.fromJson( - item as Map, - ), - ) - .toList(), - ); - } - - Map toJson() => { - 'templateId': templateId, - 'templateName': templateName, - 'preparations': preparations.map((step) => step.toJson()).toList(), - }; - - static UpsertPreparationTemplateRequestModel fromEntity( - PreparationTemplateEntity entity, - ) { - return UpsertPreparationTemplateRequestModel( - templateId: entity.id, - templateName: entity.name, - preparations: OrderedPreparationStepModel.fromPreparationEntity( - entity.preparation, - ), - ); - } - - static UpsertPreparationTemplateRequestModel fromValues({ - required String templateId, - required String templateName, - required PreparationEntity preparation, - }) { - return UpsertPreparationTemplateRequestModel( - templateId: templateId, - templateName: templateName, - preparations: OrderedPreparationStepModel.fromPreparationEntity( - preparation, - ), - ); - } -} - -DateTime? _optionalDateTime(Object? value) { - if (value is String && value.isNotEmpty) { - return DateTime.parse(value); - } - return null; -} diff --git a/lib/data/models/scheduled_alarm_record_model.dart b/lib/data/models/scheduled_alarm_record_model.dart index 05efb93c..a74ff659 100644 --- a/lib/data/models/scheduled_alarm_record_model.dart +++ b/lib/data/models/scheduled_alarm_record_model.dart @@ -6,20 +6,23 @@ class ScheduledAlarmRecordModel { const ScheduledAlarmRecordModel(this.record); factory ScheduledAlarmRecordModel.fromJson(Map json) { - final payload = (json['payload'] as Map? ?? const {}) - .map((key, value) => MapEntry(key, value.toString())); + final payload = (json['payload'] as Map? ?? const {}).map( + (key, value) => MapEntry(key, value.toString()), + ); return ScheduledAlarmRecordModel( ScheduledAlarmRecord( scheduleId: json['scheduleId'] as String, alarmTime: DateTime.parse(json['alarmTime'] as String), - preparationStartTime: - DateTime.parse(json['preparationStartTime'] as String), + preparationStartTime: DateTime.parse( + json['preparationStartTime'] as String, + ), scheduleFingerprint: json['scheduleFingerprint'] as String? ?? '', nativeAlarmId: (json['nativeAlarmId'] as num?)?.toInt(), - fallbackNotificationId: - (json['fallbackNotificationId'] as num?)?.toInt(), - provider: - AlarmProviderWireValue.fromWireValue(json['provider'] as String?), + fallbackNotificationId: (json['fallbackNotificationId'] as num?) + ?.toInt(), + provider: AlarmProviderWireValue.fromWireValue( + json['provider'] as String?, + ), scheduleTitle: json['scheduleTitle'] as String? ?? '', payload: payload, ), diff --git a/lib/data/models/sign_in_user_response_model.dart b/lib/data/models/sign_in_user_response_model.dart deleted file mode 100644 index c7f6bd16..00000000 --- a/lib/data/models/sign_in_user_response_model.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:on_time_front/domain/entities/user_entity.dart'; - -part 'sign_in_user_response_model.g.dart'; - -@JsonSerializable() -class SignInUserResponseModel { - final int userId; - final String email; - final String name; - final int? spareTime; - final String? note; - final double? punctualityScore; - final String? role; - - const SignInUserResponseModel({ - required this.userId, - required this.email, - required this.name, - required this.spareTime, - required this.punctualityScore, - this.role, - this.note, - }); - - UserEntity toEntity() { - return UserEntity( - id: userId.toString(), - email: email, - name: name, - spareTime: Duration(minutes: spareTime ?? 0), - score: punctualityScore ?? -1, - isOnboardingCompleted: role == 'GUEST' ? false : true, - note: note ?? '', - ); - } - - factory SignInUserResponseModel.fromJson(Map json) => - _$SignInUserResponseModelFromJson(json); - - Map toJson() => _$SignInUserResponseModelToJson(this); -} diff --git a/lib/data/models/sign_in_with_apple_request_model.dart b/lib/data/models/sign_in_with_apple_request_model.dart deleted file mode 100644 index 6c009a1d..00000000 --- a/lib/data/models/sign_in_with_apple_request_model.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'sign_in_with_apple_request_model.g.dart'; - -@JsonSerializable() -class SignInWithAppleRequestModel { - final String idToken; - final String authCode; - final String fullName; - final String? email; - - SignInWithAppleRequestModel({ - required this.idToken, - required this.authCode, - required this.fullName, - this.email, - }); - - factory SignInWithAppleRequestModel.fromJson(Map json) => - _$SignInWithAppleRequestModelFromJson(json); - - Map toJson() { - final map = _$SignInWithAppleRequestModelToJson(this); - map.removeWhere((key, value) => value == null); - return map; - } -} diff --git a/lib/data/models/sign_in_with_google_request_model.dart b/lib/data/models/sign_in_with_google_request_model.dart deleted file mode 100644 index 361538a0..00000000 --- a/lib/data/models/sign_in_with_google_request_model.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'sign_in_with_google_request_model.g.dart'; - -@JsonSerializable() -class SignInWithGoogleRequestModel { - final String idToken; - final String refreshToken; - - SignInWithGoogleRequestModel({ - required this.idToken, - required this.refreshToken, - }); - - factory SignInWithGoogleRequestModel.fromJson(Map json) => - _$SignInWithGoogleRequestModelFromJson(json); - - Map toJson() => _$SignInWithGoogleRequestModelToJson(this); -} diff --git a/lib/data/models/update_preparation_schedule_request_model.dart b/lib/data/models/update_preparation_schedule_request_model.dart deleted file mode 100644 index d03b5d71..00000000 --- a/lib/data/models/update_preparation_schedule_request_model.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; - -part 'update_preparation_schedule_request_model.g.dart'; - -@JsonSerializable() -class PreparationScheduleModifyRequestModel { - @JsonKey(name: 'preparationId') - final String id; - final String preparationName; - final int preparationTime; - final String? nextPreparationId; - - PreparationScheduleModifyRequestModel({ - required this.id, - required this.preparationName, - required this.preparationTime, - required this.nextPreparationId, - }); - - factory PreparationScheduleModifyRequestModel.fromJson( - Map json) => - _$PreparationScheduleModifyRequestModelFromJson(json); - - Map toJson() => - _$PreparationScheduleModifyRequestModelToJson(this); - - static PreparationScheduleModifyRequestModel fromEntity( - PreparationStepEntity entity) { - return PreparationScheduleModifyRequestModel( - id: entity.id, - preparationName: entity.preparationName, - preparationTime: entity.preparationTime.inMinutes, - nextPreparationId: entity.nextPreparationId, - ); - } - - PreparationStepEntity toEntity() { - return PreparationStepEntity( - id: id, - preparationName: preparationName, - preparationTime: Duration(minutes: preparationTime), - nextPreparationId: nextPreparationId, - ); - } -} - -extension PreparationScheduleModifyRequestModelListExtension - on List { - List toEntityList() { - return map((model) => model.toEntity()).toList(); - } - - static List fromEntityList( - List entities) { - return entities - .map((entity) => - PreparationScheduleModifyRequestModel.fromEntity(entity)) - .toList(); - } -} diff --git a/lib/data/models/update_preparation_user_request_model.dart b/lib/data/models/update_preparation_user_request_model.dart deleted file mode 100644 index d0c9c4be..00000000 --- a/lib/data/models/update_preparation_user_request_model.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; - -part 'update_preparation_user_request_model.g.dart'; - -@JsonSerializable() -class PreparationUserModifyRequestModel { - @JsonKey(name: 'preparationId') - final String id; - final String preparationName; - final int preparationTime; - final String? nextPreparationId; - - PreparationUserModifyRequestModel({ - required this.id, - required this.preparationName, - required this.preparationTime, - required this.nextPreparationId, - }); - - factory PreparationUserModifyRequestModel.fromJson( - Map json) => - _$PreparationUserModifyRequestModelFromJson(json); - - Map toJson() => - _$PreparationUserModifyRequestModelToJson(this); - - static PreparationUserModifyRequestModel fromEntity( - PreparationStepEntity entity) { - return PreparationUserModifyRequestModel( - id: entity.id, - preparationName: entity.preparationName, - preparationTime: entity.preparationTime.inMinutes, - nextPreparationId: entity.nextPreparationId, - ); - } - - PreparationStepEntity toEntity() { - return PreparationStepEntity( - id: id, - preparationName: preparationName, - preparationTime: Duration(minutes: preparationTime), - nextPreparationId: nextPreparationId, - ); - } -} - -extension PreparationUserModifyRequestModelListExtension - on List { - List toEntityList() { - return map((model) => model.toEntity()).toList(); - } - - static List fromEntityList( - List entities) { - return entities - .map((entity) => PreparationUserModifyRequestModel.fromEntity(entity)) - .toList(); - } -} diff --git a/lib/data/models/update_schedule_request_model.dart b/lib/data/models/update_schedule_request_model.dart deleted file mode 100644 index 5ab59ff4..00000000 --- a/lib/data/models/update_schedule_request_model.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:on_time_front/core/validation/backend_constraints.dart'; -import 'package:on_time_front/data/models/ordered_preparation_step_model.dart'; -import 'package:on_time_front/domain/entities/schedule_entity.dart'; -import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; - -part 'update_schedule_request_model.g.dart'; - -@JsonSerializable(includeIfNull: false, explicitToJson: true) -class UpdateScheduleRequestModel { - final String scheduleId; - final String placeId; - final String placeName; - final String scheduleName; - final DateTime scheduleTime; - final int moveTime; - final int? scheduleSpareTime; - final String scheduleNote; - final SchedulePreparationMode? preparationMode; - final String? preparationTemplateId; - final List? customPreparations; - - const UpdateScheduleRequestModel({ - required this.scheduleId, - required this.placeId, - required this.placeName, - required this.scheduleName, - required this.scheduleTime, - required this.moveTime, - this.scheduleSpareTime, - required this.scheduleNote, - this.preparationMode, - this.preparationTemplateId, - this.customPreparations, - }); - - factory UpdateScheduleRequestModel.fromJson(Map json) => - _$UpdateScheduleRequestModelFromJson(json); - - Map toJson() => _$UpdateScheduleRequestModelToJson(this); - - static UpdateScheduleRequestModel fromEntity( - ScheduleEntity entity, { - bool includePreparationSource = false, - }) { - final preparationMode = includePreparationSource - ? entity.preparationMode - : null; - final preparationTemplateId = _templateIdForMode(entity, preparationMode); - final customPreparations = _customPreparationsForMode( - entity, - preparationMode, - ); - - return UpdateScheduleRequestModel( - scheduleId: entity.id, - placeId: entity.place.id, - placeName: entity.place.placeName, - scheduleName: BackendConstraints.trimToMaxLength( - entity.scheduleName, - BackendConstraints.maxScheduleNameLength, - ), - scheduleTime: entity.scheduleTime, - moveTime: entity.moveTime.inMinutes, - scheduleSpareTime: entity.scheduleSpareTime?.inMinutes, - scheduleNote: BackendConstraints.trimToMaxLength( - entity.scheduleNote, - BackendConstraints.maxLongTextLength, - ), - preparationMode: preparationMode, - preparationTemplateId: preparationTemplateId, - customPreparations: customPreparations, - ); - } -} - -String? _templateIdForMode( - ScheduleEntity entity, - SchedulePreparationMode? preparationMode, -) { - if (preparationMode != SchedulePreparationMode.template) { - return null; - } - final templateId = entity.preparationTemplateId; - if (templateId == null || templateId.isEmpty) { - throw ArgumentError('TEMPLATE schedules require preparationTemplateId'); - } - if (entity.customPreparations != null) { - throw ArgumentError('TEMPLATE schedules cannot include customPreparations'); - } - return templateId; -} - -List? _customPreparationsForMode( - ScheduleEntity entity, - SchedulePreparationMode? preparationMode, -) { - if (preparationMode != SchedulePreparationMode.custom) { - return null; - } - final preparation = entity.customPreparations; - if (preparation == null) { - throw ArgumentError('CUSTOM schedules require customPreparations'); - } - if (entity.preparationTemplateId != null) { - throw ArgumentError( - 'CUSTOM schedules cannot include preparationTemplateId', - ); - } - return OrderedPreparationStepModel.fromPreparationEntity(preparation); -} diff --git a/lib/data/models/update_spare_time_request_model.dart b/lib/data/models/update_spare_time_request_model.dart deleted file mode 100644 index 8b34aa69..00000000 --- a/lib/data/models/update_spare_time_request_model.dart +++ /dev/null @@ -1,13 +0,0 @@ -class UpdateSpareTimeRequestModel { - final int newSpareTime; - - UpdateSpareTimeRequestModel({required this.newSpareTime}); - - Map toJson() => { - 'newSpareTime': newSpareTime, - }; - - factory UpdateSpareTimeRequestModel.fromDuration(Duration duration) { - return UpdateSpareTimeRequestModel(newSpareTime: duration.inMinutes); - } -} diff --git a/lib/data/repositories/alarm_registry_repository_impl.dart b/lib/data/repositories/alarm_registry_repository_impl.dart index 4ff74c20..75fa569a 100644 --- a/lib/data/repositories/alarm_registry_repository_impl.dart +++ b/lib/data/repositories/alarm_registry_repository_impl.dart @@ -17,10 +17,11 @@ class AlarmRegistryRepositoryImpl implements AlarmRegistryRepository { @override Future upsert(ScheduledAlarmRecord record) async { final records = await loadAll(); - final nextRecords = records - .where((existing) => existing.scheduleId != record.scheduleId) - .toList() - ..add(record); + final nextRecords = + records + .where((existing) => existing.scheduleId != record.scheduleId) + .toList() + ..add(record); await replaceAll(nextRecords); } diff --git a/lib/data/repositories/alarm_repository_impl.dart b/lib/data/repositories/alarm_repository_impl.dart index bb83e0dd..2d7134c9 100644 --- a/lib/data/repositories/alarm_repository_impl.dart +++ b/lib/data/repositories/alarm_repository_impl.dart @@ -1,124 +1,84 @@ import 'package:injectable/injectable.dart'; -import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; -import 'package:on_time_front/core/services/app_metadata_service.dart'; -import 'package:on_time_front/core/services/device_info_service/shared.dart'; -import 'package:on_time_front/core/validation/backend_constraints.dart'; -import 'package:on_time_front/data/data_sources/alarm_remote_data_source.dart'; +import 'package:on_time_front/core/constants/local_profile.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/daos/user_dao.dart'; import 'package:on_time_front/domain/entities/alarm_entities.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/domain/repositories/alarm_repository.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:uuid/uuid.dart'; +import 'package:on_time_front/domain/repositories/preparation_repository.dart'; +import 'package:on_time_front/domain/repositories/schedule_repository.dart'; @Singleton(as: AlarmRepository) class AlarmRepositoryImpl implements AlarmRepository { - final AlarmRemoteDataSource remoteDataSource; - final AlarmSchedulerService schedulerService; - final AppMetadataProvider appMetadataProvider; - AlarmRepositoryImpl({ - required this.remoteDataSource, - required this.schedulerService, - required this.appMetadataProvider, - }); + required AppDatabase database, + required ScheduleRepository scheduleRepository, + required PreparationRepository preparationRepository, + }) : _userDao = database.userDao, + _scheduleRepository = scheduleRepository, + _preparationRepository = preparationRepository; - static const _deviceIdKey = 'alarm_device_id'; + final UserDao _userDao; + final ScheduleRepository _scheduleRepository; + final PreparationRepository _preparationRepository; @override - Future getDeviceId() async { - final prefs = await SharedPreferences.getInstance(); - final existing = prefs.getString(_deviceIdKey); - if (existing != null && - BackendConstraints.deviceIdPattern.hasMatch(existing)) { - return existing; - } - - final next = const Uuid().v4(); - await prefs.setString(_deviceIdKey, next); - return next; - } - - @override - Future buildCurrentDeviceInfo() async { - final capabilities = await schedulerService.getCapabilities(); - final metadata = await appMetadataProvider.getMetadata(); - return AlarmDeviceInfo( - deviceId: await getDeviceId(), - platform: _platformWireValue(), - appVersion: metadata.version, - osVersion: _osWireValue(), - supportsNativeAlarm: capabilities.supportsNativeAlarm, - nativeAlarmProvider: capabilities.nativeAlarmProvider, - fallbackProvider: capabilities.fallbackProvider, + Future getAlarmSettings() async { + final settings = await _userDao.getAlarmSettings(localProfileId); + return AlarmSettings( + alarmsEnabled: settings.enabled, + defaultAlarmOffsetMinutes: settings.offsetMinutes, + detailedNotificationContent: settings.detailedNotificationContent, ); } @override - Future getAlarmSettings() { - return remoteDataSource.getAlarmSettings(); - } - - @override - Future updateAlarmSettings({required bool alarmsEnabled}) { - return remoteDataSource.updateAlarmSettings(alarmsEnabled: alarmsEnabled); - } - - @override - Future registerCurrentDevice(AlarmDeviceInfo deviceInfo) { - return remoteDataSource.registerCurrentDevice(deviceInfo); - } - - @override - Future unregisterCurrentDevice(String deviceId) { - return remoteDataSource.unregisterCurrentDevice(deviceId); + Future updateAlarmSettings({ + required bool alarmsEnabled, + }) async { + await _userDao.updateAlarmSettings( + userId: localProfileId, + enabled: alarmsEnabled, + ); + return AlarmSettings( + alarmsEnabled: alarmsEnabled, + defaultAlarmOffsetMinutes: 0, + updatedAt: DateTime.now(), + detailedNotificationContent: (await _userDao.getAlarmSettings( + localProfileId, + )).detailedNotificationContent, + ); } @override Future> getAlarmWindow( DateTime startDate, DateTime endDate, - ) { - return remoteDataSource.getAlarmWindow(startDate, endDate); - } - - @override - Future postAlarmStatus(AlarmStatusReport report) { - return remoteDataSource.postAlarmStatus(report); - } - - String _platformWireValue() { - try { - switch (DeviceInfoService.platformType) { - case PlatformType.android: - return 'android'; - case PlatformType.ios: - return 'ios'; - case PlatformType.web: - return 'web'; - } - } catch (_) { - return 'unknown'; - } - } - - String _osWireValue() { - try { - switch (DeviceInfoService.osType) { - case OsType.android: - return 'android'; - case OsType.ios: - return 'ios'; - case OsType.macos: - return 'macos'; - case OsType.windows: - return 'windows'; - case OsType.linux: - return 'linux'; - case OsType.unknown: - return 'unknown'; - } - } catch (_) { - return 'unknown'; + ) async { + final schedules = await _scheduleRepository.getSchedulesByDate( + startDate, + endDate, + ); + final defaultPreparation = await _preparationRepository + .getDefualtPreparation(); + final result = []; + for (final schedule in schedules) { + await _preparationRepository.getPreparationByScheduleId(schedule.id); + final custom = await _preparationRepository.preparationStream.first.then( + (preparations) => preparations[schedule.id], + ); + result.add( + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + schedule, + PreparationWithTimeEntity.fromPreparation( + custom == null || custom.preparationStepList.isEmpty + ? defaultPreparation + : custom, + ), + ), + ); } + return result; } } diff --git a/lib/data/repositories/analytics_preference_repository_impl.dart b/lib/data/repositories/analytics_preference_repository_impl.dart deleted file mode 100644 index fd6b586d..00000000 --- a/lib/data/repositories/analytics_preference_repository_impl.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/data/data_sources/analytics_preference_local_data_source.dart'; -import 'package:on_time_front/data/data_sources/analytics_preference_remote_data_source.dart'; -import 'package:on_time_front/domain/entities/analytics_preference.dart'; -import 'package:on_time_front/domain/repositories/analytics_preference_repository.dart'; - -@Singleton(as: AnalyticsPreferenceRepository) -class AnalyticsPreferenceRepositoryImpl implements AnalyticsPreferenceRepository { - AnalyticsPreferenceRepositoryImpl({ - required this.localDataSource, - required this.remoteDataSource, - }); - - final AnalyticsPreferenceLocalDataSource localDataSource; - final AnalyticsPreferenceRemoteDataSource remoteDataSource; - - @override - Future loadLocalPreference() { - return localDataSource.loadPreference(); - } - - @override - Future saveLocalPreference(bool enabled) { - return localDataSource.savePreference(enabled); - } - - @override - Future loadAccountPreference() { - return remoteDataSource.getAnalyticsPreference(); - } - - @override - Future updateAccountPreference(bool enabled) { - return remoteDataSource.updateAnalyticsPreference(enabled: enabled); - } -} diff --git a/lib/data/repositories/early_start_session_repository_impl.dart b/lib/data/repositories/early_start_session_repository_impl.dart index 3cf84b60..826576cc 100644 --- a/lib/data/repositories/early_start_session_repository_impl.dart +++ b/lib/data/repositories/early_start_session_repository_impl.dart @@ -25,7 +25,9 @@ class EarlyStartSessionRepositoryImpl implements EarlyStartSessionRepository { final startedAt = await localDataSource.loadSessionStartedAt(scheduleId); if (startedAt == null) return null; return EarlyStartSessionEntity( - scheduleId: scheduleId, startedAt: startedAt); + scheduleId: scheduleId, + startedAt: startedAt, + ); } @override diff --git a/lib/data/repositories/preparation_repository_impl.dart b/lib/data/repositories/preparation_repository_impl.dart index 834f1dc7..2a212b8f 100644 --- a/lib/data/repositories/preparation_repository_impl.dart +++ b/lib/data/repositories/preparation_repository_impl.dart @@ -1,33 +1,33 @@ -import 'dart:async'; - import 'package:injectable/injectable.dart'; +import 'package:on_time_front/core/constants/local_profile.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/daos/user_dao.dart'; import 'package:on_time_front/data/data_sources/preparation_local_data_source.dart'; -import 'package:on_time_front/data/data_sources/preparation_remote_data_source.dart'; -import 'package:on_time_front/data/models/create_defualt_preparation_request_model.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/preparation_repository.dart'; +import 'package:on_time_front/domain/repositories/user_repository.dart'; import 'package:rxdart/subjects.dart'; @Singleton(as: PreparationRepository) class PreparationRepositoryImpl implements PreparationRepository { - final PreparationRemoteDataSource preparationRemoteDataSource; - final PreparationLocalDataSource preparationLocalDataSource; - - late final _preparationStreamController = - BehaviorSubject>.seeded( - const {}, - ); - PreparationRepositoryImpl({ - required this.preparationRemoteDataSource, - required this.preparationLocalDataSource, - }); + required PreparationLocalDataSource preparationLocalDataSource, + required UserRepository userRepository, + required AppDatabase database, + }) : _localDataSource = preparationLocalDataSource, + _userRepository = userRepository, + _userDao = database.userDao; + + final PreparationLocalDataSource _localDataSource; + final UserRepository _userRepository; + final UserDao _userDao; + final _preparationStreamController = + BehaviorSubject>.seeded(const {}); @override Stream> get preparationStream => - _preparationStreamController.asBroadcastStream(); + _preparationStreamController.stream; @override Future createDefaultPreparation({ @@ -35,17 +35,21 @@ class PreparationRepositoryImpl implements PreparationRepository { required Duration spareTime, required String note, }) async { - try { - await preparationRemoteDataSource.createDefaultPreparation( - CreateDefaultPreparationRequestModel.fromEntity( - preparationEntity: preparationEntity, - spareTime: spareTime, - note: note, - ), - ); - } catch (e) { - rethrow; - } + await _localDataSource.createDefaultPreparation( + preparationEntity, + userId: localProfileId, + ); + final profile = (await _userRepository.getUser()).valueOrNull!; + await _userRepository.saveUser( + UserEntity( + id: profile.id, + spareTime: spareTime, + note: note, + isOnboardingCompleted: true, + eligibleOutcomeCount: profile.eligibleOutcomeCount, + onTimeOutcomeCount: profile.onTimeOutcomeCount, + ), + ); } @override @@ -53,62 +57,36 @@ class PreparationRepositoryImpl implements PreparationRepository { PreparationEntity preparationEntity, String scheduleId, ) async { - try { - await preparationRemoteDataSource.createCustomPreparation( - preparationEntity, - scheduleId, - ); - _preparationStreamController.add( - Map.from(_preparationStreamController.value) - ..[scheduleId] = preparationEntity, - ); - } catch (e) { - rethrow; - } + await _localDataSource.createCustomPreparation( + preparationEntity, + scheduleId, + ); + _emitSchedulePreparation(scheduleId, preparationEntity); + await _userDao.markDurableDataChanged(localProfileId); } @override Future getPreparationByScheduleId(String scheduleId) async { - try { - final remotePreparation = await preparationRemoteDataSource - .getPreparationByScheduleId(scheduleId); - _preparationStreamController.add( - Map.from(_preparationStreamController.value) - ..[scheduleId] = remotePreparation, - ); - } catch (e) { - rethrow; - } + final preparation = await _localDataSource.getPreparationByScheduleId( + scheduleId, + ); + _emitSchedulePreparation(scheduleId, preparation); } @override - Future getDefualtPreparation() async { - try { - final remotePreparation = await preparationRemoteDataSource - .getDefualtPreparation(); - return remotePreparation; - } catch (e) { - rethrow; - } + Future getDefualtPreparation() { + return _localDataSource.getDefaultPreparation(localProfileId); } @override Future updateDefaultPreparation( PreparationEntity preparationEntity, ) async { - try { - await preparationRemoteDataSource.updateDefaultPreparation( - preparationEntity, - ); - final persistedPreparation = await preparationRemoteDataSource - .getDefualtPreparation(); - if (!_samePreparation(preparationEntity, persistedPreparation)) { - throw StateError('Default preparation update was not persisted.'); - } - // await preparationLocalDataSource.updatePreparation(preparationEntity); - } catch (e) { - rethrow; - } + await _localDataSource.replaceDefaultPreparation( + preparationEntity, + userId: localProfileId, + ); + await _userDao.markDurableDataChanged(localProfileId); } @override @@ -116,45 +94,38 @@ class PreparationRepositoryImpl implements PreparationRepository { PreparationEntity preparationEntity, String scheduleId, ) async { - try { - await preparationRemoteDataSource.updatePreparationByScheduleId( - preparationEntity, - scheduleId, - ); - _preparationStreamController.add( - Map.from(_preparationStreamController.value) - ..[scheduleId] = preparationEntity, - ); - } catch (e) { - rethrow; - } + await _localDataSource.replaceSchedulePreparation( + preparationEntity, + scheduleId: scheduleId, + ); + _emitSchedulePreparation(scheduleId, preparationEntity); + await _userDao.markDurableDataChanged(localProfileId); } @override Future updateSpareTime(Duration newSpareTime) async { - try { - await preparationRemoteDataSource.updateSpareTime(newSpareTime); - } catch (e) { - rethrow; - } + final profile = (await _userRepository.getUser()).valueOrNull!; + await _userRepository.saveUser( + UserEntity( + id: profile.id, + spareTime: newSpareTime, + note: profile.note, + isOnboardingCompleted: profile.isOnboardingCompleted, + eligibleOutcomeCount: profile.eligibleOutcomeCount, + onTimeOutcomeCount: profile.onTimeOutcomeCount, + ), + ); } - bool _samePreparation(PreparationEntity expected, PreparationEntity actual) { - final expectedSteps = expected.ordered.preparationStepList; - final actualSteps = actual.ordered.preparationStepList; - if (expectedSteps.length != actualSteps.length) { - return false; - } - for (var index = 0; index < expectedSteps.length; index++) { - final expectedStep = expectedSteps[index]; - final actualStep = actualSteps[index]; - if (expectedStep.id != actualStep.id || - expectedStep.preparationName.trim() != - actualStep.preparationName.trim() || - expectedStep.preparationTime != actualStep.preparationTime) { - return false; - } - } - return true; + void _emitSchedulePreparation( + String scheduleId, + PreparationEntity preparation, + ) { + _preparationStreamController.add({ + ..._preparationStreamController.value, + scheduleId: preparation, + }); } + + Future dispose() => _preparationStreamController.close(); } diff --git a/lib/data/repositories/preparation_template_repository_impl.dart b/lib/data/repositories/preparation_template_repository_impl.dart index 329cb6c3..97fd3b9c 100644 --- a/lib/data/repositories/preparation_template_repository_impl.dart +++ b/lib/data/repositories/preparation_template_repository_impl.dart @@ -1,5 +1,8 @@ import 'package:injectable/injectable.dart'; -import 'package:on_time_front/data/data_sources/preparation_template_remote_data_source.dart'; +import 'package:on_time_front/core/constants/local_profile.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/daos/preparation_template_dao.dart'; +import 'package:on_time_front/data/daos/user_dao.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/entities/preparation_template_entity.dart'; import 'package:on_time_front/domain/repositories/preparation_template_repository.dart'; @@ -7,31 +10,34 @@ import 'package:on_time_front/domain/repositories/preparation_template_repositor @Singleton(as: PreparationTemplateRepository) class PreparationTemplateRepositoryImpl implements PreparationTemplateRepository { - final PreparationTemplateRemoteDataSource remoteDataSource; + PreparationTemplateRepositoryImpl(AppDatabase database) + : _dao = database.preparationTemplateDao, + _userDao = database.userDao; - PreparationTemplateRepositoryImpl({required this.remoteDataSource}); + final PreparationTemplateDao _dao; + final UserDao _userDao; @override - Future> getPreparationTemplates() { - return remoteDataSource.getPreparationTemplates(); - } + Future> getPreparationTemplates() => + _dao.getAll(); @override - Future getPreparationTemplate(String templateId) { - return remoteDataSource.getPreparationTemplate(templateId); - } + Future getPreparationTemplate(String templateId) => + _dao.getById(templateId); @override Future createPreparationTemplate({ required String templateId, required String templateName, required PreparationEntity preparation, - }) { - return remoteDataSource.createPreparationTemplate( - templateId: templateId, - templateName: templateName, + }) async { + await _dao.put( + id: templateId, + name: templateName, preparation: preparation, + now: DateTime.now(), ); + await _userDao.markDurableDataChanged(localProfileId); } @override @@ -39,16 +45,15 @@ class PreparationTemplateRepositoryImpl required String templateId, required String templateName, required PreparationEntity preparation, - }) { - return remoteDataSource.updatePreparationTemplate( - templateId: templateId, - templateName: templateName, - preparation: preparation, - ); - } + }) => createPreparationTemplate( + templateId: templateId, + templateName: templateName, + preparation: preparation, + ); @override - Future deletePreparationTemplate(String templateId) { - return remoteDataSource.deletePreparationTemplate(templateId); + Future deletePreparationTemplate(String templateId) async { + await _dao.deleteById(templateId); + await _userDao.markDurableDataChanged(localProfileId); } } diff --git a/lib/data/repositories/schedule_repository_impl.dart b/lib/data/repositories/schedule_repository_impl.dart index 8bc66d25..fea75325 100644 --- a/lib/data/repositories/schedule_repository_impl.dart +++ b/lib/data/repositories/schedule_repository_impl.dart @@ -1,30 +1,44 @@ import 'dart:async'; import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; import 'package:injectable/injectable.dart'; -import 'package:on_time_front/data/data_sources/schedule_remote_data_source.dart'; -import 'package:on_time_front/data/models/create_schedule_request_model.dart'; -import 'package:on_time_front/data/models/update_schedule_request_model.dart'; +import 'package:on_time_front/core/constants/local_profile.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/daos/schedule_dao.dart'; +import 'package:on_time_front/data/daos/user_dao.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart'; +import 'package:on_time_front/data/tables/schedule_with_place_model.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; import 'package:on_time_front/domain/repositories/schedule_repository.dart'; import 'package:on_time_front/domain/repositories/timed_preparation_repository.dart'; import 'package:rxdart/subjects.dart'; @Singleton(as: ScheduleRepository) class ScheduleRepositoryImpl implements ScheduleRepository { - final ScheduleRemoteDataSource scheduleRemoteDataSource; - final TimedPreparationRepository timedPreparationRepository; - - late final _scheduleStreamController = - BehaviorSubject>.seeded(const {}); - final _rangeStreamControllers = - <_ScheduleDateRange, BehaviorSubject>>{}; - final _scheduleListEquality = const ListEquality(); - ScheduleRepositoryImpl({ - required this.scheduleRemoteDataSource, - required this.timedPreparationRepository, - }); + required AppDatabase database, + required TimedPreparationRepository timedPreparationRepository, + }) : _database = database, + _scheduleDao = database.scheduleDao, + _userDao = database.userDao, + _timedPreparationRepository = timedPreparationRepository { + _subscription = _scheduleDao.watchScheduleList().listen( + (rows) => _scheduleStreamController.add( + rows.map((row) => row.toScheduleEntity()).toSet(), + ), + ); + } + + final AppDatabase _database; + final ScheduleDao _scheduleDao; + final UserDao _userDao; + final TimedPreparationRepository _timedPreparationRepository; + final _scheduleStreamController = BehaviorSubject>.seeded( + const {}, + ); + late final StreamSubscription> _subscription; @override Stream> get scheduleStream => @@ -35,67 +49,50 @@ class ScheduleRepositoryImpl implements ScheduleRepository { DateTime startDate, DateTime endDate, ) { - final range = _ScheduleDateRange(startDate: startDate, endDate: endDate); - return _rangeStreamControllers - .putIfAbsent( - range, - () => BehaviorSubject>.seeded( - _schedulesInRange(range), - ), - ) - .stream; + return scheduleStream + .map((schedules) { + final result = schedules + .where( + (schedule) => + !schedule.scheduleTime.isBefore(startDate) && + schedule.scheduleTime.isBefore(endDate), + ) + .toList(); + result.sort((a, b) => a.scheduleTime.compareTo(b.scheduleTime)); + return result; + }) + .distinct(const DeepCollectionEquality().equals); } @override Future createSchedule(ScheduleEntity schedule) async { - try { - await scheduleRemoteDataSource.createSchedule( - CreateScheduleRequestModel.fromEntity(schedule), - ); - _emitUpsertedSchedule(schedule); - } catch (e) { - rethrow; - } + await _scheduleDao.createSchedule(schedule.toScheduleWithPlaceRow()); + await _userDao.markDurableDataChanged(localProfileId); } @override Future deleteSchedule(ScheduleEntity schedule) async { - try { - await scheduleRemoteDataSource.deleteSchedule(schedule.id); - await _clearTimedPreparationSafe(schedule.id); - _emitScheduleSet( - Set.from(_scheduleStreamController.value) - ..removeWhere((existing) => existing.id == schedule.id), - affectedRanges: _rangesContaining(schedule.scheduleTime), - ); - } catch (e) { - rethrow; - } + await _scheduleDao.deleteSchedule(schedule.toScheduleRow()); + await _clearTimedPreparation(schedule.id); + await _userDao.markDurableDataChanged(localProfileId); } @override Future startSchedule(String scheduleId) async { - try { - await scheduleRemoteDataSource.startSchedule(scheduleId); - } catch (e) { - rethrow; - } + final existing = await _scheduleDao.getScheduleById(scheduleId); + await _scheduleDao.updateSchedule( + existing.schedule.copyWith( + isStarted: true, + startedAt: Value(DateTime.now()), + preparationFrozen: true, + ), + ); + await _userDao.markDurableDataChanged(localProfileId); } @override Future getScheduleById(String id) async { - try { - final schedule = (await scheduleRemoteDataSource.getScheduleById( - id, - )).toEntity(); - if (_isEnded(schedule.doneStatus)) { - await _clearTimedPreparationSafe(schedule.id); - } - _emitUpsertedSchedule(schedule); - return schedule; - } catch (e) { - rethrow; - } + return (await _scheduleDao.getScheduleById(id)).toScheduleEntity(); } @override @@ -103,25 +100,8 @@ class ScheduleRepositoryImpl implements ScheduleRepository { DateTime startDate, DateTime? endDate, ) async { - try { - final schedules = (await scheduleRemoteDataSource.getSchedulesByDate( - startDate, - endDate, - )).map((schedule) => schedule.toEntity()).toList(); - for (final schedule in schedules) { - if (_isEnded(schedule.doneStatus)) { - await _clearTimedPreparationSafe(schedule.id); - } - } - _replaceSchedulesInRange( - startDate: startDate, - endDate: endDate, - schedules: schedules, - ); - return schedules; - } catch (e) { - rethrow; - } + final rows = await _scheduleDao.getSchedulesByDate(startDate, endDate); + return rows.map((row) => row.toScheduleEntity()).toList(); } @override @@ -129,173 +109,64 @@ class ScheduleRepositoryImpl implements ScheduleRepository { ScheduleEntity schedule, { bool includePreparationSource = false, }) async { - try { - await scheduleRemoteDataSource.updateSchedule( - UpdateScheduleRequestModel.fromEntity( - schedule, - includePreparationSource: includePreparationSource, - ), - ); - await _clearTimedPreparationSafe(schedule.id); - final refreshedSchedule = (await scheduleRemoteDataSource.getScheduleById( - schedule.id, - )).toEntity(); - if (_isEnded(refreshedSchedule.doneStatus)) { - await _clearTimedPreparationSafe(refreshedSchedule.id); - } - _emitUpsertedSchedule(refreshedSchedule); - } catch (e) { - rethrow; - } + await _scheduleDao.updateScheduleWithPlace( + schedule.toScheduleWithPlaceRow(), + ); + await _clearTimedPreparation(schedule.id); + await _userDao.markDurableDataChanged(localProfileId); } @override Future finishSchedule(String scheduleId, int latenessTime) async { - try { - await scheduleRemoteDataSource.finishSchedule(scheduleId, latenessTime); - await _clearTimedPreparationSafe(scheduleId); - final lateStatus = latenessTime > 0 + await _database.transaction(() async { + final existing = await _scheduleDao.getScheduleById(scheduleId); + if (existing.schedule.doneStatus != ScheduleDoneStatus.notEnded.name) { + return; + } + + final doneStatus = latenessTime > 0 ? ScheduleDoneStatus.lateEnd : ScheduleDoneStatus.normalEnd; - final schedule = _scheduleStreamController.value.firstWhere( - (schedule) => schedule.id == scheduleId, + await _scheduleDao.updateSchedule( + existing.schedule.copyWith( + isStarted: false, + latenessTime: latenessTime, + doneStatus: doneStatus.name, + finishedAt: Value(DateTime.now()), + scoreContributionRecorded: true, + ), ); - _emitUpsertedSchedule(schedule.copyWith(doneStatus: lateStatus)); - } catch (e) { - rethrow; - } - } - bool _isEnded(ScheduleDoneStatus doneStatus) { - return doneStatus == ScheduleDoneStatus.normalEnd || - doneStatus == ScheduleDoneStatus.lateEnd || - doneStatus == ScheduleDoneStatus.abnormalEnd; + final user = await _userDao.getUserById(localProfileId); + if (!existing.schedule.scoreContributionRecorded && user != null) { + final value = user.valueOrNull!; + await _userDao.putUser( + UserEntity( + id: value.id, + spareTime: value.spareTime, + note: value.note, + isOnboardingCompleted: value.isOnboardingCompleted, + eligibleOutcomeCount: value.eligibleOutcomeCount + 1, + onTimeOutcomeCount: + value.onTimeOutcomeCount + (latenessTime > 0 ? 0 : 1), + ), + ); + } + await _userDao.markDurableDataChanged(localProfileId); + }); + await _clearTimedPreparation(scheduleId); } - Future _clearTimedPreparationSafe(String scheduleId) async { + Future _clearTimedPreparation(String scheduleId) async { try { - await timedPreparationRepository.clearTimedPreparation(scheduleId); + await _timedPreparationRepository.clearTimedPreparation(scheduleId); } catch (_) { - // Best-effort cleanup: cache invalidation must not fail schedule operations. + // Active timer state is reconstructible and must not fail durable writes. } } - void _emitUpsertedSchedule(ScheduleEntity schedule) { - final existingSchedules = _scheduleStreamController.value.where( - (existing) => existing.id == schedule.id, - ); - final previousSchedule = existingSchedules.isEmpty - ? null - : existingSchedules.first; - final nextSchedules = - Set.from(_scheduleStreamController.value) - ..removeWhere((existing) => existing.id == schedule.id) - ..add(schedule); - _emitScheduleSet( - nextSchedules, - affectedRanges: _rangesContainingAny([ - schedule.scheduleTime, - if (previousSchedule != null) previousSchedule.scheduleTime, - ]), - ); - } - - void _replaceSchedulesInRange({ - required DateTime startDate, - required DateTime? endDate, - required Iterable schedules, - }) { - final nextSchedules = - Set.from(_scheduleStreamController.value)..removeWhere( - (existing) => - !existing.scheduleTime.isBefore(startDate) && - (endDate == null || existing.scheduleTime.isBefore(endDate)), - ); - for (final schedule in schedules) { - nextSchedules.add(schedule); - } - final loadedRange = endDate == null - ? null - : _ScheduleDateRange(startDate: startDate, endDate: endDate); - _emitScheduleSet( - nextSchedules, - affectedRanges: loadedRange == null - ? _rangeStreamControllers.keys - : _rangesOverlapping(loadedRange), - ); + Future dispose() async { + await _subscription.cancel(); + await _scheduleStreamController.close(); } - - void _emitScheduleSet( - Set nextSchedules, { - required Iterable<_ScheduleDateRange> affectedRanges, - }) { - _scheduleStreamController.add(nextSchedules); - _publishRangeUpdates(affectedRanges); - } - - Iterable<_ScheduleDateRange> _rangesContaining(DateTime scheduleTime) { - return _rangeStreamControllers.keys.where( - (range) => range.contains(scheduleTime), - ); - } - - Iterable<_ScheduleDateRange> _rangesContainingAny( - Iterable scheduleTimes, - ) { - return _rangeStreamControllers.keys.where( - (range) => scheduleTimes.any(range.contains), - ); - } - - Iterable<_ScheduleDateRange> _rangesOverlapping(_ScheduleDateRange range) { - return _rangeStreamControllers.keys.where(range.overlaps); - } - - void _publishRangeUpdates(Iterable<_ScheduleDateRange> affectedRanges) { - for (final range in affectedRanges) { - final controller = _rangeStreamControllers[range]; - if (controller == null || controller.isClosed) { - continue; - } - final nextSchedules = _schedulesInRange(range); - if (!_scheduleListEquality.equals(controller.value, nextSchedules)) { - controller.add(nextSchedules); - } - } - } - - List _schedulesInRange(_ScheduleDateRange range) { - final schedules = _scheduleStreamController.value - .where((schedule) => range.contains(schedule.scheduleTime)) - .toList(); - schedules.sort((a, b) => a.scheduleTime.compareTo(b.scheduleTime)); - return schedules; - } -} - -class _ScheduleDateRange { - const _ScheduleDateRange({required this.startDate, required this.endDate}); - - final DateTime startDate; - final DateTime endDate; - - bool contains(DateTime dateTime) { - return dateTime.compareTo(startDate) >= 0 && dateTime.isBefore(endDate); - } - - bool overlaps(_ScheduleDateRange other) { - return startDate.isBefore(other.endDate) && - other.startDate.isBefore(endDate); - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - other is _ScheduleDateRange && - startDate == other.startDate && - endDate == other.endDate; - } - - @override - int get hashCode => Object.hash(startDate, endDate); } diff --git a/lib/data/repositories/timed_preparation_repository_impl.dart b/lib/data/repositories/timed_preparation_repository_impl.dart index 63b03017..74548f5f 100644 --- a/lib/data/repositories/timed_preparation_repository_impl.dart +++ b/lib/data/repositories/timed_preparation_repository_impl.dart @@ -16,13 +16,16 @@ class TimedPreparationRepositoryImpl implements TimedPreparationRepository { @override Future getTimedPreparationSnapshot( - String scheduleId) { + String scheduleId, + ) { return localDataSource.loadPreparation(scheduleId); } @override Future saveTimedPreparationSnapshot( - String scheduleId, TimedPreparationSnapshotEntity snapshot) { + String scheduleId, + TimedPreparationSnapshotEntity snapshot, + ) { return localDataSource.savePreparation(scheduleId, snapshot); } } diff --git a/lib/data/repositories/user_repository_impl.dart b/lib/data/repositories/user_repository_impl.dart index 3c26f224..18e67fb2 100644 --- a/lib/data/repositories/user_repository_impl.dart +++ b/lib/data/repositories/user_repository_impl.dart @@ -1,206 +1,81 @@ import 'dart:async'; -import 'package:dio/dio.dart'; import 'package:injectable/injectable.dart'; -import 'package:on_time_front/core/logging/app_logger.dart'; -import 'package:on_time_front/core/services/google_authentication_service.dart'; -import 'package:on_time_front/core/validation/backend_constraints.dart'; -import 'package:on_time_front/data/data_sources/authentication_remote_data_source.dart'; -import 'package:on_time_front/data/data_sources/token_local_data_source.dart'; -import 'package:on_time_front/data/models/sign_in_with_google_request_model.dart'; -import 'package:on_time_front/data/models/sign_in_with_apple_request_model.dart'; -import 'package:on_time_front/domain/entities/google_auth_credential.dart'; +import 'package:on_time_front/core/constants/local_profile.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/daos/user_dao.dart'; import 'package:on_time_front/domain/entities/user_entity.dart'; import 'package:on_time_front/domain/repositories/user_repository.dart'; import 'package:rxdart/subjects.dart'; @Singleton(as: UserRepository) class UserRepositoryImpl implements UserRepository { - final AuthenticationRemoteDataSource _authenticationRemoteDataSource; - final TokenLocalDataSource _tokenLocalDataSource; - final GoogleAuthenticationService _googleAuthenticationService; - late final _userStreamController = BehaviorSubject.seeded( - const UserEntity.empty(), - ); - - UserRepositoryImpl( - this._authenticationRemoteDataSource, - this._tokenLocalDataSource, - this._googleAuthenticationService, - ); - - @override - Future getUser() async { - try { - final user = await _authenticationRemoteDataSource.getUser(); - _userStreamController.add(user); - return user; - } on DioException catch (e) { - if (e.response?.statusCode == 401) { - await _tokenLocalDataSource.deleteToken(); - _userStreamController.add(const UserEntity.empty()); - return const UserEntity.empty(); - } - rethrow; - } catch (e) { - rethrow; - } - } - - @override - Future signIn({required String email, required String password}) async { - try { - final result = await _authenticationRemoteDataSource.signIn( - email, - password, - ); - await _tokenLocalDataSource.storeTokens(result.$2); - _userStreamController.add(result.$1); - } catch (e) { - rethrow; - } + UserRepositoryImpl(this._database) : _userDao = _database.userDao { + _subscription = _userDao.watchUserById(localProfileId).listen((user) { + if (user != null) _userStreamController.add(user); + }); } - @override - Future signUp({ - required String email, - required String password, - required String name, - }) async { - final passwordError = PasswordPolicy.validate(password); - if (passwordError != null) { - throw ArgumentError.value(password, 'password', passwordError.name); - } - try { - final result = await _authenticationRemoteDataSource.signUp( - email, - password, - name, - ); - await _tokenLocalDataSource.storeTokens(result.$2); - _userStreamController.add(result.$1); - } catch (e) { - rethrow; - } - } + final AppDatabase _database; + final UserDao _userDao; + final _userStreamController = BehaviorSubject.seeded( + const UserEntity.empty(), + ); + late final StreamSubscription _subscription; @override - Future signOut() async { - await _tokenLocalDataSource.deleteToken(); - _userStreamController.add(const UserEntity.empty()); - } + Stream get userStream => _userStreamController.stream; @override - Future signInWithGoogle(GoogleAuthCredential credential) async { - try { - if (credential.idToken.isEmpty) { - throw Exception('Google ID Token is null'); - } - final signInWithGoogleRequestModel = SignInWithGoogleRequestModel( - idToken: credential.idToken, - refreshToken: credential.refreshToken, - ); - await _tokenLocalDataSource.deleteToken(); - final result = await _authenticationRemoteDataSource.signInWithGoogle( - signInWithGoogleRequestModel, - ); - await _tokenLocalDataSource.storeTokens(result.$2); - _userStreamController.add(result.$1); - } catch (error) { - AppLogger.debug('Google Sign-In failed errorType=${error.runtimeType}'); - rethrow; - } - } - - @override - Future signInWithApple({ - required String idToken, - required String authCode, - required String fullName, - String? email, - }) async { - try { - final signInWithAppleRequestModel = SignInWithAppleRequestModel( - idToken: idToken, - authCode: authCode, - fullName: fullName, - email: email, - ); - await _tokenLocalDataSource.deleteToken(); - final result = await _authenticationRemoteDataSource.signInWithApple( - signInWithAppleRequestModel, - ); - await _tokenLocalDataSource.storeTokens(result.$2); - _userStreamController.add(result.$1); - } catch (error) { - AppLogger.debug('Apple Sign-In failed errorType=${error.runtimeType}'); - rethrow; + Future getUser() async { + final existing = await _userDao.getUserById(localProfileId); + if (existing != null) { + _userStreamController.add(existing); + return existing; } - } - @override - Future deleteUser({String? feedbackMessage}) async { - try { - await _authenticationRemoteDataSource.deleteUser( - feedbackMessage: feedbackMessage, - ); - } catch (e) { - rethrow; - } + const profile = UserEntity( + id: localProfileId, + spareTime: Duration.zero, + note: '', + ); + await _userDao.putUser(profile); + _userStreamController.add(profile); + return profile; } @override - Future deleteGoogleUser({String? feedbackMessage}) async { - try { - await _authenticationRemoteDataSource.deleteGoogleMe( - feedbackMessage: feedbackMessage, + Future saveUser(UserEntity user) async { + final value = user.valueOrNull; + if (value == null) { + throw ArgumentError.value( + user, + 'user', + 'An empty profile cannot be saved.', ); - } catch (e) { - rethrow; } - } - - @override - Future deleteAppleUser({String? feedbackMessage}) async { - try { - await _authenticationRemoteDataSource.deleteAppleMe( - feedbackMessage: feedbackMessage, + if (value.id != localProfileId) { + throw ArgumentError.value( + value.id, + 'user.id', + 'Only one local profile exists.', ); - } catch (e) { - rethrow; - } - } - - @override - Future postFeedback(String message) async { - try { - await _authenticationRemoteDataSource.postFeedback(message); - } catch (e) { - rethrow; } + await _userDao.putUser(user); + await _userDao.markDurableDataChanged(localProfileId); + final saved = await _userDao.getUserById(localProfileId); + if (saved != null) _userStreamController.add(saved); } @override - Future getUserSocialType() async { - try { - return await _authenticationRemoteDataSource.getUserSocialType(); - } catch (e) { - return null; - } + Future resetLocalData() async { + await _database.deleteAllDurableData(); + _userStreamController.add(const UserEntity.empty()); + await getUser(); } - @override - Future disconnectGoogleSignIn() async { - try { - await _googleAuthenticationService.disconnect(); - } catch (error) { - AppLogger.debug( - 'Google Sign-In disconnect failed errorType=${error.runtimeType}', - ); - } + Future dispose() async { + await _subscription.cancel(); + await _userStreamController.close(); } - - @override - Stream get userStream => - _userStreamController.asBroadcastStream(); } diff --git a/lib/data/services/device_fcm_token_registrar.dart b/lib/data/services/device_fcm_token_registrar.dart deleted file mode 100644 index 8729d777..00000000 --- a/lib/data/services/device_fcm_token_registrar.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/core/services/notification_token_registrar.dart'; -import 'package:on_time_front/data/data_sources/notification_remote_data_source.dart'; -import 'package:on_time_front/data/models/fcm_token_register_request_model.dart'; -import 'package:on_time_front/domain/repositories/alarm_repository.dart'; - -@Singleton(as: FcmTokenRegistrar) -class DeviceFcmTokenRegistrar implements FcmTokenRegistrar { - DeviceFcmTokenRegistrar( - this._alarmRepository, - this._notificationRemoteDataSource, - ); - - final AlarmRepository _alarmRepository; - final NotificationRemoteDataSource _notificationRemoteDataSource; - - @override - Future registerToken(String firebaseToken) async { - final deviceId = await _alarmRepository.getDeviceId(); - await _notificationRemoteDataSource.fcmTokenRegister( - FcmTokenRegisterRequestModel( - firebaseToken: firebaseToken, - deviceId: deviceId, - ), - ); - } -} diff --git a/lib/data/services/token_local_session_invalidator.dart b/lib/data/services/token_local_session_invalidator.dart deleted file mode 100644 index f1ab9090..00000000 --- a/lib/data/services/token_local_session_invalidator.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/core/dio/interceptors/token_session_invalidator.dart'; -import 'package:on_time_front/data/data_sources/token_local_data_source.dart'; - -@Singleton(as: TokenSessionInvalidator) -class TokenLocalSessionInvalidator implements TokenSessionInvalidator { - TokenLocalSessionInvalidator(this._tokenLocalDataSource); - - final TokenLocalDataSource _tokenLocalDataSource; - - @override - Future signOutLocally() { - return _tokenLocalDataSource.deleteToken(); - } -} diff --git a/lib/data/tables/preparation_template_step_table.dart b/lib/data/tables/preparation_template_step_table.dart new file mode 100644 index 00000000..9dd1debb --- /dev/null +++ b/lib/data/tables/preparation_template_step_table.dart @@ -0,0 +1,18 @@ +import 'package:drift/drift.dart'; +import 'package:on_time_front/data/tables/preparation_template_table.dart'; +import 'package:uuid/uuid.dart'; + +@TableIndex( + name: 'preparation_template_steps_template_id_idx', + columns: {#templateId}, +) +class PreparationTemplateSteps extends Table { + TextColumn get id => text().clientDefault(() => const Uuid().v7())(); + TextColumn get templateId => text().references(PreparationTemplates, #id)(); + TextColumn get preparationName => text().withLength(min: 1, max: 30)(); + IntColumn get preparationTime => integer()(); + IntColumn get position => integer()(); + + @override + Set get primaryKey => {id}; +} diff --git a/lib/data/tables/preparation_template_table.dart b/lib/data/tables/preparation_template_table.dart new file mode 100644 index 00000000..29b01ced --- /dev/null +++ b/lib/data/tables/preparation_template_table.dart @@ -0,0 +1,12 @@ +import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; + +class PreparationTemplates extends Table { + TextColumn get id => text().clientDefault(() => const Uuid().v7())(); + TextColumn get templateName => text().withLength(min: 1, max: 30)(); + DateTimeColumn get createdAt => dateTime().clientDefault(DateTime.now)(); + DateTimeColumn get updatedAt => dateTime().clientDefault(DateTime.now)(); + + @override + Set get primaryKey => {id}; +} diff --git a/lib/data/tables/schedules_table.dart b/lib/data/tables/schedules_table.dart index 17cfcbcc..95c91d29 100644 --- a/lib/data/tables/schedules_table.dart +++ b/lib/data/tables/schedules_table.dart @@ -9,7 +9,10 @@ class Schedules extends Table { TextColumn get id => text().clientDefault(() => Uuid().v7())(); TextColumn get placeId => text().references(Places, #id)(); TextColumn get scheduleName => text()(); - DateTimeColumn get scheduleTime => dateTime()(); + TextColumn get timeZoneId => text().withDefault(const Constant('UTC'))(); + IntColumn get occurrenceOffsetSeconds => integer().nullable()(); + TextColumn get scheduleTime => + text().map(const CivilDateTimeSqlConverter())(); IntColumn get moveTime => integer().map(DurationSqlConverter())(); BoolColumn get isChanged => boolean().withDefault(const Constant(false))(); BoolColumn get isStarted => boolean().withDefault(const Constant(false))(); @@ -17,6 +20,18 @@ class Schedules extends Table { integer().nullable().map(DurationSqlConverter())(); TextColumn get scheduleNote => text().nullable()(); IntColumn get latenessTime => integer().withDefault(const Constant(-1))(); + TextColumn get doneStatus => text().withDefault(const Constant('notEnded'))(); + DateTimeColumn get startedAt => dateTime().nullable()(); + DateTimeColumn get finishedAt => dateTime().nullable()(); + TextColumn get preparationMode => text().nullable()(); + TextColumn get preparationTemplateId => text().nullable()(); + TextColumn get preparationTemplateName => text().nullable()(); + BoolColumn get preparationTemplateDeleted => + boolean().withDefault(const Constant(false))(); + BoolColumn get preparationFrozen => + boolean().withDefault(const Constant(false))(); + BoolColumn get scoreContributionRecorded => + boolean().withDefault(const Constant(false))(); @override Set get primaryKey => {id}; diff --git a/lib/data/tables/user_table.dart b/lib/data/tables/user_table.dart index c44594f7..59916114 100644 --- a/lib/data/tables/user_table.dart +++ b/lib/data/tables/user_table.dart @@ -3,11 +3,24 @@ import 'package:uuid/uuid.dart'; class Users extends Table { TextColumn get id => text().clientDefault(() => Uuid().v7())(); - TextColumn get email => text().withLength(min: 1, max: 320)(); - TextColumn get name => text().withLength(min: 1, max: 30)(); IntColumn get spareTime => integer()(); TextColumn get note => text()(); - RealColumn get score => real()(); + BoolColumn get isOnboardingCompleted => + boolean().withDefault(const Constant(false))(); + IntColumn get eligibleOutcomeCount => + integer().withDefault(const Constant(0))(); + IntColumn get onTimeOutcomeCount => + integer().withDefault(const Constant(0))(); + BoolColumn get alarmsEnabled => boolean().withDefault(const Constant(true))(); + IntColumn get alarmOffsetMinutes => + integer().withDefault(const Constant(0))(); + BoolColumn get detailedNotificationContent => + boolean().withDefault(const Constant(false))(); + IntColumn get dataRevision => integer().withDefault(const Constant(0))(); + IntColumn get lastExportedRevision => integer().nullable()(); + DateTimeColumn get lastExportedAt => dateTime().nullable()(); + DateTimeColumn get firstDurableDataAt => dateTime().nullable()(); + DateTimeColumn get lastDurableDataAt => dateTime().nullable()(); @override Set get primaryKey => {id}; diff --git a/lib/domain/entities/adjacent_schedules_with_preparation_entity.dart b/lib/domain/entities/adjacent_schedules_with_preparation_entity.dart index 8803d760..0de261bf 100644 --- a/lib/domain/entities/adjacent_schedules_with_preparation_entity.dart +++ b/lib/domain/entities/adjacent_schedules_with_preparation_entity.dart @@ -14,4 +14,3 @@ class AdjacentSchedulesWithPreparationEntity { bool get hasNext => nextSchedule != null; bool get isEmpty => !hasPrevious && !hasNext; } - diff --git a/lib/domain/entities/alarm_entities.dart b/lib/domain/entities/alarm_entities.dart index 7d225622..3f0cd02a 100644 --- a/lib/domain/entities/alarm_entities.dart +++ b/lib/domain/entities/alarm_entities.dart @@ -204,22 +204,17 @@ class AlarmSchedulingException implements Exception { } } -class DeviceSessionNotActiveException implements Exception { - const DeviceSessionNotActiveException(); - - @override - String toString() => 'DeviceSessionNotActiveException'; -} - class AlarmSettings extends Equatable { final bool alarmsEnabled; final int defaultAlarmOffsetMinutes; final DateTime? updatedAt; + final bool detailedNotificationContent; const AlarmSettings({ required this.alarmsEnabled, this.defaultAlarmOffsetMinutes = 5, this.updatedAt, + this.detailedNotificationContent = false, }); Duration get alarmOffset => Duration(minutes: defaultAlarmOffsetMinutes); @@ -229,37 +224,7 @@ class AlarmSettings extends Equatable { alarmsEnabled, defaultAlarmOffsetMinutes, updatedAt, - ]; -} - -class AlarmDeviceInfo extends Equatable { - final String deviceId; - final String platform; - final String appVersion; - final String osVersion; - final bool supportsNativeAlarm; - final AlarmProvider nativeAlarmProvider; - final AlarmProvider fallbackProvider; - - const AlarmDeviceInfo({ - required this.deviceId, - required this.platform, - required this.appVersion, - required this.osVersion, - required this.supportsNativeAlarm, - required this.nativeAlarmProvider, - required this.fallbackProvider, - }); - - @override - List get props => [ - deviceId, - platform, - appVersion, - osVersion, - supportsNativeAlarm, - nativeAlarmProvider, - fallbackProvider, + detailedNotificationContent, ]; } @@ -405,58 +370,6 @@ class AlarmReconciliationResult extends Equatable { ]; } -class AlarmStatusReport extends Equatable { - final String deviceId; - final DateTime reconciledAt; - final DateTime scheduleWindowStart; - final DateTime scheduleWindowEnd; - final DateTime alarmCoverageStart; - final DateTime alarmCoverageEnd; - final AlarmReconciliationStatus status; - final AlarmPermissionIssue? permissionIssue; - final AlarmProvider nativeAlarmProvider; - final AlarmProvider fallbackProvider; - final int armedScheduleCount; - final List armedScheduleIds; - final int skippedScheduleCount; - final List failures; - - const AlarmStatusReport({ - required this.deviceId, - required this.reconciledAt, - required this.scheduleWindowStart, - required this.scheduleWindowEnd, - required this.alarmCoverageStart, - required this.alarmCoverageEnd, - required this.status, - required this.nativeAlarmProvider, - required this.fallbackProvider, - required this.armedScheduleCount, - required this.armedScheduleIds, - required this.skippedScheduleCount, - required this.failures, - this.permissionIssue, - }); - - @override - List get props => [ - deviceId, - reconciledAt, - scheduleWindowStart, - scheduleWindowEnd, - alarmCoverageStart, - alarmCoverageEnd, - status, - permissionIssue, - nativeAlarmProvider, - fallbackProvider, - armedScheduleCount, - armedScheduleIds, - skippedScheduleCount, - failures, - ]; -} - bool isAlarmEligibleSchedule(ScheduleWithPreparationEntity schedule) { return schedule.doneStatus == ScheduleDoneStatus.notEnded; } @@ -487,6 +400,8 @@ ScheduledAlarmRecord buildScheduledAlarmRecord( ScheduleWithPreparationEntity schedule, { required Duration alarmOffset, required AlarmProvider provider, + bool detailedNotificationContent = false, + String? currentTimeZoneId, }) { final alarmTime = computeAlarmTime(schedule, offset: alarmOffset); final id = stableAlarmId(schedule.id); @@ -499,7 +414,9 @@ ScheduledAlarmRecord buildScheduledAlarmRecord( nativeAlarmId: id, fallbackNotificationId: id, provider: provider, - scheduleTitle: schedule.scheduleName, + scheduleTitle: detailedNotificationContent + ? schedule.scheduleName + : 'OnTime', payload: { 'type': 'schedule_notification', 'alarmLaunchPayloadVersion': alarmLaunchPayloadVersion, @@ -507,8 +424,12 @@ ScheduledAlarmRecord buildScheduledAlarmRecord( 'alarmTime': alarmTime.toIso8601String(), 'preparationStartTime': preparationStartTime.toIso8601String(), 'scheduleFingerprint': buildAlarmScheduleFingerprint(schedule), - 'placeName': schedule.place.placeName, 'promptVariant': 'notification', + 'detailedNotificationContent': detailedNotificationContent.toString(), + if (detailedNotificationContent && + currentTimeZoneId != null && + currentTimeZoneId != schedule.timeZoneId) + 'notificationTimeZone': schedule.timeZoneId, }, ); } diff --git a/lib/domain/entities/analytics_preference.dart b/lib/domain/entities/analytics_preference.dart deleted file mode 100644 index c8abf1f1..00000000 --- a/lib/domain/entities/analytics_preference.dart +++ /dev/null @@ -1,28 +0,0 @@ -class AnalyticsPreference { - const AnalyticsPreference({ - required this.enabled, - this.updatedAt, - this.isConfirmed = true, - }); - - const AnalyticsPreference.disabledUnconfirmed() - : enabled = false, - updatedAt = null, - isConfirmed = false; - - final bool enabled; - final DateTime? updatedAt; - final bool isConfirmed; - - @override - bool operator ==(Object other) { - return identical(this, other) || - other is AnalyticsPreference && - other.enabled == enabled && - other.updatedAt == updatedAt && - other.isConfirmed == isConfirmed; - } - - @override - int get hashCode => Object.hash(enabled, updatedAt, isConfirmed); -} diff --git a/lib/domain/entities/google_auth_credential.dart b/lib/domain/entities/google_auth_credential.dart deleted file mode 100644 index e444b22a..00000000 --- a/lib/domain/entities/google_auth_credential.dart +++ /dev/null @@ -1,6 +0,0 @@ -class GoogleAuthCredential { - const GoogleAuthCredential({required this.idToken, this.refreshToken = ''}); - - final String idToken; - final String refreshToken; -} diff --git a/lib/domain/entities/preparation_entity.dart b/lib/domain/entities/preparation_entity.dart index a077a02a..16b18ea1 100644 --- a/lib/domain/entities/preparation_entity.dart +++ b/lib/domain/entities/preparation_entity.dart @@ -4,9 +4,7 @@ import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; class PreparationEntity extends Equatable { final List preparationStepList; - const PreparationEntity({ - required this.preparationStepList, - }); + const PreparationEntity({required this.preparationStepList}); Duration get totalDuration { return preparationStepList.fold( @@ -20,9 +18,7 @@ class PreparationEntity extends Equatable { return this; } - final stepById = { - for (final step in preparationStepList) step.id: step, - }; + final stepById = {for (final step in preparationStepList) step.id: step}; final referencedIds = preparationStepList .map((step) => step.nextPreparationId) .whereType() diff --git a/lib/domain/entities/preparation_step_with_time_entity.dart b/lib/domain/entities/preparation_step_with_time_entity.dart index f41ed687..9d974b68 100644 --- a/lib/domain/entities/preparation_step_with_time_entity.dart +++ b/lib/domain/entities/preparation_step_with_time_entity.dart @@ -35,10 +35,7 @@ class PreparationStepWithTimeEntity extends PreparationStepEntity { PreparationStepWithTimeEntity timeElapsed(Duration elapsed) { final updatedElapsed = elapsedTime + elapsed; final updatedIsDone = updatedElapsed >= preparationTime; - return copyWith( - elapsedTime: updatedElapsed, - isDone: updatedIsDone, - ); + return copyWith(elapsedTime: updatedElapsed, isDone: updatedIsDone); } @override @@ -48,11 +45,11 @@ class PreparationStepWithTimeEntity extends PreparationStepEntity { @override List get props => [ - id, - preparationName, - preparationTime, - nextPreparationId, - elapsedTime, - isDone - ]; + id, + preparationName, + preparationTime, + nextPreparationId, + elapsedTime, + isDone, + ]; } diff --git a/lib/domain/entities/product_usage_event.dart b/lib/domain/entities/product_usage_event.dart deleted file mode 100644 index 4a99d087..00000000 --- a/lib/domain/entities/product_usage_event.dart +++ /dev/null @@ -1,348 +0,0 @@ -import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; - -class ProductUsageEvent { - ProductUsageEvent._({ - required ProductUsageEventDefinition definition, - required ProductUsageResult eventResult, - Map parameters = const {}, - }) : name = definition.name, - workflow = definition.workflow.wireValue, - result = eventResult.wireValue, - schemaVersion = definition.schemaVersion, - parameters = ProductUsageEventCatalog.validateParameters( - definition, - parameters, - ); - - factory ProductUsageEvent.fromCatalog({ - required String name, - required ProductUsageResult result, - Map parameters = const {}, - }) { - final definition = ProductUsageEventCatalog.definitionFor(name); - return ProductUsageEvent._( - definition: definition, - eventResult: result, - parameters: parameters, - ); - } - - factory ProductUsageEvent.scheduleCreated({ - required SchedulePreparationMode? preparationMode, - required int preparationStepCount, - required int minutesUntilSchedule, - }) { - return ProductUsageEvent._( - definition: ProductUsageEventCatalog.scheduleCreated, - eventResult: ProductUsageResult.success, - parameters: { - 'preparation_mode': _preparationModeWireValue(preparationMode), - 'preparation_step_count': preparationStepCount, - 'minutes_until_schedule': minutesUntilSchedule, - }, - ); - } - - final String name; - final String workflow; - final String result; - final int schemaVersion; - final Map parameters; - - Map toAnalyticsParameters({ - required String platform, - required String appVersion, - }) { - return { - 'schema_version': schemaVersion, - 'workflow': workflow, - 'result': result, - 'platform': platform, - 'app_version': appVersion, - ...parameters, - }; - } -} - -enum ProductUsageResult { - success('success'), - failure('failure'), - allowed('allowed'), - denied('denied'), - disabled('disabled'); - - const ProductUsageResult(this.wireValue); - - final String wireValue; -} - -enum ProductUsageWorkflow { - analytics('analytics'), - onboarding('onboarding'), - authentication('authentication'), - schedule('schedule'), - notification('notification'), - alarm('alarm'); - - const ProductUsageWorkflow(this.wireValue); - - final String wireValue; -} - -enum ProductUsageEventParameterKey { - enabled('enabled'), - source('source'), - preparationStepCount('preparation_step_count'), - spareTimeMinutes('spare_time_minutes'), - authProvider('auth_provider'), - preparationMode('preparation_mode'), - minutesUntilSchedule('minutes_until_schedule'), - preparationChanged('preparation_changed'), - permissionResult('permission_result'), - launchAction('launch_action'), - provider('provider'), - errorCode('error_code'), - latenessBucket('lateness_bucket'), - startedEarly('started_early'); - - const ProductUsageEventParameterKey(this.wireValue); - - final String wireValue; - - static ProductUsageEventParameterKey? fromWireValue(String wireValue) { - for (final key in ProductUsageEventParameterKey.values) { - if (key.wireValue == wireValue) return key; - } - return null; - } -} - -class ProductUsageEventDefinition { - const ProductUsageEventDefinition({ - required this.name, - required this.workflow, - required this.allowedParameters, - this.schemaVersion = 1, - }); - - final String name; - final ProductUsageWorkflow workflow; - final Set allowedParameters; - final int schemaVersion; - - Set get allowedParameterNames => Set.unmodifiable( - allowedParameters.map((parameter) => parameter.wireValue), - ); -} - -class ProductUsageEventCatalog { - const ProductUsageEventCatalog._(); - - static const analyticsPreferenceChanged = ProductUsageEventDefinition( - name: 'analytics_preference_changed', - workflow: ProductUsageWorkflow.analytics, - allowedParameters: { - ProductUsageEventParameterKey.enabled, - ProductUsageEventParameterKey.source, - }, - ); - - static const onboardingCompleted = ProductUsageEventDefinition( - name: 'onboarding_completed', - workflow: ProductUsageWorkflow.onboarding, - allowedParameters: { - ProductUsageEventParameterKey.preparationStepCount, - ProductUsageEventParameterKey.spareTimeMinutes, - }, - ); - - static const signUpCompleted = ProductUsageEventDefinition( - name: 'sign_up_completed', - workflow: ProductUsageWorkflow.authentication, - allowedParameters: {ProductUsageEventParameterKey.authProvider}, - ); - - static const loginCompleted = ProductUsageEventDefinition( - name: 'login_completed', - workflow: ProductUsageWorkflow.authentication, - allowedParameters: {ProductUsageEventParameterKey.authProvider}, - ); - - static const scheduleCreateStarted = ProductUsageEventDefinition( - name: 'schedule_create_started', - workflow: ProductUsageWorkflow.schedule, - allowedParameters: {ProductUsageEventParameterKey.source}, - ); - - static const scheduleCreated = ProductUsageEventDefinition( - name: 'schedule_created', - workflow: ProductUsageWorkflow.schedule, - allowedParameters: { - ProductUsageEventParameterKey.preparationMode, - ProductUsageEventParameterKey.preparationStepCount, - ProductUsageEventParameterKey.minutesUntilSchedule, - }, - ); - - static const scheduleUpdated = ProductUsageEventDefinition( - name: 'schedule_updated', - workflow: ProductUsageWorkflow.schedule, - allowedParameters: { - ProductUsageEventParameterKey.preparationChanged, - ProductUsageEventParameterKey.minutesUntilSchedule, - }, - ); - - static const scheduleDeleted = ProductUsageEventDefinition( - name: 'schedule_deleted', - workflow: ProductUsageWorkflow.schedule, - allowedParameters: {ProductUsageEventParameterKey.minutesUntilSchedule}, - ); - - static const notificationPermissionResult = ProductUsageEventDefinition( - name: 'notification_permission_result', - workflow: ProductUsageWorkflow.notification, - allowedParameters: { - ProductUsageEventParameterKey.permissionResult, - ProductUsageEventParameterKey.source, - }, - ); - - static const alarmOpened = ProductUsageEventDefinition( - name: 'alarm_opened', - workflow: ProductUsageWorkflow.alarm, - allowedParameters: { - ProductUsageEventParameterKey.launchAction, - ProductUsageEventParameterKey.provider, - }, - ); - - static const alarmFailed = ProductUsageEventDefinition( - name: 'alarm_failed', - workflow: ProductUsageWorkflow.alarm, - allowedParameters: { - ProductUsageEventParameterKey.errorCode, - ProductUsageEventParameterKey.provider, - }, - ); - - static const scheduleFinished = ProductUsageEventDefinition( - name: 'schedule_finished', - workflow: ProductUsageWorkflow.schedule, - allowedParameters: { - ProductUsageEventParameterKey.latenessBucket, - ProductUsageEventParameterKey.preparationStepCount, - ProductUsageEventParameterKey.startedEarly, - }, - ); - - static const firstReleaseEvents = [ - analyticsPreferenceChanged, - onboardingCompleted, - signUpCompleted, - loginCompleted, - scheduleCreateStarted, - scheduleCreated, - scheduleUpdated, - scheduleDeleted, - notificationPermissionResult, - alarmOpened, - alarmFailed, - scheduleFinished, - ]; - - static final Map _definitionsByName = { - for (final definition in firstReleaseEvents) definition.name: definition, - }; - - static const _forbiddenParameterNames = { - 'email', - 'display_name', - 'oauth_identifier', - 'fcm_token', - 'access_token', - 'refresh_token', - 'schedule_name', - 'schedule_note', - 'place_name', - 'preparation_step_name', - 'exception', - 'stack_trace', - 'request_body', - 'response_body', - 'location', - 'latitude', - 'longitude', - }; - - static ProductUsageEventDefinition definitionFor(String name) { - final definition = _definitionsByName[name]; - if (definition == null) { - throw ProductUsageEventCatalogException( - 'Unknown Product Usage Event: $name', - ); - } - return definition; - } - - static Map validateParameters( - ProductUsageEventDefinition definition, - Map parameters, - ) { - final validatedParameters = {}; - for (final entry in parameters.entries) { - final key = entry.key; - final value = entry.value; - if (_forbiddenParameterNames.contains(key)) { - throw ProductUsageEventCatalogException( - 'Forbidden Analytics Event Parameter: $key', - ); - } - - final parameterKey = ProductUsageEventParameterKey.fromWireValue(key); - if (parameterKey == null || - !definition.allowedParameters.contains(parameterKey)) { - throw ProductUsageEventCatalogException( - 'Parameter $key is not allowed for ${definition.name}', - ); - } - - _validateParameterValue(key, value); - validatedParameters[key] = value; - } - return Map.unmodifiable(validatedParameters); - } - - static void _validateParameterValue(String key, Object value) { - if (value is Map || value is Iterable) { - throw ProductUsageEventCatalogException( - 'Parameter $key must be a scalar analytics value', - ); - } - if (value is String || value is num || value is bool) return; - throw ProductUsageEventCatalogException( - 'Parameter $key has unsupported value type ${value.runtimeType}', - ); - } -} - -class ProductUsageEventCatalogException implements Exception { - const ProductUsageEventCatalogException(this.message); - - final String message; - - @override - String toString() => message; -} - -String _preparationModeWireValue(SchedulePreparationMode? mode) { - switch (mode) { - case SchedulePreparationMode.template: - return 'template'; - case SchedulePreparationMode.custom: - return 'custom'; - case SchedulePreparationMode.defaultPreparation: - case null: - return 'default'; - } -} diff --git a/lib/domain/entities/schedule_entity.dart b/lib/domain/entities/schedule_entity.dart index b8e41604..5b200085 100644 --- a/lib/domain/entities/schedule_entity.dart +++ b/lib/domain/entities/schedule_entity.dart @@ -8,6 +8,8 @@ class ScheduleEntity extends Equatable { final String id; final PlaceEntity place; final String scheduleName; + final String timeZoneId; + final int? occurrenceOffsetSeconds; final DateTime scheduleTime; final Duration moveTime; final bool isChanged; @@ -23,12 +25,15 @@ class ScheduleEntity extends Equatable { final String? preparationTemplateName; final bool preparationTemplateDeleted; final bool preparationFrozen; + final bool scoreContributionRecorded; final PreparationEntity? customPreparations; const ScheduleEntity({ required this.id, required this.place, required this.scheduleName, + this.timeZoneId = 'UTC', + this.occurrenceOffsetSeconds, required this.scheduleTime, required this.moveTime, required this.isChanged, @@ -44,11 +49,35 @@ class ScheduleEntity extends Equatable { this.preparationTemplateName, this.preparationTemplateDeleted = false, this.preparationFrozen = false, + this.scoreContributionRecorded = false, this.customPreparations, }); + /// The absolute instant selected for this civil schedule occurrence. + /// + /// [scheduleTime] intentionally keeps the wall-clock fields for display. + /// The stored UTC offset disambiguates repeated local times without needing + /// to reinterpret the occurrence in the device's current time zone. + DateTime get occurrenceInstantUtc { + final offset = occurrenceOffsetSeconds; + if (offset == null) return scheduleTime.toUtc(); + final civilAsUtc = DateTime.utc( + scheduleTime.year, + scheduleTime.month, + scheduleTime.day, + scheduleTime.hour, + scheduleTime.minute, + scheduleTime.second, + scheduleTime.millisecond, + scheduleTime.microsecond, + ); + return civilAsUtc.subtract(Duration(seconds: offset)); + } + ScheduleEntity copyWith({ ScheduleDoneStatus? doneStatus, + String? timeZoneId, + int? occurrenceOffsetSeconds, DateTime? startedAt, DateTime? finishedAt, SchedulePreparationMode? preparationMode, @@ -56,12 +85,16 @@ class ScheduleEntity extends Equatable { String? preparationTemplateName, bool? preparationTemplateDeleted, bool? preparationFrozen, + bool? scoreContributionRecorded, PreparationEntity? customPreparations, }) { return ScheduleEntity( id: id, place: place, scheduleName: scheduleName, + timeZoneId: timeZoneId ?? this.timeZoneId, + occurrenceOffsetSeconds: + occurrenceOffsetSeconds ?? this.occurrenceOffsetSeconds, scheduleTime: scheduleTime, moveTime: moveTime, isChanged: isChanged, @@ -80,6 +113,8 @@ class ScheduleEntity extends Equatable { preparationTemplateDeleted: preparationTemplateDeleted ?? this.preparationTemplateDeleted, preparationFrozen: preparationFrozen ?? this.preparationFrozen, + scoreContributionRecorded: + scoreContributionRecorded ?? this.scoreContributionRecorded, customPreparations: customPreparations ?? this.customPreparations, ); } @@ -94,6 +129,8 @@ class ScheduleEntity extends Equatable { id, place, scheduleName, + timeZoneId, + occurrenceOffsetSeconds, scheduleTime, moveTime, isChanged, @@ -109,6 +146,7 @@ class ScheduleEntity extends Equatable { preparationTemplateName, preparationTemplateDeleted, preparationFrozen, + scoreContributionRecorded, customPreparations, ]; } diff --git a/lib/domain/entities/schedule_with_preparation_entity.dart b/lib/domain/entities/schedule_with_preparation_entity.dart index 20a2fdb4..df76af7d 100644 --- a/lib/domain/entities/schedule_with_preparation_entity.dart +++ b/lib/domain/entities/schedule_with_preparation_entity.dart @@ -8,6 +8,8 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { required super.id, required super.place, required super.scheduleName, + super.timeZoneId, + super.occurrenceOffsetSeconds, required super.scheduleTime, required super.moveTime, required super.isChanged, @@ -23,6 +25,7 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { super.preparationTemplateName, super.preparationTemplateDeleted, super.preparationFrozen, + super.scoreContributionRecorded, super.customPreparations, required this.preparation, }); @@ -34,13 +37,18 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { (scheduleSpareTime ?? Duration.zero); ///Returns the time when the preparation starts. - DateTime get preparationStartTime => scheduleTime.subtract(totalDuration); + DateTime get preparationStartTime => + occurrenceInstantUtc.subtract(totalDuration); /// Fingerprint for validating whether cached timed-preparation is still valid. String get cacheFingerprint { final spare = scheduleSpareTime ?? Duration.zero; final buffer = StringBuffer() - ..write(scheduleTime.millisecondsSinceEpoch) + ..write(scheduleTime.toIso8601String()) + ..write('|') + ..write(timeZoneId) + ..write('|') + ..write(occurrenceOffsetSeconds) ..write('|') ..write(moveTime.inMilliseconds) ..write('|') @@ -65,7 +73,8 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { /// Returns the time remaining before needing to leave at [now]. Duration timeRemainingBeforeLeavingAt(DateTime now) { final spareTime = scheduleSpareTime ?? Duration.zero; - final remaining = scheduleTime.difference(now) - moveTime - spareTime; + final remaining = + occurrenceInstantUtc.difference(now.toUtc()) - moveTime - spareTime; return remaining; } @@ -92,6 +101,8 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { id: schedule.id, place: schedule.place, scheduleName: schedule.scheduleName, + timeZoneId: schedule.timeZoneId, + occurrenceOffsetSeconds: schedule.occurrenceOffsetSeconds, scheduleTime: schedule.scheduleTime, moveTime: schedule.moveTime, isChanged: schedule.isChanged, @@ -107,6 +118,7 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { preparationTemplateName: schedule.preparationTemplateName, preparationTemplateDeleted: schedule.preparationTemplateDeleted, preparationFrozen: schedule.preparationFrozen, + scoreContributionRecorded: schedule.scoreContributionRecorded, customPreparations: schedule.customPreparations, preparation: preparation, ); @@ -117,6 +129,8 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { id, place, scheduleName, + timeZoneId, + occurrenceOffsetSeconds, scheduleTime, moveTime, isChanged, @@ -131,6 +145,7 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { preparationTemplateName, preparationTemplateDeleted, preparationFrozen, + scoreContributionRecorded, preparation, ]; } diff --git a/lib/domain/entities/token_entity.dart b/lib/domain/entities/token_entity.dart deleted file mode 100644 index 31a20f76..00000000 --- a/lib/domain/entities/token_entity.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:equatable/equatable.dart'; - -class TokenEntity extends Equatable { - final String accessToken; - final String refreshToken; - - const TokenEntity({ - required this.accessToken, - required this.refreshToken, - }); - - static TokenEntity fromHeaders(Headers headers) { - return TokenEntity( - accessToken: headers.value('authorization')!, - refreshToken: headers.value('authorization-refresh')!, - ); - } - - @override - List get props => [accessToken, refreshToken]; -} diff --git a/lib/domain/entities/user_entity.dart b/lib/domain/entities/user_entity.dart index 93d0a5ed..783aaa6f 100644 --- a/lib/domain/entities/user_entity.dart +++ b/lib/domain/entities/user_entity.dart @@ -8,11 +8,10 @@ class UserEntity with _$UserEntity { const factory UserEntity({ required String id, - required String email, - required String name, required Duration spareTime, required String note, - required double score, + @Default(0) int eligibleOutcomeCount, + @Default(0) int onTimeOutcomeCount, @Default(false) bool isOnboardingCompleted, }) = _UserEntity; @@ -24,26 +23,53 @@ class UserEntity with _$UserEntity { _ => null, }; - Duration? get spareTimeOrNull => switch (this) { + String get id => switch (this) { + _UserEntity(:final id) => id, + _UserEntityEmpty() => throw StateError('The local profile is empty.'), + _ => throw StateError('Unknown local profile state.'), + }; + + Duration get spareTime => switch (this) { _UserEntity(:final spareTime) => spareTime, - _UserEntityEmpty() => null, - _ => null, + _UserEntityEmpty() => throw StateError('The local profile is empty.'), + _ => throw StateError('Unknown local profile state.'), }; - double? get scoreOrNull => switch (this) { - _UserEntity(:final score) => score, - _UserEntityEmpty() => null, - _ => null, + String get note => switch (this) { + _UserEntity(:final note) => note, + _UserEntityEmpty() => throw StateError('The local profile is empty.'), + _ => throw StateError('Unknown local profile state.'), }; - String? get nameOrNull => switch (this) { - _UserEntity(:final name) => name, + int get eligibleOutcomeCount => switch (this) { + _UserEntity(:final eligibleOutcomeCount) => eligibleOutcomeCount, + _UserEntityEmpty() => throw StateError('The local profile is empty.'), + _ => throw StateError('Unknown local profile state.'), + }; + + int get onTimeOutcomeCount => switch (this) { + _UserEntity(:final onTimeOutcomeCount) => onTimeOutcomeCount, + _UserEntityEmpty() => throw StateError('The local profile is empty.'), + _ => throw StateError('Unknown local profile state.'), + }; + + bool get isOnboardingCompleted => switch (this) { + _UserEntity(:final isOnboardingCompleted) => isOnboardingCompleted, + _UserEntityEmpty() => false, + _ => false, + }; + + Duration? get spareTimeOrNull => switch (this) { + _UserEntity(:final spareTime) => spareTime, _UserEntityEmpty() => null, _ => null, }; - String? get emailOrNull => switch (this) { - _UserEntity(:final email) => email, + double? get scoreOrNull => switch (this) { + _UserEntity(:final eligibleOutcomeCount, :final onTimeOutcomeCount) => + eligibleOutcomeCount == 0 + ? null + : onTimeOutcomeCount * 100 / eligibleOutcomeCount, _UserEntityEmpty() => null, _ => null, }; diff --git a/lib/domain/repositories/alarm_repository.dart b/lib/domain/repositories/alarm_repository.dart index 5365a619..ca257c5f 100644 --- a/lib/domain/repositories/alarm_repository.dart +++ b/lib/domain/repositories/alarm_repository.dart @@ -2,22 +2,12 @@ import 'package:on_time_front/domain/entities/alarm_entities.dart'; import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; abstract interface class AlarmRepository { - Future getDeviceId(); - - Future buildCurrentDeviceInfo(); - Future getAlarmSettings(); Future updateAlarmSettings({required bool alarmsEnabled}); - Future registerCurrentDevice(AlarmDeviceInfo deviceInfo); - - Future unregisterCurrentDevice(String deviceId); - Future> getAlarmWindow( DateTime startDate, DateTime endDate, ); - - Future postAlarmStatus(AlarmStatusReport report); } diff --git a/lib/domain/repositories/analytics_preference_repository.dart b/lib/domain/repositories/analytics_preference_repository.dart deleted file mode 100644 index a5807cc4..00000000 --- a/lib/domain/repositories/analytics_preference_repository.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:on_time_front/domain/entities/analytics_preference.dart'; - -abstract interface class AnalyticsPreferenceRepository { - Future loadLocalPreference(); - - Future saveLocalPreference(bool enabled); - - Future loadAccountPreference(); - - Future updateAccountPreference(bool enabled); -} diff --git a/lib/domain/repositories/preparation_repository.dart b/lib/domain/repositories/preparation_repository.dart index 37e970c3..eab9488d 100644 --- a/lib/domain/repositories/preparation_repository.dart +++ b/lib/domain/repositories/preparation_repository.dart @@ -7,18 +7,23 @@ abstract interface class PreparationRepository { Future getDefualtPreparation(); - Future createDefaultPreparation( - {required PreparationEntity preparationEntity, - required Duration spareTime, - required String note}); + Future createDefaultPreparation({ + required PreparationEntity preparationEntity, + required Duration spareTime, + required String note, + }); Future createCustomPreparation( - PreparationEntity preparationEntity, String scheduleId); + PreparationEntity preparationEntity, + String scheduleId, + ); Future updateDefaultPreparation(PreparationEntity preparationEntity); Future updatePreparationByScheduleId( - PreparationEntity preparationEntity, String scheduleId); + PreparationEntity preparationEntity, + String scheduleId, + ); Future updateSpareTime(Duration newSpareTime); } diff --git a/lib/domain/repositories/user_repository.dart b/lib/domain/repositories/user_repository.dart index db7b248c..96307b67 100644 --- a/lib/domain/repositories/user_repository.dart +++ b/lib/domain/repositories/user_repository.dart @@ -1,39 +1,11 @@ -import 'package:on_time_front/domain/entities/google_auth_credential.dart'; import 'package:on_time_front/domain/entities/user_entity.dart'; abstract interface class UserRepository { Stream get userStream; - Future signUp({ - required String email, - required String password, - required String name, - }); + Future getUser(); - Future signIn({required String email, required String password}); + Future saveUser(UserEntity user); - Future signOut(); - - Future signInWithGoogle(GoogleAuthCredential credential); - - Future signInWithApple({ - required String idToken, - required String authCode, - required String fullName, - String? email, - }); - - Future getUser(); - - Future deleteUser({String? feedbackMessage}); - - Future deleteGoogleUser({String? feedbackMessage}); - - Future deleteAppleUser({String? feedbackMessage}); - - Future postFeedback(String message); - - Future getUserSocialType(); - - Future disconnectGoogleSignIn(); + Future resetLocalData(); } diff --git a/lib/domain/use-cases/cancel_all_alarms_use_case.dart b/lib/domain/use-cases/cancel_all_alarms_use_case.dart index a5dc6f00..340d4850 100644 --- a/lib/domain/use-cases/cancel_all_alarms_use_case.dart +++ b/lib/domain/use-cases/cancel_all_alarms_use_case.dart @@ -3,36 +3,23 @@ import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; import 'package:on_time_front/domain/entities/alarm_entities.dart'; import 'package:on_time_front/domain/repositories/alarm_registry_repository.dart'; -import 'package:on_time_front/domain/repositories/alarm_repository.dart'; @Injectable() class CancelAllAlarmsUseCase { - final AlarmRepository _alarmRepository; final AlarmRegistryRepository _registryRepository; final AlarmSchedulerService _schedulerService; final FallbackAlarmNotificationService _fallbackNotificationService; CancelAllAlarmsUseCase( - this._alarmRepository, this._registryRepository, this._schedulerService, this._fallbackNotificationService, ); - Future call({bool unregisterDevice = false}) async { + Future call() async { final records = await _registryRepository.loadAll(); await cancelRecords(records); await _registryRepository.deleteAll(); - - if (unregisterDevice) { - try { - await _alarmRepository.unregisterCurrentDevice( - await _alarmRepository.getDeviceId(), - ); - } catch (_) { - // Logout/session cleanup must still complete when the backend is gone. - } - } } Future cancelRecords(List records) async { diff --git a/lib/domain/use-cases/create_custom_preparation_use_case.dart b/lib/domain/use-cases/create_custom_preparation_use_case.dart index 061a6a61..b8012ecd 100644 --- a/lib/domain/use-cases/create_custom_preparation_use_case.dart +++ b/lib/domain/use-cases/create_custom_preparation_use_case.dart @@ -9,8 +9,12 @@ class CreateCustomPreparationUseCase { CreateCustomPreparationUseCase(this._preparationRepository); Future call( - PreparationEntity preparationEntity, String scheduleId) async { + PreparationEntity preparationEntity, + String scheduleId, + ) async { await _preparationRepository.createCustomPreparation( - preparationEntity, scheduleId); + preparationEntity, + scheduleId, + ); } } diff --git a/lib/domain/use-cases/create_schedule_form_submission_use_case.dart b/lib/domain/use-cases/create_schedule_form_submission_use_case.dart index 667076a7..bf4d56f3 100644 --- a/lib/domain/use-cases/create_schedule_form_submission_use_case.dart +++ b/lib/domain/use-cases/create_schedule_form_submission_use_case.dart @@ -1,19 +1,16 @@ import 'package:injectable/injectable.dart'; import 'package:on_time_front/domain/use-cases/create_custom_preparation_use_case.dart'; import 'package:on_time_front/domain/use-cases/create_schedule_with_place_use_case.dart'; -import 'package:on_time_front/domain/use-cases/schedule_analytics_tracker.dart'; import 'package:on_time_front/domain/use-cases/schedule_form_submission.dart'; @Injectable() class CreateScheduleFormSubmissionUseCase { final CreateScheduleWithPlaceUseCase _createScheduleWithPlaceUseCase; final CreateCustomPreparationUseCase _createCustomPreparationUseCase; - final ScheduleAnalyticsTracker _scheduleAnalyticsTracker; CreateScheduleFormSubmissionUseCase( this._createScheduleWithPlaceUseCase, this._createCustomPreparationUseCase, - this._scheduleAnalyticsTracker, ); Future call(ScheduleFormSubmission submission) async { @@ -24,9 +21,5 @@ class CreateScheduleFormSubmissionUseCase { submission.schedule.id, ); } - await _scheduleAnalyticsTracker.trackScheduleCreated( - schedule: submission.schedule, - preparation: submission.preparation, - ); } } diff --git a/lib/domain/use-cases/delete_user_use_case.dart b/lib/domain/use-cases/delete_user_use_case.dart deleted file mode 100644 index 0a39967e..00000000 --- a/lib/domain/use-cases/delete_user_use_case.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/domain/repositories/user_repository.dart'; -import 'package:on_time_front/presentation/shared/constants/constants.dart'; - -@Injectable() -class DeleteUserUseCase { - final UserRepository _userRepository; - - DeleteUserUseCase(this._userRepository); - - Future call(String feedbackMessage) async { - final socialTypeString = await _userRepository.getUserSocialType(); - final socialType = socialTypeFromString(socialTypeString); - - if (socialType == SocialType.google) { - await _userRepository.deleteGoogleUser(feedbackMessage: feedbackMessage); - await _userRepository.disconnectGoogleSignIn(); - } else if (socialType == SocialType.apple) { - await _userRepository.deleteAppleUser(feedbackMessage: feedbackMessage); - } else { - await _userRepository.deleteUser(feedbackMessage: feedbackMessage); - } - } -} diff --git a/lib/domain/use-cases/get_adjacent_schedules_with_preparation_use_case.dart b/lib/domain/use-cases/get_adjacent_schedules_with_preparation_use_case.dart index 08ef4179..aaf0cd80 100644 --- a/lib/domain/use-cases/get_adjacent_schedules_with_preparation_use_case.dart +++ b/lib/domain/use-cases/get_adjacent_schedules_with_preparation_use_case.dart @@ -34,8 +34,10 @@ class GetAdjacentSchedulesWithPreparationUseCase { }) async { try { // Get schedules from the stream - final schedules = - await _getSchedulesByDateUseCase(startDate, endDate).first; + final schedules = await _getSchedulesByDateUseCase( + startDate, + endDate, + ).first; AppLogger.debug( 'Schedule filtering selectedDateTime=$selectedDateTime ' @@ -50,8 +52,9 @@ class GetAdjacentSchedulesWithPreparationUseCase { final filteredSchedules = schedules.where((schedule) { final isNotCurrent = schedule.id != currentScheduleId; final isAfterSelected = schedule.scheduleTime.isAfter(selectedDateTime); - final timeComparison = - schedule.scheduleTime.compareTo(selectedDateTime); + final timeComparison = schedule.scheduleTime.compareTo( + selectedDateTime, + ); AppLogger.debug( 'Next schedule filter scheduleId=${schedule.id} ' @@ -67,8 +70,9 @@ class GetAdjacentSchedulesWithPreparationUseCase { // Filter schedules before selectedDateTime for previous schedule final previousSchedules = schedules.where((schedule) { final isNotCurrent = schedule.id != currentScheduleId; - final isBeforeSelected = - schedule.scheduleTime.isBefore(selectedDateTime); + final isBeforeSelected = schedule.scheduleTime.isBefore( + selectedDateTime, + ); AppLogger.debug( 'Previous schedule filter scheduleId=${schedule.id} ' @@ -89,22 +93,24 @@ class GetAdjacentSchedulesWithPreparationUseCase { // For overlap checking, we use the canonical preparation from the stream // (not locally stored timed preparations which are for tracking progress) Future getScheduleWithPreparation( - schedule) async { + schedule, + ) async { try { // Try to get preparation from stream with a longer timeout // Preparations should have been loaded by LoadAdjacentScheduleWithPreparationUseCase final preparationEntity = await _getPreparationByScheduleIdUseCase(schedule.id).timeout( - const Duration(seconds: 10), - onTimeout: () { - throw TimeoutException( - 'Preparation not found in stream for schedule ${schedule.id} after 10 seconds. ' - 'It may not have been loaded yet.', + const Duration(seconds: 10), + onTimeout: () { + throw TimeoutException( + 'Preparation not found in stream for schedule ${schedule.id} after 10 seconds. ' + 'It may not have been loaded yet.', + ); + }, ); - }, + final preparation = PreparationWithTimeEntity.fromPreparation( + preparationEntity, ); - final preparation = - PreparationWithTimeEntity.fromPreparation(preparationEntity); // Create ScheduleWithPreparationEntity return ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( @@ -115,7 +121,8 @@ class GetAdjacentSchedulesWithPreparationUseCase { // If preparation is not in stream, return null // This can happen if the preparation hasn't been loaded yet or doesn't exist AppLogger.debug( - 'Preparation not found in stream for schedule ${schedule.id}: $e'); + 'Preparation not found in stream for schedule ${schedule.id}: $e', + ); return null; } } @@ -124,20 +131,24 @@ class GetAdjacentSchedulesWithPreparationUseCase { ScheduleWithPreparationEntity? nextSchedule; if (filteredSchedules.isNotEmpty) { // Sort by scheduleTime and get the first one (closest) - filteredSchedules - .sort((a, b) => a.scheduleTime.compareTo(b.scheduleTime)); - nextSchedule = - await getScheduleWithPreparation(filteredSchedules.first); + filteredSchedules.sort( + (a, b) => a.scheduleTime.compareTo(b.scheduleTime), + ); + nextSchedule = await getScheduleWithPreparation( + filteredSchedules.first, + ); } // Get previous schedule ScheduleWithPreparationEntity? previousSchedule; if (previousSchedules.isNotEmpty) { // Sort by scheduleTime descending and get the first one (closest before) - previousSchedules - .sort((a, b) => b.scheduleTime.compareTo(a.scheduleTime)); - previousSchedule = - await getScheduleWithPreparation(previousSchedules.first); + previousSchedules.sort( + (a, b) => b.scheduleTime.compareTo(a.scheduleTime), + ); + previousSchedule = await getScheduleWithPreparation( + previousSchedules.first, + ); } return AdjacentSchedulesWithPreparationEntity( diff --git a/lib/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart b/lib/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart index a26ce686..21874b91 100644 --- a/lib/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart +++ b/lib/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart @@ -17,32 +17,38 @@ class GetNearestUpcomingScheduleUseCase { final LoadSchedulesForWeekUseCase _loadSchedulesForWeekUseCase; GetNearestUpcomingScheduleUseCase( - this._getScheduleByDateUseCase, - this._loadPreparationByScheduleIdUseCase, - this._getPreparationByScheduleIdUseCase, - this._loadSchedulesForWeekUseCase); + this._getScheduleByDateUseCase, + this._loadPreparationByScheduleIdUseCase, + this._getPreparationByScheduleIdUseCase, + this._loadSchedulesForWeekUseCase, + ); Stream call() async* { final DateTime now = DateTime.now(); unawaited(_loadSchedulesForWeekUseCase(now).catchError((_) {})); - final upcomingScheduleStream = - _getScheduleByDateUseCase(now, now.add(const Duration(days: 2))); + final upcomingScheduleStream = _getScheduleByDateUseCase( + now, + now.add(const Duration(days: 2)), + ); await for (final upcomingSchedule in upcomingScheduleStream) { if (upcomingSchedule.isNotEmpty) { try { final schedule = upcomingSchedule.firstWhere( - (s) => s.doneStatus == ScheduleDoneStatus.notEnded, - orElse: () => throw Exception('No upcoming schedule found')); + (s) => s.doneStatus == ScheduleDoneStatus.notEnded, + orElse: () => throw Exception('No upcoming schedule found'), + ); await _loadPreparationByScheduleIdUseCase(schedule.id); - final preparation = - await _getPreparationByScheduleIdUseCase(schedule.id); + final preparation = await _getPreparationByScheduleIdUseCase( + schedule.id, + ); final scheduleWithPreparation = ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( - schedule, - PreparationWithTimeEntity.fromPreparation(preparation)); + schedule, + PreparationWithTimeEntity.fromPreparation(preparation), + ); yield scheduleWithPreparation; } catch (e) { yield null; diff --git a/lib/domain/use-cases/load_adjacent_schedule_with_preparation_use_case.dart b/lib/domain/use-cases/load_adjacent_schedule_with_preparation_use_case.dart index ea7baf24..837d865c 100644 --- a/lib/domain/use-cases/load_adjacent_schedule_with_preparation_use_case.dart +++ b/lib/domain/use-cases/load_adjacent_schedule_with_preparation_use_case.dart @@ -15,9 +15,8 @@ class LoadAdjacentScheduleWithPreparationUseCase { this._loadPreparationByScheduleIdUseCase, ); - /// Loads schedules and preparation data from the server for the given date range. - /// This triggers fetching schedules and preparation from the remote data source - /// and updating repository streams. + /// Loads schedules and preparation from the local database for this range. + /// Repository streams are updated for presentation consumers. /// /// [startDate] - Start date for the search range /// [endDate] - End date for the search range diff --git a/lib/domain/use-cases/load_analytics_preference_use_case.dart b/lib/domain/use-cases/load_analytics_preference_use_case.dart deleted file mode 100644 index e97dea96..00000000 --- a/lib/domain/use-cases/load_analytics_preference_use_case.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/domain/entities/analytics_preference.dart'; -import 'package:on_time_front/domain/repositories/analytics_preference_repository.dart'; - -@Injectable() -class LoadAnalyticsPreferenceUseCase { - LoadAnalyticsPreferenceUseCase(this._repository); - - final AnalyticsPreferenceRepository _repository; - - Future call({required bool signedIn}) async { - final localPreference = await _repository.loadLocalPreference(); - if (!signedIn) return localPreference; - - try { - final accountPreference = await _repository.loadAccountPreference(); - return AnalyticsPreference( - enabled: localPreference.enabled && accountPreference.enabled, - updatedAt: accountPreference.updatedAt, - ); - } catch (_) { - return const AnalyticsPreference.disabledUnconfirmed(); - } - } -} diff --git a/lib/domain/use-cases/load_preparation_by_schedule_id_use_case.dart b/lib/domain/use-cases/load_preparation_by_schedule_id_use_case.dart index 4a349eb7..dc602355 100644 --- a/lib/domain/use-cases/load_preparation_by_schedule_id_use_case.dart +++ b/lib/domain/use-cases/load_preparation_by_schedule_id_use_case.dart @@ -8,8 +8,7 @@ class LoadPreparationByScheduleIdUseCase { LoadPreparationByScheduleIdUseCase(this._preparationRepository); /// Loads preparation for the given schedule ID. - /// This triggers fetching preparation from the remote data source - /// and updating the preparation repository stream/cache. + /// This refreshes the repository stream from encrypted local storage. /// /// [scheduleId] - The ID of the schedule to load preparation for Future call(String scheduleId) async { diff --git a/lib/domain/use-cases/load_schedule_form_draft_use_case.dart b/lib/domain/use-cases/load_schedule_form_draft_use_case.dart index f2e917ef..d38f2503 100644 --- a/lib/domain/use-cases/load_schedule_form_draft_use_case.dart +++ b/lib/domain/use-cases/load_schedule_form_draft_use_case.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:injectable/injectable.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/core/services/local_time_zone_service.dart'; import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; import 'package:on_time_front/domain/use-cases/get_default_preparation_use_case.dart'; import 'package:on_time_front/domain/use-cases/get_preparation_by_schedule_id_use_case.dart'; @@ -14,6 +15,8 @@ class ScheduleFormDraft extends Equatable { final String? placeName; final String? scheduleName; final DateTime? scheduleTime; + final String timeZoneId; + final int? occurrenceOffsetSeconds; final Duration? moveTime; final bool preparationChanged; final Duration? scheduleSpareTime; @@ -27,6 +30,8 @@ class ScheduleFormDraft extends Equatable { required this.placeName, required this.scheduleName, required this.scheduleTime, + this.timeZoneId = 'UTC', + this.occurrenceOffsetSeconds, required this.moveTime, required this.preparationChanged, required this.scheduleSpareTime, @@ -42,6 +47,8 @@ class ScheduleFormDraft extends Equatable { placeName, scheduleName, scheduleTime, + timeZoneId, + occurrenceOffsetSeconds, moveTime, preparationChanged, scheduleSpareTime, @@ -59,6 +66,7 @@ class LoadScheduleFormDraftUseCase { final GetScheduleByIdUseCase _getScheduleByIdUseCase; final DateTime Function() _now; final String Function() _newId; + final Future Function() _timeZoneId; LoadScheduleFormDraftUseCase( this._loadPreparationByScheduleIdUseCase, @@ -66,7 +74,8 @@ class LoadScheduleFormDraftUseCase { this._getDefaultPreparationUseCase, this._getScheduleByIdUseCase, ) : _now = DateTime.now, - _newId = const Uuid().v7; + _newId = const Uuid().v7, + _timeZoneId = LocalTimeZoneService.current; LoadScheduleFormDraftUseCase.withOverrides( this._loadPreparationByScheduleIdUseCase, @@ -75,8 +84,10 @@ class LoadScheduleFormDraftUseCase { this._getScheduleByIdUseCase, { required DateTime Function() now, required String Function() newId, + Future Function()? timeZoneId, }) : _now = now, - _newId = newId; + _newId = newId, + _timeZoneId = timeZoneId ?? LocalTimeZoneService.current; Future create({ DateTime? initialDate, @@ -92,6 +103,8 @@ class LoadScheduleFormDraftUseCase { scheduleTime: initialDate == null ? null : _initialScheduleTime(initialDate, _now()), + timeZoneId: await _timeZoneId(), + occurrenceOffsetSeconds: null, moveTime: null, preparationChanged: false, scheduleSpareTime: currentUserSpareTime, @@ -112,6 +125,8 @@ class LoadScheduleFormDraftUseCase { placeName: schedule.place.placeName, scheduleName: schedule.scheduleName, scheduleTime: schedule.scheduleTime, + timeZoneId: schedule.timeZoneId, + occurrenceOffsetSeconds: schedule.occurrenceOffsetSeconds, moveTime: schedule.moveTime, preparationChanged: schedule.isChanged, scheduleSpareTime: schedule.scheduleSpareTime, diff --git a/lib/domain/use-cases/load_schedules_by_date_use_case.dart b/lib/domain/use-cases/load_schedules_by_date_use_case.dart index cae8e4b8..8d7412a7 100644 --- a/lib/domain/use-cases/load_schedules_by_date_use_case.dart +++ b/lib/domain/use-cases/load_schedules_by_date_use_case.dart @@ -8,8 +8,7 @@ class LoadSchedulesByDateUseCase { LoadSchedulesByDateUseCase(this._scheduleRepository); /// Loads schedules for the given date range. - /// This triggers fetching schedules from the remote data source - /// and updating the in-memory schedule stream. + /// This refreshes the in-memory stream from the encrypted local database. /// /// [startDate] - Start date of the range (inclusive) /// [endDate] - End date of the range (exclusive), or null for all schedules after startDate diff --git a/lib/domain/use-cases/mark_early_start_session_use_case.dart b/lib/domain/use-cases/mark_early_start_session_use_case.dart index a9f4a956..5d53a77e 100644 --- a/lib/domain/use-cases/mark_early_start_session_use_case.dart +++ b/lib/domain/use-cases/mark_early_start_session_use_case.dart @@ -7,10 +7,7 @@ class MarkEarlyStartSessionUseCase { final EarlyStartSessionRepository _earlyStartSessionRepository; - Future call({ - required String scheduleId, - required DateTime startedAt, - }) { + Future call({required String scheduleId, required DateTime startedAt}) { return _earlyStartSessionRepository.markStarted( scheduleId: scheduleId, startedAt: startedAt, diff --git a/lib/domain/use-cases/onboard_use_case.dart b/lib/domain/use-cases/onboard_use_case.dart index cf193f59..80a9593b 100644 --- a/lib/domain/use-cases/onboard_use_case.dart +++ b/lib/domain/use-cases/onboard_use_case.dart @@ -10,12 +10,17 @@ class OnboardUseCase { OnboardUseCase(this._preparationRepository, this._userRepository); - Future call( - {required PreparationEntity preparationEntity, - required Duration spareTime, - required String note}) async { + Future call({ + required PreparationEntity preparationEntity, + required Duration spareTime, + required String note, + }) async { + await _userRepository.getUser(); await _preparationRepository.createDefaultPreparation( - preparationEntity: preparationEntity, spareTime: spareTime, note: note); + preparationEntity: preparationEntity, + spareTime: spareTime, + note: note, + ); await _userRepository.getUser(); } } diff --git a/lib/domain/use-cases/reconcile_alarms_use_case.dart b/lib/domain/use-cases/reconcile_alarms_use_case.dart index 53b4336e..53b7032c 100644 --- a/lib/domain/use-cases/reconcile_alarms_use_case.dart +++ b/lib/domain/use-cases/reconcile_alarms_use_case.dart @@ -2,12 +2,12 @@ import 'package:flutter/foundation.dart'; import 'package:injectable/injectable.dart'; import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; +import 'package:on_time_front/core/services/local_time_zone_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/schedule_with_preparation_entity.dart'; import 'package:on_time_front/domain/repositories/alarm_registry_repository.dart'; import 'package:on_time_front/domain/repositories/alarm_repository.dart'; -import 'package:on_time_front/domain/repositories/user_repository.dart'; import 'package:on_time_front/core/logging/app_logger.dart'; typedef AlarmNowProvider = DateTime Function(); @@ -15,12 +15,12 @@ typedef AlarmNowProvider = DateTime Function(); @Singleton() class ReconcileAlarmsUseCase { static const _logTag = '[ReconcileAlarms]'; + static const _platformAlarmCapacity = 60; final AlarmRepository _alarmRepository; final AlarmRegistryRepository _registryRepository; final AlarmSchedulerService _schedulerService; final FallbackAlarmNotificationService _fallbackNotificationService; - final UserRepository? _userRepository; final AlarmNowProvider _nowProvider; Future? _inFlight; @@ -29,9 +29,7 @@ class ReconcileAlarmsUseCase { this._registryRepository, this._schedulerService, this._fallbackNotificationService, - UserRepository userRepository, - ) : _userRepository = userRepository, - _nowProvider = DateTime.now; + ) : _nowProvider = DateTime.now; @visibleForTesting ReconcileAlarmsUseCase.test( @@ -40,9 +38,7 @@ class ReconcileAlarmsUseCase { this._schedulerService, this._fallbackNotificationService, { required AlarmNowProvider nowProvider, - UserRepository? userRepository, - }) : _userRepository = userRepository, - _nowProvider = nowProvider; + }) : _nowProvider = nowProvider; Future call() { final running = _inFlight; @@ -64,11 +60,10 @@ class ReconcileAlarmsUseCase { Future _run() async { final now = _nowProvider(); final scheduleWindowStart = now; - final scheduleWindowEnd = now.add(const Duration(days: 8)); + final scheduleWindowEnd = DateTime(now.year + 50, 1, 1); final alarmCoverageStart = now; - final deviceId = await _alarmRepository.getDeviceId(); final capabilities = await _schedulerService.getCapabilities(); - final alarmCoverageEnd = now.add(const Duration(days: 7)); + final alarmCoverageEnd = scheduleWindowEnd; AppLogger.debug( '$_logTag start now=${now.toIso8601String()} ' 'scheduleWindow=${scheduleWindowStart.toIso8601String()}..' @@ -77,7 +72,7 @@ class ReconcileAlarmsUseCase { '${alarmCoverageEnd.toIso8601String()}', ); AppLogger.debug( - '$_logTag deviceId=$deviceId capabilities=' + '$_logTag capabilities=' 'nativeSupported=${capabilities.supportsNativeAlarm} ' 'nativeProvider=${capabilities.nativeAlarmProvider} ' 'fallbackProvider=${capabilities.fallbackProvider}', @@ -100,7 +95,6 @@ class ReconcileAlarmsUseCase { alarmCoverageStart: alarmCoverageStart, alarmCoverageEnd: alarmCoverageEnd, ); - await _postStatusBestEffort(deviceId, result); return result; } @@ -119,20 +113,9 @@ class ReconcileAlarmsUseCase { alarmCoverageStart: alarmCoverageStart, alarmCoverageEnd: alarmCoverageEnd, ); - await _postStatusBestEffort(deviceId, result); return result; } - try { - await _alarmRepository.registerCurrentDevice( - await _alarmRepository.buildCurrentDeviceInfo(), - ); - AppLogger.debug('$_logTag registerCurrentDevice success'); - } catch (_) { - // Device registration is diagnostic. Local scheduling can still proceed. - AppLogger.debug('$_logTag registerCurrentDevice failed; continuing'); - } - late final List schedules; try { AppLogger.debug( @@ -163,25 +146,31 @@ class ReconcileAlarmsUseCase { alarmCoverageStart: alarmCoverageStart, alarmCoverageEnd: alarmCoverageEnd, ); - await _postStatusBestEffort(deviceId, result); return result; } - final desiredRecords = _desiredRecords( + final allDesiredRecords = _desiredRecords( schedules: schedules, now: now, alarmCoverageEnd: alarmCoverageEnd, alarmOffset: settings.alarmOffset, + detailedNotificationContent: settings.detailedNotificationContent, + currentTimeZoneId: await LocalTimeZoneService.current(), ); - final skippedScheduleCount = schedules - .where( - (schedule) => !_isDesired( - schedule, - now, - alarmCoverageEnd, - settings.alarmOffset, - ), - ) - .length; + final desiredRecords = allDesiredRecords + .take(_platformAlarmCapacity) + .toList(); + final skippedScheduleCount = + schedules + .where( + (schedule) => !_isDesired( + schedule, + now, + alarmCoverageEnd, + settings.alarmOffset, + ), + ) + .length + + (allDesiredRecords.length - desiredRecords.length); AppLogger.debug( '$_logTag desiredRecords=${desiredRecords.length} ' 'skippedSchedules=$skippedScheduleCount ' @@ -306,7 +295,6 @@ class ReconcileAlarmsUseCase { alarmCoverageEnd: alarmCoverageEnd, ); - await _postStatusBestEffort(deviceId, result); AppLogger.debug( '$_logTag complete status=${result.status} ' 'armed=${result.armedScheduleCount} skipped=${result.skippedScheduleCount} ' @@ -320,8 +308,10 @@ class ReconcileAlarmsUseCase { required DateTime now, required DateTime alarmCoverageEnd, required Duration alarmOffset, + required bool detailedNotificationContent, + required String currentTimeZoneId, }) { - return schedules + final records = schedules .where( (schedule) => _isDesired(schedule, now, alarmCoverageEnd, alarmOffset), @@ -331,9 +321,13 @@ class ReconcileAlarmsUseCase { schedule, alarmOffset: alarmOffset, provider: AlarmProvider.none, + detailedNotificationContent: detailedNotificationContent, + currentTimeZoneId: currentTimeZoneId, ), ) .toList(); + records.sort((a, b) => a.alarmTime.compareTo(b.alarmTime)); + return records; } bool _isDesired( @@ -605,48 +599,6 @@ class ReconcileAlarmsUseCase { ); } - Future _postStatusBestEffort( - String deviceId, - AlarmReconciliationResult result, - ) async { - try { - AppLogger.debug( - '$_logTag postAlarmStatus start deviceId=$deviceId ' - 'status=${result.status} armed=${result.armedScheduleCount}', - ); - await _alarmRepository.postAlarmStatus( - AlarmStatusReport( - deviceId: deviceId, - reconciledAt: _nowProvider(), - scheduleWindowStart: result.scheduleWindowStart, - scheduleWindowEnd: result.scheduleWindowEnd, - alarmCoverageStart: result.alarmCoverageStart, - alarmCoverageEnd: result.alarmCoverageEnd, - status: result.status, - permissionIssue: result.permissionIssue, - nativeAlarmProvider: result.nativeAlarmProvider, - fallbackProvider: result.fallbackProvider, - armedScheduleCount: result.armedScheduleCount, - armedScheduleIds: result.armedScheduleIds, - skippedScheduleCount: result.skippedScheduleCount, - failures: result.failures, - ), - ); - AppLogger.debug('$_logTag postAlarmStatus success'); - } on DeviceSessionNotActiveException { - AppLogger.debug( - '$_logTag postAlarmStatus device session inactive; signing out', - ); - final records = await _registryRepository.loadAll(); - await _cancelRecords(records); - await _registryRepository.deleteAll(); - await _userRepository?.signOut(); - } catch (_) { - // Status reports are diagnostic; scheduling result should still return. - AppLogger.debug('$_logTag postAlarmStatus failed; continuing'); - } - } - String _recordSummary(List records) { if (records.isEmpty) return '[]'; return records diff --git a/lib/domain/use-cases/schedule_analytics_tracker.dart b/lib/domain/use-cases/schedule_analytics_tracker.dart deleted file mode 100644 index 10abb063..00000000 --- a/lib/domain/use-cases/schedule_analytics_tracker.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:on_time_front/domain/entities/preparation_entity.dart'; -import 'package:on_time_front/domain/entities/schedule_entity.dart'; - -abstract interface class ScheduleAnalyticsTracker { - Future trackScheduleCreated({ - required ScheduleEntity schedule, - required PreparationEntity preparation, - }); -} diff --git a/lib/domain/use-cases/sign_out_use_case.dart b/lib/domain/use-cases/sign_out_use_case.dart deleted file mode 100644 index b37c7d47..00000000 --- a/lib/domain/use-cases/sign_out_use_case.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/domain/repositories/user_repository.dart'; -import 'package:on_time_front/domain/use-cases/cancel_all_alarms_use_case.dart'; - -@Injectable() -class SignOutUseCase { - final UserRepository _userRepository; - final CancelAllAlarmsUseCase _cancelAllAlarmsUseCase; - - SignOutUseCase(this._userRepository, this._cancelAllAlarmsUseCase); - - Future call() async { - await _cancelAllAlarmsUseCase(unregisterDevice: true); - return _userRepository.signOut(); - } -} diff --git a/lib/domain/use-cases/track_product_usage_event_use_case.dart b/lib/domain/use-cases/track_product_usage_event_use_case.dart deleted file mode 100644 index 4a1b74d0..00000000 --- a/lib/domain/use-cases/track_product_usage_event_use_case.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/core/logging/app_logger.dart'; -import 'package:on_time_front/core/services/product_analytics_service.dart'; -import 'package:on_time_front/domain/entities/product_usage_event.dart'; - -abstract interface class ProductUsageEventTracker { - Future track(ProductUsageEvent event); -} - -@Injectable(as: ProductUsageEventTracker) -class TrackProductUsageEventUseCase implements ProductUsageEventTracker { - TrackProductUsageEventUseCase(this._analyticsService); - - final ProductAnalyticsService _analyticsService; - - @override - Future track(ProductUsageEvent event) async { - try { - await _analyticsService.track(event); - } catch (error) { - AppLogger.debug( - '[Analytics] track failed event=${event.name} ' - 'errorType=${error.runtimeType}', - ); - } - } -} diff --git a/lib/domain/use-cases/track_schedule_analytics_use_case.dart b/lib/domain/use-cases/track_schedule_analytics_use_case.dart deleted file mode 100644 index 2d3b2f7f..00000000 --- a/lib/domain/use-cases/track_schedule_analytics_use_case.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/domain/entities/preparation_entity.dart'; -import 'package:on_time_front/domain/entities/product_usage_event.dart'; -import 'package:on_time_front/domain/entities/schedule_entity.dart'; -import 'package:on_time_front/domain/use-cases/schedule_analytics_tracker.dart'; -import 'package:on_time_front/domain/use-cases/track_product_usage_event_use_case.dart'; - -@Injectable(as: ScheduleAnalyticsTracker) -class TrackScheduleAnalyticsUseCase implements ScheduleAnalyticsTracker { - final ProductUsageEventTracker _productUsageEventTracker; - final DateTime Function() _now; - - TrackScheduleAnalyticsUseCase(this._productUsageEventTracker) - : _now = DateTime.now; - - TrackScheduleAnalyticsUseCase.withClock( - this._productUsageEventTracker, { - required DateTime Function() now, - }) : _now = now; - - @override - Future trackScheduleCreated({ - required ScheduleEntity schedule, - required PreparationEntity preparation, - }) async { - await _productUsageEventTracker.track( - ProductUsageEvent.scheduleCreated( - preparationMode: schedule.preparationMode, - preparationStepCount: preparation.preparationStepList.length, - minutesUntilSchedule: schedule.scheduleTime - .difference(_now()) - .inMinutes, - ), - ); - } -} diff --git a/lib/domain/use-cases/update_analytics_preference_use_case.dart b/lib/domain/use-cases/update_analytics_preference_use_case.dart deleted file mode 100644 index a42e8c1c..00000000 --- a/lib/domain/use-cases/update_analytics_preference_use_case.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/domain/entities/analytics_preference.dart'; -import 'package:on_time_front/domain/repositories/analytics_preference_repository.dart'; - -@Injectable() -class UpdateAnalyticsPreferenceUseCase { - UpdateAnalyticsPreferenceUseCase(this._repository); - - final AnalyticsPreferenceRepository _repository; - - Future call({ - required bool enabled, - required bool signedIn, - }) async { - if (!signedIn) { - await _repository.saveLocalPreference(enabled); - return AnalyticsPreference(enabled: enabled); - } - - final accountPreference = await _repository.updateAccountPreference( - enabled, - ); - await _repository.saveLocalPreference(accountPreference.enabled); - return accountPreference; - } -} diff --git a/lib/domain/use-cases/update_preparation_by_schedule_id_use_case.dart b/lib/domain/use-cases/update_preparation_by_schedule_id_use_case.dart index da7b501f..a1c1bf6a 100644 --- a/lib/domain/use-cases/update_preparation_by_schedule_id_use_case.dart +++ b/lib/domain/use-cases/update_preparation_by_schedule_id_use_case.dart @@ -9,8 +9,12 @@ class UpdatePreparationByScheduleIdUseCase { UpdatePreparationByScheduleIdUseCase(this._preparationRepository); Future call( - PreparationEntity preparationEntity, String scheduleId) async { + PreparationEntity preparationEntity, + String scheduleId, + ) async { await _preparationRepository.updatePreparationByScheduleId( - preparationEntity, scheduleId); + preparationEntity, + scheduleId, + ); } } diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart deleted file mode 100644 index 345f94bd..00000000 --- a/lib/firebase_options.dart +++ /dev/null @@ -1,76 +0,0 @@ -// File generated by FlutterFire CLI. -// ignore_for_file: type=lint -import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; -import 'package:flutter/foundation.dart' - show defaultTargetPlatform, kIsWeb, TargetPlatform; - -/// Default [FirebaseOptions] for use with your Firebase apps. -/// -/// Example: -/// ```dart -/// import 'firebase_options.dart'; -/// // ... -/// await Firebase.initializeApp( -/// options: DefaultFirebaseOptions.currentPlatform, -/// ); -/// ``` -class DefaultFirebaseOptions { - static FirebaseOptions get currentPlatform { - if (kIsWeb) { - return web; - } - switch (defaultTargetPlatform) { - case TargetPlatform.android: - return android; - case TargetPlatform.iOS: - return ios; - case TargetPlatform.macOS: - throw UnsupportedError( - 'DefaultFirebaseOptions have not been configured for macos - ' - 'you can reconfigure this by running the FlutterFire CLI again.', - ); - case TargetPlatform.windows: - throw UnsupportedError( - 'DefaultFirebaseOptions have not been configured for windows - ' - 'you can reconfigure this by running the FlutterFire CLI again.', - ); - case TargetPlatform.linux: - throw UnsupportedError( - 'DefaultFirebaseOptions have not been configured for linux - ' - 'you can reconfigure this by running the FlutterFire CLI again.', - ); - default: - throw UnsupportedError( - 'DefaultFirebaseOptions are not supported for this platform.', - ); - } - } - - static const FirebaseOptions web = FirebaseOptions( - apiKey: 'AIzaSyB61_R9KigUpSsriTYFzYCPVVjDRJs8mFU', - appId: '1:456571312261:web:1d7c24d90acdc27d7e71ec', - messagingSenderId: '456571312261', - projectId: 'ontime-c63f1', - authDomain: 'ontime-c63f1.firebaseapp.com', - storageBucket: 'ontime-c63f1.firebasestorage.app', - measurementId: 'G-4TNCHRK7KR', - ); - - static const FirebaseOptions android = FirebaseOptions( - apiKey: 'AIzaSyBidmimBkVLWxS9r8O-e4vRuqDs7Lyijqk', - appId: '1:456571312261:android:b3574e6f89d21a467e71ec', - messagingSenderId: '456571312261', - projectId: 'ontime-c63f1', - storageBucket: 'ontime-c63f1.firebasestorage.app', - ); - - static const FirebaseOptions ios = FirebaseOptions( - apiKey: 'AIzaSyD9sTpL3rDqyuP8o7OQfVHJVcOgEyGuwRs', - appId: '1:456571312261:ios:0224dcabd68996867e71ec', - messagingSenderId: '456571312261', - projectId: 'ontime-c63f1', - storageBucket: 'ontime-c63f1.firebasestorage.app', - iosClientId: '456571312261-e0g33a9qnct35j1uud89dmfcnv9lffeq.apps.googleusercontent.com', - iosBundleId: 'club.devkor.ontime.ios', - ); -} diff --git a/lib/main.dart b/lib/main.dart index bf4c7c16..31075709 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,14 +1,12 @@ -import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/core/database/local_data_lifecycle.dart'; import 'package:on_time_front/core/logging/app_logger.dart'; import 'package:on_time_front/core/services/device_info_service/shared.dart'; import 'package:on_time_front/core/services/notification_service.dart'; import 'package:on_time_front/core/services/notification_tap_router.dart'; -import 'package:on_time_front/core/services/notification_token_registrar.dart'; -import 'package:on_time_front/firebase_options.dart'; import 'package:on_time_front/presentation/app/screens/app.dart'; void main() async { @@ -16,11 +14,9 @@ void main() async { AppLogger.configureFlutterDebugPrint(); await HardwareKeyboard.instance.syncKeyboardState().catchError((_) {}); await initializeDateFormatting(); - await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); - AppLogger.debug('[FCM Main] Firebase initialized'); + await LocalDataLifecycle.bootstrap(); configureDependencies(); - NotificationService.instance.configureDelegates( - fcmTokenRegistrar: getIt.get(), + NotificationService.instance.configureDelegate( notificationTapRouter: getIt.get(), ); diff --git a/lib/presentation/alarm/components/alarm_graph_animator.dart b/lib/presentation/alarm/components/alarm_graph_animator.dart index 6ae90b6a..e0b2f57c 100644 --- a/lib/presentation/alarm/components/alarm_graph_animator.dart +++ b/lib/presentation/alarm/components/alarm_graph_animator.dart @@ -34,10 +34,7 @@ class _AlarmGraphAnimatorState extends State _progressAnimation = Tween( begin: 0.0, end: 0.0, - ).animate(CurvedAnimation( - parent: _controller, - curve: Curves.easeOut, - )); + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); } @override @@ -58,10 +55,7 @@ class _AlarmGraphAnimatorState extends State _progressAnimation = Tween( begin: previousProgress, end: newProgress, - ).animate(CurvedAnimation( - parent: _controller, - curve: Curves.easeOut, - )); + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); _controller.forward(from: 0); previousProgress = newProgress; diff --git a/lib/presentation/alarm/components/alarm_graph_component.dart b/lib/presentation/alarm/components/alarm_graph_component.dart index 4cde465d..988cbed7 100644 --- a/lib/presentation/alarm/components/alarm_graph_component.dart +++ b/lib/presentation/alarm/components/alarm_graph_component.dart @@ -38,24 +38,12 @@ class AlarmGraphComponent extends CustomPainter { ..strokeCap = StrokeCap.round; // 그래프 배경 호 - canvas.drawArc( - rect, - startAngle, - sweepAngle, - false, - backgroundPaint, - ); + canvas.drawArc(rect, startAngle, sweepAngle, false, backgroundPaint); final double currentSweep = sweepAngle * (1.0 - progress); // 그래프 채워진 호 - canvas.drawArc( - rect, - startAngle, - currentSweep, - false, - progressPaint, - ); + canvas.drawArc(rect, startAngle, currentSweep, false, progressPaint); } @override diff --git a/lib/presentation/alarm/components/alarm_screen_top_section.dart b/lib/presentation/alarm/components/alarm_screen_top_section.dart index 6ab2e914..e539c433 100644 --- a/lib/presentation/alarm/components/alarm_screen_top_section.dart +++ b/lib/presentation/alarm/components/alarm_screen_top_section.dart @@ -26,10 +26,7 @@ class AlarmScreenTopSection extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; return Column( children: [ - _BeforeOutTimeText( - isLate: isLate, - beforeOutTime: beforeOutTime, - ), + _BeforeOutTimeText(isLate: isLate, beforeOutTime: beforeOutTime), _AlarmGraphSection( preparationName: preparationName, showPreparationName: showPreparationName, @@ -50,10 +47,7 @@ class _BeforeOutTimeText extends StatelessWidget { final bool isLate; final int beforeOutTime; - const _BeforeOutTimeText({ - required this.isLate, - required this.beforeOutTime, - }); + const _BeforeOutTimeText({required this.isLate, required this.beforeOutTime}); @override Widget build(BuildContext context) { diff --git a/lib/presentation/alarm/components/preparation_step_list_widget.dart b/lib/presentation/alarm/components/preparation_step_list_widget.dart index 5b4de2cb..db94d7d5 100644 --- a/lib/presentation/alarm/components/preparation_step_list_widget.dart +++ b/lib/presentation/alarm/components/preparation_step_list_widget.dart @@ -51,7 +51,8 @@ class _PreparationStepListWidgetState extends State { if (key?.currentContext != null) { final RenderBox box = key!.currentContext!.findRenderObject() as RenderBox; - final double targetOffset = box.localToGlobal(Offset.zero).dy + + final double targetOffset = + box.localToGlobal(Offset.zero).dy + _scrollController.offset - (MediaQuery.of(context).size.height / 2) + (box.size.height / 2) - @@ -81,8 +82,9 @@ class _PreparationStepListWidgetState extends State { key: _tileKeys[index], stepIndex: index + 1, preparationName: preparation.preparationName, - preparationTime: - formatTime(preparation.preparationTime.inSeconds), + preparationTime: formatTime( + preparation.preparationTime.inSeconds, + ), isLastItem: index == widget.preparationSteps.length - 1, stepElapsedTime: widget.stepElapsedTimes[index], preparationStepState: widget.preparationStepStates[index], diff --git a/lib/presentation/alarm/components/preparation_step_tile.dart b/lib/presentation/alarm/components/preparation_step_tile.dart index dd494f40..41c824bc 100644 --- a/lib/presentation/alarm/components/preparation_step_tile.dart +++ b/lib/presentation/alarm/components/preparation_step_tile.dart @@ -81,7 +81,8 @@ class PreparationStepTile extends StatelessWidget { curve: Curves.ease, child: Container( width: 358, - height: (preparationStepState == PreparationStateEnum.now && + height: + (preparationStepState == PreparationStateEnum.now && skipButton != null) ? 135 : 62, @@ -136,7 +137,7 @@ class PreparationStepTile extends StatelessWidget { if (skipButton != null) ...[ const SizedBox(height: 20), skipButton, - ] + ], ], ), ), diff --git a/lib/presentation/alarm/screens/alarm_screen.dart b/lib/presentation/alarm/screens/alarm_screen.dart index 2a6e439b..da9b987b 100644 --- a/lib/presentation/alarm/screens/alarm_screen.dart +++ b/lib/presentation/alarm/screens/alarm_screen.dart @@ -14,10 +14,7 @@ import 'package:on_time_front/presentation/shared/router/route_arguments.dart'; import 'package:on_time_front/presentation/shared/utils/time_format.dart'; class AlarmScreen extends StatefulWidget { - const AlarmScreen({ - super.key, - this.nowProvider = DateTime.now, - }); + const AlarmScreen({super.key, this.nowProvider = DateTime.now}); final DateTime Function() nowProvider; @@ -79,9 +76,13 @@ class _AlarmScreenState extends State { } void _onPreparationFinished( - BuildContext context, Duration timeRemainingBeforeLeaving, bool isLate) { - final latenessMinutes = - isLate ? (timeRemainingBeforeLeaving.inMinutes.abs()) : 0; + BuildContext context, + Duration timeRemainingBeforeLeaving, + bool isLate, + ) { + final latenessMinutes = isLate + ? (timeRemainingBeforeLeaving.inMinutes.abs()) + : 0; _pendingEarlyLateSeconds = timeRemainingBeforeLeaving.inSeconds; _pendingIsLate = isLate; _navigateAfterFinish = true; @@ -138,10 +139,7 @@ class _AlarmScreenState extends State { earlyLateTime: earlyLateSeconds, isLate: isLate, ), - extra: { - 'earlyLateTime': earlyLateSeconds, - 'isLate': isLate, - }, + extra: {'earlyLateTime': earlyLateSeconds, 'isLate': isLate}, ); return; } @@ -194,10 +192,9 @@ class _AlarmScreenState extends State { } _ensureUiTicker( - preparation.isAllStepsDone && _isContinuingAfterCompletion); - return _buildAlarmScreen( - schedule: schedule, + preparation.isAllStepsDone && _isContinuingAfterCompletion, ); + return _buildAlarmScreen(schedule: schedule); } else if (scheduleState.status == ScheduleStatus.upcoming && scheduleState.schedule != null) { _completionScheduleId = scheduleState.schedule!.id; @@ -231,9 +228,7 @@ class _AlarmScreenState extends State { ); } - Widget _buildAlarmScreen({ - required ScheduleWithPreparationEntity schedule, - }) { + Widget _buildAlarmScreen({required ScheduleWithPreparationEntity schedule}) { final timeRemainingBeforeLeaving = _timeRemainingBeforeLeaving(schedule); final isLate = timeRemainingBeforeLeaving.isNegative; final preparation = schedule.preparation; @@ -245,8 +240,8 @@ class _AlarmScreenState extends State { final timerLabel = isLateContinueMode ? '지각이에요' : isReadyContinueMode - ? l10n.preparationReadyToGo - : preparation.currentStepName; + ? l10n.preparationReadyToGo + : preparation.currentStepName; final displayProgress = isLateContinueMode ? 0.0 : preparation.progress; final displayRemainingSeconds = isContinuingAfterCompletion ? timeRemainingBeforeLeaving.inSeconds.abs() @@ -282,9 +277,9 @@ class _AlarmScreenState extends State { child: AlarmScreenBottomSection( preparation: preparation, onSkip: () { - context - .read() - .add(const ScheduleStepSkipped()); + context.read().add( + const ScheduleStepSkipped(), + ); }, onEndPreparation: () => _onPreparationFinished( context, @@ -368,9 +363,9 @@ class _AlarmScreenState extends State { height: 57, child: ElevatedButton( onPressed: () { - context - .read() - .add(const SchedulePreparationStarted()); + context.read().add( + const SchedulePreparationStarted(), + ); }, child: Text(l10n.startPreparing), ), @@ -382,8 +377,9 @@ class _AlarmScreenState extends State { child: ElevatedButton( onPressed: () => context.go('/home'), style: ElevatedButton.styleFrom( - backgroundColor: - Theme.of(context).colorScheme.primaryContainer, + backgroundColor: Theme.of( + context, + ).colorScheme.primaryContainer, foregroundColor: Theme.of(context).colorScheme.primary, ), child: Text(l10n.home), diff --git a/lib/presentation/alarm/screens/schedule_start_screen.dart b/lib/presentation/alarm/screens/schedule_start_screen.dart index 828fcdb2..84d9f359 100644 --- a/lib/presentation/alarm/screens/schedule_start_screen.dart +++ b/lib/presentation/alarm/screens/schedule_start_screen.dart @@ -12,16 +12,9 @@ import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; import 'package:on_time_front/presentation/shared/router/route_arguments.dart'; import 'package:on_time_front/presentation/shared/utils/duration_format.dart'; -enum ScheduleStartPromptVariant { - officialStart, - earlyStart, - alarm, -} +enum ScheduleStartPromptVariant { officialStart, earlyStart, alarm } -enum ScheduleStartLaunchAction { - prompt, - startPreparation, -} +enum ScheduleStartLaunchAction { prompt, startPreparation } ScheduleStartPromptVariant scheduleStartPromptVariantFromRouteValue( String? value, @@ -140,8 +133,9 @@ class _ScheduleStartScreenState extends State { return l10n.preparationStartsLaterStartEarly; } - final remainingLeadTime = - schedule.preparationStartTime.difference(DateTime.now()); + final remainingLeadTime = schedule.preparationStartTime.difference( + DateTime.now(), + ); if (remainingLeadTime.inMinutes <= 0) { return l10n.preparationStartsLaterStartEarly; } @@ -323,8 +317,9 @@ class _ScheduleStartScreenState extends State { context.go('/home'); }, style: ElevatedButton.styleFrom( - backgroundColor: - Theme.of(context).colorScheme.primaryContainer, + backgroundColor: Theme.of( + context, + ).colorScheme.primaryContainer, foregroundColor: Theme.of(context).colorScheme.primary, ), child: Text(AppLocalizations.of(context)!.notNow), diff --git a/lib/presentation/app/bloc/auth/auth_bloc.dart b/lib/presentation/app/bloc/auth/auth_bloc.dart index e5b91bd2..267e40d8 100644 --- a/lib/presentation/app/bloc/auth/auth_bloc.dart +++ b/lib/presentation/app/bloc/auth/auth_bloc.dart @@ -6,7 +6,6 @@ import 'package:injectable/injectable.dart'; import 'package:on_time_front/domain/entities/user_entity.dart'; import 'package:on_time_front/domain/use-cases/load_user_use_case.dart'; import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; -import 'package:on_time_front/domain/use-cases/sign_out_use_case.dart'; import 'package:on_time_front/domain/use-cases/stream_user_use_case.dart'; import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; @@ -15,16 +14,17 @@ part 'auth_state.dart'; @Injectable() class AuthBloc extends Bloc { - AuthBloc(this._streamUserUseCase, this._signOutUseCase, this._loadUserUseCase, - this._scheduleBloc, this._reconcileAlarmsUseCase) - : super(const AuthState.loading()) { + AuthBloc( + this._streamUserUseCase, + this._loadUserUseCase, + this._scheduleBloc, + this._reconcileAlarmsUseCase, + ) : super(const AuthState.loading()) { on(_appUserSubscriptionRequested); - on(_appLogoutPressed); } final StreamUserUseCase _streamUserUseCase; final LoadUserUseCase _loadUserUseCase; - final SignOutUseCase _signOutUseCase; final ScheduleBloc _scheduleBloc; final ReconcileAlarmsUseCase _reconcileAlarmsUseCase; Timer? _timer; @@ -37,7 +37,8 @@ class AuthBloc extends Bloc { await _loadUserUseCase(); } catch (error, stackTrace) { addError(error, stackTrace); - emit(AuthState(user: const UserEntity.empty())); + emit(const AuthState.recovery()); + return; } return emit.onEach( @@ -50,7 +51,7 @@ class AuthBloc extends Bloc { (entity) => entity.isOnboardingCompleted ? AuthStatus.authenticated : AuthStatus.onboardingNotCompleted, - empty: (_) => AuthStatus.unauthenticated, + empty: (_) => AuthStatus.onboardingNotCompleted, ), ), ); @@ -64,13 +65,6 @@ class AuthBloc extends Bloc { ); } - void _appLogoutPressed( - AuthSignOutPressed event, - Emitter emit, - ) { - _signOutUseCase(); - } - @override Future close() { _timer?.cancel(); diff --git a/lib/presentation/app/bloc/auth/auth_event.dart b/lib/presentation/app/bloc/auth/auth_event.dart index 7f1fae10..fcd846d1 100644 --- a/lib/presentation/app/bloc/auth/auth_event.dart +++ b/lib/presentation/app/bloc/auth/auth_event.dart @@ -7,7 +7,3 @@ abstract class AuthEvent { final class AuthUserSubscriptionRequested extends AuthEvent { const AuthUserSubscriptionRequested(); } - -final class AuthSignOutPressed extends AuthEvent { - const AuthSignOutPressed(); -} diff --git a/lib/presentation/app/bloc/auth/auth_state.dart b/lib/presentation/app/bloc/auth/auth_state.dart index 82f3f878..d8e9b3f4 100644 --- a/lib/presentation/app/bloc/auth/auth_state.dart +++ b/lib/presentation/app/bloc/auth/auth_state.dart @@ -1,29 +1,24 @@ part of 'auth_bloc.dart'; -enum AuthStatus { - loading, - authenticated, - unauthenticated, - onboardingNotCompleted, -} +enum AuthStatus { loading, authenticated, onboardingNotCompleted, recovery } class AuthState extends Equatable { AuthState({UserEntity user = const UserEntity.empty()}) - : this._( - status: user.map( - (entity) => entity.isOnboardingCompleted - ? AuthStatus.authenticated - : AuthStatus.onboardingNotCompleted, - empty: (_) => AuthStatus.unauthenticated, - ), - user: user, - ); + : this._( + status: user.map( + (entity) => entity.isOnboardingCompleted + ? AuthStatus.authenticated + : AuthStatus.onboardingNotCompleted, + empty: (_) => AuthStatus.onboardingNotCompleted, + ), + user: user, + ); const AuthState.loading() - : this._( - status: AuthStatus.loading, - user: const UserEntity.empty(), - ); + : this._(status: AuthStatus.loading, user: const UserEntity.empty()); + + const AuthState.recovery() + : this._(status: AuthStatus.recovery, user: const UserEntity.empty()); const AuthState._({ required this.status, @@ -33,14 +28,8 @@ class AuthState extends Equatable { final AuthStatus status; final UserEntity user; - AuthState copyWith({ - AuthStatus? status, - UserEntity? user, - }) { - return AuthState._( - status: status ?? this.status, - user: user ?? this.user, - ); + AuthState copyWith({AuthStatus? status, UserEntity? user}) { + return AuthState._(status: status ?? this.status, user: user ?? this.user); } @override diff --git a/lib/presentation/app/bloc/schedule/schedule_bloc.dart b/lib/presentation/app/bloc/schedule/schedule_bloc.dart index 235ff003..10160687 100644 --- a/lib/presentation/app/bloc/schedule/schedule_bloc.dart +++ b/lib/presentation/app/bloc/schedule/schedule_bloc.dart @@ -180,7 +180,7 @@ class ScheduleBloc extends Bloc { ), ); } - await _startScheduleOnServer(resolvedSchedule.id); + await _startScheduleLocally(resolvedSchedule.id); if (isClosed) return; emit(ScheduleState.started(resolvedSchedule, isEarlyStarted: true)); await _saveTimedPreparationSnapshot(resolvedSchedule, force: true); @@ -199,7 +199,7 @@ class ScheduleBloc extends Bloc { } if (_isPreparationOnGoing(resolvedSchedule, now)) { - await _startScheduleOnServer(resolvedSchedule.id); + await _startScheduleLocally(resolvedSchedule.id); if (isClosed) return; emit(ScheduleState.ongoing(resolvedSchedule)); AppLogger.debug( @@ -223,7 +223,7 @@ class ScheduleBloc extends Bloc { if (state.schedule != null && state.schedule!.id == _currentScheduleId) { if (_activeEarlyStartScheduleId == _currentScheduleId) return; AppLogger.debug('schedule started scheduleId=${state.schedule!.id}'); - await _startScheduleOnServer(state.schedule!.id); + await _startScheduleLocally(state.schedule!.id); if (isClosed) return; emit(ScheduleState.started(state.schedule!)); _initializeNotificationTracking(state.schedule!); @@ -468,7 +468,7 @@ class ScheduleBloc extends Bloc { } } - Future _startScheduleOnServer(String scheduleId) async { + Future _startScheduleLocally(String scheduleId) async { await _schedulePreparationSessionUseCase.startSchedulePreparation( scheduleId, ); diff --git a/lib/presentation/app/bloc/schedule/schedule_state.dart b/lib/presentation/app/bloc/schedule/schedule_state.dart index ce3a13d6..3472ca57 100644 --- a/lib/presentation/app/bloc/schedule/schedule_state.dart +++ b/lib/presentation/app/bloc/schedule/schedule_state.dart @@ -1,12 +1,6 @@ part of 'schedule_bloc.dart'; -enum ScheduleStatus { - initial, - notExists, - upcoming, - ongoing, - started, -} +enum ScheduleStatus { initial, notExists, upcoming, ongoing, started } class ScheduleState extends Equatable { const ScheduleState._({ @@ -20,19 +14,19 @@ class ScheduleState extends Equatable { const ScheduleState.notExists() : this._(status: ScheduleStatus.notExists); const ScheduleState.upcoming(ScheduleWithPreparationEntity schedule) - : this._(status: ScheduleStatus.upcoming, schedule: schedule); + : this._(status: ScheduleStatus.upcoming, schedule: schedule); const ScheduleState.ongoing(ScheduleWithPreparationEntity schedule) - : this._(status: ScheduleStatus.ongoing, schedule: schedule); + : this._(status: ScheduleStatus.ongoing, schedule: schedule); const ScheduleState.started( ScheduleWithPreparationEntity schedule, { bool isEarlyStarted = false, }) : this._( - status: ScheduleStatus.started, - schedule: schedule, - isEarlyStarted: isEarlyStarted, - ); + status: ScheduleStatus.started, + schedule: schedule, + isEarlyStarted: isEarlyStarted, + ); final ScheduleStatus status; final ScheduleWithPreparationEntity? schedule; @@ -63,9 +57,9 @@ class ScheduleState extends Equatable { @override List get props => [ - status, - schedule, - schedule?.preparation, - isEarlyStarted, - ]; + status, + schedule, + schedule?.preparation, + isEarlyStarted, + ]; } diff --git a/lib/presentation/app/cubit/analytics_preference_cubit.dart b/lib/presentation/app/cubit/analytics_preference_cubit.dart deleted file mode 100644 index f332b91d..00000000 --- a/lib/presentation/app/cubit/analytics_preference_cubit.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/core/services/product_analytics_service.dart'; -import 'package:on_time_front/domain/entities/analytics_preference.dart'; -import 'package:on_time_front/domain/use-cases/load_analytics_preference_use_case.dart'; -import 'package:on_time_front/domain/use-cases/update_analytics_preference_use_case.dart'; - -part 'analytics_preference_state.dart'; - -@Injectable() -class AnalyticsPreferenceCubit extends Cubit { - AnalyticsPreferenceCubit({ - required LoadAnalyticsPreferenceUseCase loadPreferenceUseCase, - required UpdateAnalyticsPreferenceUseCase updatePreferenceUseCase, - required ProductAnalyticsService analyticsService, - }) : _loadPreferenceUseCase = loadPreferenceUseCase, - _updatePreferenceUseCase = updatePreferenceUseCase, - _analyticsService = analyticsService, - super(const AnalyticsPreferenceState.initial()); - - final LoadAnalyticsPreferenceUseCase _loadPreferenceUseCase; - final UpdateAnalyticsPreferenceUseCase _updatePreferenceUseCase; - final ProductAnalyticsService _analyticsService; - - Future load({required bool signedIn}) async { - emit(state.copyWith(status: AnalyticsPreferenceStatus.loading)); - final preference = await _loadPreferenceUseCase(signedIn: signedIn); - if (!preference.isConfirmed) { - await _analyticsService.applyPreference(preference); - emit( - AnalyticsPreferenceState.failure( - enabled: preference.enabled, - isConfirmed: false, - ), - ); - return; - } - await _analyticsService.applyPreference(preference); - emit( - AnalyticsPreferenceState.loaded( - enabled: preference.enabled, - isConfirmed: true, - ), - ); - } - - Future update({ - required bool enabled, - required bool signedIn, - }) async { - final previous = state; - emit(state.copyWith(status: AnalyticsPreferenceStatus.updating)); - try { - final preference = await _updatePreferenceUseCase( - enabled: enabled, - signedIn: signedIn, - ); - await _analyticsService.applyPreference(preference); - emit( - AnalyticsPreferenceState.loaded( - enabled: preference.enabled, - isConfirmed: true, - ), - ); - } catch (_) { - await _analyticsService.applyPreference( - AnalyticsPreference( - enabled: previous.enabled, - isConfirmed: previous.isConfirmed, - ), - ); - emit(previous.copyWith(status: AnalyticsPreferenceStatus.failure)); - } - } -} diff --git a/lib/presentation/app/cubit/analytics_preference_state.dart b/lib/presentation/app/cubit/analytics_preference_state.dart deleted file mode 100644 index b61553dc..00000000 --- a/lib/presentation/app/cubit/analytics_preference_state.dart +++ /dev/null @@ -1,64 +0,0 @@ -part of 'analytics_preference_cubit.dart'; - -enum AnalyticsPreferenceStatus { - initial, - loading, - loaded, - updating, - failure, -} - -class AnalyticsPreferenceState extends Equatable { - const AnalyticsPreferenceState._({ - required this.status, - required this.enabled, - required this.isConfirmed, - }); - - const AnalyticsPreferenceState.initial() - : this._( - status: AnalyticsPreferenceStatus.initial, - enabled: false, - isConfirmed: false, - ); - - const AnalyticsPreferenceState.loaded({ - required bool enabled, - required bool isConfirmed, - }) : this._( - status: AnalyticsPreferenceStatus.loaded, - enabled: enabled, - isConfirmed: isConfirmed, - ); - - const AnalyticsPreferenceState.failure({ - required bool enabled, - required bool isConfirmed, - }) : this._( - status: AnalyticsPreferenceStatus.failure, - enabled: enabled, - isConfirmed: isConfirmed, - ); - - final AnalyticsPreferenceStatus status; - final bool enabled; - final bool isConfirmed; - - bool get canEmitEvents => - status == AnalyticsPreferenceStatus.loaded && isConfirmed && enabled; - - AnalyticsPreferenceState copyWith({ - AnalyticsPreferenceStatus? status, - bool? enabled, - bool? isConfirmed, - }) { - return AnalyticsPreferenceState._( - status: status ?? this.status, - enabled: enabled ?? this.enabled, - isConfirmed: isConfirmed ?? this.isConfirmed, - ); - } - - @override - List get props => [status, enabled, isConfirmed]; -} diff --git a/lib/presentation/app/cubit/notification_gate_cubit.dart b/lib/presentation/app/cubit/notification_gate_cubit.dart index 864156d8..8f692cd2 100644 --- a/lib/presentation/app/cubit/notification_gate_cubit.dart +++ b/lib/presentation/app/cubit/notification_gate_cubit.dart @@ -1,5 +1,4 @@ import 'package:equatable/equatable.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:on_time_front/core/logging/app_logger.dart'; import 'package:on_time_front/core/services/notification_service.dart'; @@ -8,11 +7,10 @@ import 'package:shared_preferences/shared_preferences.dart'; part 'notification_gate_state.dart'; class NotificationGateCubit extends Cubit { - NotificationGateCubit({ - NotificationService? notificationService, - }) : _notificationService = - notificationService ?? NotificationService.instance, - super(const NotificationGateState.initial()) { + NotificationGateCubit({NotificationService? notificationService}) + : _notificationService = + notificationService ?? NotificationService.instance, + super(const NotificationGateState.initial()) { refreshPermission(); } diff --git a/lib/presentation/app/cubit/notification_gate_state.dart b/lib/presentation/app/cubit/notification_gate_state.dart index 0c99b9c1..de7f969c 100644 --- a/lib/presentation/app/cubit/notification_gate_state.dart +++ b/lib/presentation/app/cubit/notification_gate_state.dart @@ -1,28 +1,21 @@ part of 'notification_gate_cubit.dart'; -enum NotificationGateStatus { - initial, - allowed, - required, - dismissed, -} +enum NotificationGateStatus { initial, allowed, required, dismissed } class NotificationGateState extends Equatable { - const NotificationGateState._({ - required this.status, - }); + const NotificationGateState._({required this.status}); const NotificationGateState.initial() - : this._(status: NotificationGateStatus.initial); + : this._(status: NotificationGateStatus.initial); const NotificationGateState.allowed() - : this._(status: NotificationGateStatus.allowed); + : this._(status: NotificationGateStatus.allowed); const NotificationGateState.required() - : this._(status: NotificationGateStatus.required); + : this._(status: NotificationGateStatus.required); const NotificationGateState.dismissed() - : this._(status: NotificationGateStatus.dismissed); + : this._(status: NotificationGateStatus.dismissed); final NotificationGateStatus status; diff --git a/lib/presentation/calendar/bloc/monthly_schedules_bloc.dart b/lib/presentation/calendar/bloc/monthly_schedules_bloc.dart index a5261aa5..1bc24fbf 100644 --- a/lib/presentation/calendar/bloc/monthly_schedules_bloc.dart +++ b/lib/presentation/calendar/bloc/monthly_schedules_bloc.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; -import 'package:on_time_front/core/dio/api_error_message.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/domain/use-cases/delete_schedule_use_case.dart'; @@ -159,13 +158,20 @@ class MonthlySchedulesBloc state.copyWith( lastDeletedSchedule: () => null, preparationDurationByScheduleId: () => previousPreparationMap, - deleteFailureMessage: () => ApiErrorMessage.fromException(e), + deleteFailureMessage: () => _readableErrorMessage(e), deleteFailureCount: () => state.deleteFailureCount + 1, ), ); } } + String _readableErrorMessage(Object error) { + if (error case StateError(message: final message)) { + return message; + } + return error.toString(); + } + Future _onRefreshRequested( MonthlySchedulesRefreshRequested event, Emitter emit, diff --git a/lib/presentation/calendar/screens/calendar_screen.dart b/lib/presentation/calendar/screens/calendar_screen.dart index 181a1180..b81eee6d 100644 --- a/lib/presentation/calendar/screens/calendar_screen.dart +++ b/lib/presentation/calendar/screens/calendar_screen.dart @@ -150,18 +150,15 @@ class _CalendarScreenState extends State { _refreshSchedulesIfSaved(saved); } - Future _showScheduleDeleteFailureDialog( - BuildContext context, - String? serverMessage, - ) { + Future _showScheduleDeleteFailureDialog(BuildContext context) { final l10n = AppLocalizations.of(context)!; return showTwoActionDialog( context, config: TwoActionDialogConfig( title: l10n.scheduleDeleteFailedTitle, - description: serverMessage?.trim().isNotEmpty == true - ? serverMessage!.trim() - : l10n.scheduleDeleteFailedDescription, + // Do not expose exception implementation details to the user. Local + // failures share one actionable, translated recovery message. + description: l10n.scheduleDeleteFailedDescription, primaryAction: DialogActionConfig(label: l10n.ok), ), ); @@ -225,12 +222,7 @@ class _CalendarScreenState extends State { listenWhen: (previous, current) => previous.deleteFailureCount != current.deleteFailureCount, listener: (context, state) { - unawaited( - _showScheduleDeleteFailureDialog( - context, - state.deleteFailureMessage, - ), - ); + unawaited(_showScheduleDeleteFailureDialog(context)); }, child: LayoutBuilder( builder: (context, constraints) { diff --git a/lib/presentation/early_late/bloc/early_late_screen_bloc.dart b/lib/presentation/early_late/bloc/early_late_screen_bloc.dart index 8cc066ea..f906275e 100644 --- a/lib/presentation/early_late/bloc/early_late_screen_bloc.dart +++ b/lib/presentation/early_late/bloc/early_late_screen_bloc.dart @@ -16,7 +16,9 @@ class EarlyLateScreenBloc } void _onLoadEarlyLateInfo( - LoadEarlyLateInfo event, Emitter emit) { + LoadEarlyLateInfo event, + Emitter emit, + ) { bool isLate = event.earlyLateTime < 0; int absSeconds = event.earlyLateTime.abs(); int minuteValue = (absSeconds / 60).ceil(); @@ -29,40 +31,50 @@ class EarlyLateScreenBloc messageData['message'] ?? (isLate ? '조금 늦었지만 괜찮아요!' : '준비를 잘 마쳤어요!'); final earlyLateImage = messageData['image'] ?? 'character.svg'; - emit(EarlyLateScreenLoadSuccess( - checklist: List.generate(3, (index) => false), - isLate: isLate, - earlylateMessage: earlyLateMessage, - earlylateImage: earlyLateImage, - )); + emit( + EarlyLateScreenLoadSuccess( + checklist: List.generate(3, (index) => false), + isLate: isLate, + earlylateMessage: earlyLateMessage, + earlylateImage: earlyLateImage, + ), + ); } void _onLoadChecklist( - ChecklistLoaded event, Emitter emit) { + ChecklistLoaded event, + Emitter emit, + ) { if (state is EarlyLateScreenLoadSuccess) { final currentState = state as EarlyLateScreenLoadSuccess; - emit(EarlyLateScreenLoadSuccess( - checklist: event.checklist, - isLate: currentState.isLate, - earlylateMessage: currentState.earlylateMessage, - earlylateImage: currentState.earlylateImage, - )); + emit( + EarlyLateScreenLoadSuccess( + checklist: event.checklist, + isLate: currentState.isLate, + earlylateMessage: currentState.earlylateMessage, + earlylateImage: currentState.earlylateImage, + ), + ); } } void _onToggleChecklistItem( - ChecklistItemToggled event, Emitter emit) { + ChecklistItemToggled event, + Emitter emit, + ) { if (state is EarlyLateScreenLoadSuccess) { final currentState = state as EarlyLateScreenLoadSuccess; final updatedChecklist = List.from(currentState.checklist); updatedChecklist[event.index] = !updatedChecklist[event.index]; - emit(EarlyLateScreenLoadSuccess( - checklist: updatedChecklist, - isLate: currentState.isLate, - earlylateMessage: currentState.earlylateMessage, - earlylateImage: currentState.earlylateImage, - )); + emit( + EarlyLateScreenLoadSuccess( + checklist: updatedChecklist, + isLate: currentState.isLate, + earlylateMessage: currentState.earlylateMessage, + earlylateImage: currentState.earlylateImage, + ), + ); } } } diff --git a/lib/presentation/early_late/bloc/early_late_screen_state.dart b/lib/presentation/early_late/bloc/early_late_screen_state.dart index 9d6cb42f..107120ab 100644 --- a/lib/presentation/early_late/bloc/early_late_screen_state.dart +++ b/lib/presentation/early_late/bloc/early_late_screen_state.dart @@ -24,9 +24,9 @@ class EarlyLateScreenLoadSuccess extends EarlyLateScreenState { @override List get props => [ - checklist, - isLate, - earlylateMessage, - earlylateImage, - ]; + checklist, + isLate, + earlylateMessage, + earlylateImage, + ]; } diff --git a/lib/presentation/early_late/components/check_list_box_widget.dart b/lib/presentation/early_late/components/check_list_box_widget.dart index 1a5c4b97..611967a3 100644 --- a/lib/presentation/early_late/components/check_list_box_widget.dart +++ b/lib/presentation/early_late/components/check_list_box_widget.dart @@ -57,10 +57,7 @@ class _TextSection extends StatelessWidget { Widget build(BuildContext context) { return const Text( '나가기 전에 확인하세요', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ); } } diff --git a/lib/presentation/early_late/components/check_list_item_widget.dart b/lib/presentation/early_late/components/check_list_item_widget.dart index 7c564af5..228be4f6 100644 --- a/lib/presentation/early_late/components/check_list_item_widget.dart +++ b/lib/presentation/early_late/components/check_list_item_widget.dart @@ -25,10 +25,7 @@ class ChecklistItemWidget extends StatelessWidget { height: 24, decoration: BoxDecoration( shape: BoxShape.rectangle, - border: Border.all( - color: const Color(0xff5C79FB), - width: 2, - ), + border: Border.all(color: const Color(0xff5C79FB), width: 2), borderRadius: const BorderRadius.all(Radius.circular(5)), color: isChecked ? const Color(0xff5C79FB) : Colors.transparent, ), @@ -43,8 +40,9 @@ class ChecklistItemWidget extends StatelessWidget { fontSize: 16, fontWeight: FontWeight.bold, color: isChecked ? const Color(0xff5C79FB) : Colors.black, - decoration: - isChecked ? TextDecoration.lineThrough : TextDecoration.none, + decoration: isChecked + ? TextDecoration.lineThrough + : TextDecoration.none, ), ), ], diff --git a/lib/presentation/early_late/screens/early_late_screen.dart b/lib/presentation/early_late/screens/early_late_screen.dart index f9a1c67b..9fb8b440 100644 --- a/lib/presentation/early_late/screens/early_late_screen.dart +++ b/lib/presentation/early_late/screens/early_late_screen.dart @@ -79,10 +79,7 @@ class _EarlyLateSection extends StatelessWidget { return Center( child: Column( children: [ - _EarlyLateText( - earlyLateTime: earlyLateTime, - isLate: isLate, - ), + _EarlyLateText(earlyLateTime: earlyLateTime, isLate: isLate), const SizedBox(height: 20), EarlyLateMessageImageWidget( screenHeight: screenHeight, @@ -99,15 +96,13 @@ class _EarlyLateText extends StatelessWidget { final int earlyLateTime; final bool isLate; - const _EarlyLateText({ - required this.earlyLateTime, - required this.isLate, - }); + const _EarlyLateText({required this.earlyLateTime, required this.isLate}); @override Widget build(BuildContext context) { - final textColor = - isLate ? const Color(0xffFF6953) : const Color(0xff5C79FB); + final textColor = isLate + ? const Color(0xffFF6953) + : const Color(0xff5C79FB); return Text.rich( TextSpan( children: [ diff --git a/lib/presentation/home/bloc/schedule_timer_bloc.dart b/lib/presentation/home/bloc/schedule_timer_bloc.dart index 3cec7714..6d318540 100644 --- a/lib/presentation/home/bloc/schedule_timer_bloc.dart +++ b/lib/presentation/home/bloc/schedule_timer_bloc.dart @@ -27,7 +27,9 @@ class ScheduleTimerBloc extends Bloc { } void _onTimerStarted( - ScheduleTimerStarted event, Emitter emit) { + ScheduleTimerStarted event, + Emitter emit, + ) { _scheduleTime = event.scheduleTime; _tickerSubscription?.cancel(); _initialTimer?.cancel(); @@ -41,11 +43,13 @@ class ScheduleTimerBloc extends Bloc { return; } - emit(ScheduleTimerRunning( - scheduleTime: event.scheduleTime, - currentTime: now, - remainingDuration: difference, - )); + emit( + ScheduleTimerRunning( + scheduleTime: event.scheduleTime, + currentTime: now, + remainingDuration: difference, + ), + ); // Calculate time until next minute boundary (when seconds = 0) final secondsUntilNextMinute = 60 - now.second; @@ -59,20 +63,23 @@ class ScheduleTimerBloc extends Bloc { add(ScheduleTimerTicked(DateTime.now())); // Now create a periodic timer that runs exactly every minute - _tickerSubscription = Stream.periodic( - const Duration(minutes: 1), - (_) => DateTime.now(), - ).listen((currentTime) { - // Check if bloc is still active before adding events - if (!isClosed) { - add(ScheduleTimerTicked(currentTime)); - } - }); + _tickerSubscription = + Stream.periodic( + const Duration(minutes: 1), + (_) => DateTime.now(), + ).listen((currentTime) { + // Check if bloc is still active before adding events + if (!isClosed) { + add(ScheduleTimerTicked(currentTime)); + } + }); }); } void _onTimerTicked( - ScheduleTimerTicked event, Emitter emit) { + ScheduleTimerTicked event, + Emitter emit, + ) { if (_scheduleTime == null) return; final difference = _scheduleTime!.difference(event.currentTime); @@ -81,16 +88,20 @@ class ScheduleTimerBloc extends Bloc { emit(ScheduleTimerFinished(scheduleTime: _scheduleTime!)); _tickerSubscription?.cancel(); } else { - emit(ScheduleTimerRunning( - scheduleTime: _scheduleTime!, - currentTime: event.currentTime, - remainingDuration: difference, - )); + emit( + ScheduleTimerRunning( + scheduleTime: _scheduleTime!, + currentTime: event.currentTime, + remainingDuration: difference, + ), + ); } } void _onTimerStopped( - ScheduleTimerStopped event, Emitter emit) { + ScheduleTimerStopped event, + Emitter emit, + ) { _tickerSubscription?.cancel(); _initialTimer?.cancel(); _scheduleTime = null; @@ -98,7 +109,9 @@ class ScheduleTimerBloc extends Bloc { } void _onTimerUpdated( - ScheduleTimerUpdated event, Emitter emit) { + ScheduleTimerUpdated event, + Emitter emit, + ) { if (event.scheduleTime == null) { _tickerSubscription?.cancel(); _initialTimer?.cancel(); diff --git a/lib/presentation/home/bloc/weekly_schedules_bloc.dart b/lib/presentation/home/bloc/weekly_schedules_bloc.dart index 47e67bb4..e8240924 100644 --- a/lib/presentation/home/bloc/weekly_schedules_bloc.dart +++ b/lib/presentation/home/bloc/weekly_schedules_bloc.dart @@ -16,29 +16,26 @@ class WeeklySchedulesBloc this._loadSchedulesForWeekUseCase, this._getSchedulesByDateUseCase, ) : super(WeeklySchedulesState()) { - on( - (event, emit) async { - emit(state.copyWith(status: () => WeeklySchedulesStatus.loading)); + on((event, emit) async { + emit(state.copyWith(status: () => WeeklySchedulesStatus.loading)); - try { - await _loadSchedulesForWeekUseCase(event.date); - } catch (_) { - emit(state.copyWith(status: () => WeeklySchedulesStatus.error)); - return; - } + try { + await _loadSchedulesForWeekUseCase(event.date); + } catch (_) { + emit(state.copyWith(status: () => WeeklySchedulesStatus.error)); + return; + } - await emit.forEach( - _getSchedulesByDateUseCase(event.startDate, event.endDate), - onData: (schedules) => state.copyWith( - status: () => WeeklySchedulesStatus.success, - schedules: () => schedules, - ), - onError: (error, stackTrace) => state.copyWith( - status: () => WeeklySchedulesStatus.error, - ), - ); - }, - ); + await emit.forEach( + _getSchedulesByDateUseCase(event.startDate, event.endDate), + onData: (schedules) => state.copyWith( + status: () => WeeklySchedulesStatus.success, + schedules: () => schedules, + ), + onError: (error, stackTrace) => + state.copyWith(status: () => WeeklySchedulesStatus.error), + ); + }); } final LoadSchedulesForWeekUseCase _loadSchedulesForWeekUseCase; diff --git a/lib/presentation/home/bloc/weekly_schedules_state.dart b/lib/presentation/home/bloc/weekly_schedules_state.dart index e577b3b4..d5c56d12 100644 --- a/lib/presentation/home/bloc/weekly_schedules_state.dart +++ b/lib/presentation/home/bloc/weekly_schedules_state.dart @@ -3,8 +3,10 @@ part of 'weekly_schedules_bloc.dart'; enum WeeklySchedulesStatus { initial, loading, success, error } final class WeeklySchedulesState extends Equatable { - const WeeklySchedulesState( - {this.status = WeeklySchedulesStatus.initial, this.schedules = const []}); + const WeeklySchedulesState({ + this.status = WeeklySchedulesStatus.initial, + this.schedules = const [], + }); final WeeklySchedulesStatus status; final List schedules; @@ -14,7 +16,7 @@ final class WeeklySchedulesState extends Equatable { ScheduleEntity? get todaySchedule => schedules .where((schedule) { if (schedule.doneStatus != ScheduleDoneStatus.notEnded) return false; - + final now = DateTime.now(); return schedule.scheduleTime.year == now.year && schedule.scheduleTime.month == now.month && @@ -34,8 +36,5 @@ final class WeeklySchedulesState extends Equatable { } @override - List get props => [ - status, - ...schedules, - ]; + List get props => [status, ...schedules]; } diff --git a/lib/presentation/home/components/home_app_bar.dart b/lib/presentation/home/components/home_app_bar.dart index e90ecbf9..57dee3be 100644 --- a/lib/presentation/home/components/home_app_bar.dart +++ b/lib/presentation/home/components/home_app_bar.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; import 'package:on_time_front/presentation/shared/constants/constants.dart'; class HomeAppBar extends StatelessWidget implements PreferredSizeWidget { @@ -30,19 +28,7 @@ class HomeAppBar extends StatelessWidget implements PreferredSizeWidget { Widget build(BuildContext context) { return AppBar( title: title, - actions: actions ?? - [ - IconButton( - icon: friendsSvg, - onPressed: () { - context.read().add(AuthSignOutPressed()); - }, - ), - IconButton( - icon: bellSvg, - onPressed: () {}, - ) - ], + actions: actions ?? [IconButton(icon: bellSvg, onPressed: () {})], backgroundColor: Colors.white, elevation: 0, centerTitle: true, diff --git a/lib/presentation/home/components/month_calendar.dart b/lib/presentation/home/components/month_calendar.dart index 646dd7a8..009cb74e 100644 --- a/lib/presentation/home/components/month_calendar.dart +++ b/lib/presentation/home/components/month_calendar.dart @@ -63,11 +63,10 @@ class _MonthCalendarState extends State { if (widget.dispatchBlocEvents) { context.read().add( - MonthlySchedulesMonthAdded( - date: - DateTime(clampedFocusedDay.year, clampedFocusedDay.month, 1), - ), - ); + MonthlySchedulesMonthAdded( + date: DateTime(clampedFocusedDay.year, clampedFocusedDay.month, 1), + ), + ); } } @@ -81,11 +80,10 @@ class _MonthCalendarState extends State { if (widget.dispatchBlocEvents) { context.read().add( - MonthlySchedulesMonthAdded( - date: - DateTime(clampedFocusedDay.year, clampedFocusedDay.month, 1), - ), - ); + MonthlySchedulesMonthAdded( + date: DateTime(clampedFocusedDay.year, clampedFocusedDay.month, 1), + ), + ); } } @@ -96,8 +94,9 @@ class _MonthCalendarState extends State { return LayoutBuilder( builder: (context, constraints) { - final resolvedPadding = - widget.contentPadding.resolve(Directionality.of(context)); + final resolvedPadding = widget.contentPadding.resolve( + Directionality.of(context), + ); final constrainedRowHeight = _constrainedRowHeight( maxHeight: constraints.maxHeight, verticalPadding: resolvedPadding.vertical, @@ -105,9 +104,7 @@ class _MonthCalendarState extends State { return Container( padding: widget.contentPadding, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(11), - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(11)), child: TableCalendar( locale: Localizations.localeOf(context).toString(), eventLoader: (day) { @@ -127,10 +124,16 @@ class _MonthCalendarState extends State { daysOfWeekHeight: widget.daysOfWeekHeight, calendarStyle: calendarTheme.calendarStyle, onDaySelected: (selectedDay, focusedDay) { - final clampedSelectedDay = - _clampDay(selectedDay, _firstDay, _lastDay); - final clampedFocusedDay = - _clampDay(focusedDay, _firstDay, _lastDay); + final clampedSelectedDay = _clampDay( + selectedDay, + _firstDay, + _lastDay, + ); + final clampedFocusedDay = _clampDay( + focusedDay, + _firstDay, + _lastDay, + ); setState(() { _selectedDay = clampedSelectedDay; @@ -140,8 +143,11 @@ class _MonthCalendarState extends State { widget.onDateSelected?.call(clampedSelectedDay); }, onPageChanged: (focusedDay) { - final clampedFocusedDay = - _clampDay(focusedDay, _firstDay, _lastDay); + final clampedFocusedDay = _clampDay( + focusedDay, + _firstDay, + _lastDay, + ); setState(() { _focusedDay = clampedFocusedDay; @@ -149,14 +155,14 @@ class _MonthCalendarState extends State { if (widget.dispatchBlocEvents) { context.read().add( - MonthlySchedulesMonthAdded( - date: DateTime( - clampedFocusedDay.year, - clampedFocusedDay.month, - 1, - ), - ), - ); + MonthlySchedulesMonthAdded( + date: DateTime( + clampedFocusedDay.year, + clampedFocusedDay.month, + 1, + ), + ), + ); } }, calendarBuilders: CalendarBuilders( diff --git a/lib/presentation/home/components/todays_schedule_tile.dart b/lib/presentation/home/components/todays_schedule_tile.dart index 5ac79cf1..b9caba31 100644 --- a/lib/presentation/home/components/todays_schedule_tile.dart +++ b/lib/presentation/home/components/todays_schedule_tile.dart @@ -52,10 +52,7 @@ class TodaysScheduleTile extends StatelessWidget { compact: compact, ), ), - VerticalDivider( - width: 1, - color: colorScheme.primary, - ), + VerticalDivider(width: 1, color: colorScheme.primary), Expanded( child: Padding( padding: EdgeInsets.symmetric( @@ -89,18 +86,16 @@ class TodaysScheduleTile extends StatelessWidget { width: double.infinity, constraints: BoxConstraints(minHeight: compact ? 48 : 54), alignment: schedule == null ? Alignment.centerLeft : null, - child: - schedule == null ? _noSchedule(context) : _scheduleExists(context), + child: schedule == null + ? _noSchedule(context) + : _scheduleExists(context), ), ); } } class _ScheduleDetailsColumn extends StatelessWidget { - const _ScheduleDetailsColumn({ - required this.schedule, - required this.compact, - }); + const _ScheduleDetailsColumn({required this.schedule, required this.compact}); final ScheduleEntity schedule; final bool compact; @@ -166,11 +161,7 @@ class _ScheduleLeftTimeColumn extends StatelessWidget { final hours = leftTime.inHours; final minutes = leftTime.inMinutes % 60; - return _TimeColumn( - hour: hours, - minute: minutes, - compact: compact, - ); + return _TimeColumn(hour: hours, minute: minutes, compact: compact); }, ), ); @@ -200,9 +191,7 @@ class _TimeColumn extends StatelessWidget { AppLocalizations.of(context)!.untilAppointment, style: (compact ? theme.textTheme.labelSmall : theme.textTheme.bodySmall) - ?.copyWith( - color: colorScheme.primary, - ), + ?.copyWith(color: colorScheme.primary), textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, @@ -210,12 +199,11 @@ class _TimeColumn extends StatelessWidget { 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, - ), + style: + (compact + ? theme.textTheme.labelLarge + : theme.textTheme.titleSmall) + ?.copyWith(color: colorScheme.primary), textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/presentation/home/components/week_calendar.dart b/lib/presentation/home/components/week_calendar.dart index fd44932b..c5f74c89 100644 --- a/lib/presentation/home/components/week_calendar.dart +++ b/lib/presentation/home/components/week_calendar.dart @@ -3,11 +3,12 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class WeekCalendar extends StatelessWidget with Diagnosticable { - const WeekCalendar( - {super.key, - required this.date, - required this.onDateSelected, - required this.highlightedDates}); + const WeekCalendar({ + super.key, + required this.date, + required this.onDateSelected, + required this.highlightedDates, + }); final DateTime date; final ValueChanged onDateSelected; @@ -22,25 +23,18 @@ class WeekCalendar extends StatelessWidget with Diagnosticable { if (date.month == DateTime.now().month && date.day == DateTime.now().day && date.year == DateTime.now().year) { - return DateTile.filled( - date: date, - onTap: () => onDateSelected(date), - ); + return DateTile.filled(date: date, onTap: () => onDateSelected(date)); } - if (highlightedDates.firstWhereOrNull((highlightedDate) => - highlightedDate.month == date.month && - highlightedDate.day == date.day && - highlightedDate.year == date.year) != + if (highlightedDates.firstWhereOrNull( + (highlightedDate) => + highlightedDate.month == date.month && + highlightedDate.day == date.day && + highlightedDate.year == date.year, + ) != null) { - return DateTile.outlined( - date: date, - onTap: () => onDateSelected(date), - ); + return DateTile.outlined(date: date, onTap: () => onDateSelected(date)); } - return DateTile( - date: date, - onTap: () => onDateSelected(date), - ); + return DateTile(date: date, onTap: () => onDateSelected(date)); } @override @@ -59,9 +53,7 @@ class WeekCalendar extends StatelessWidget with Diagnosticable { } class DateTileThemeData extends ThemeExtension { - const DateTileThemeData({ - this.style, - }); + const DateTileThemeData({this.style}); final DateTileStyle? style; @@ -78,14 +70,14 @@ class DateTileThemeData extends ThemeExtension { @override ThemeExtension copyWith() { - return DateTileThemeData( - style: style, - ); + return DateTileThemeData(style: style); } @override ThemeExtension lerp( - covariant ThemeExtension? other, double t) { + covariant ThemeExtension? other, + double t, + ) { if (other == null) return this; final otherData = other as DateTileThemeData; return DateTileThemeData( @@ -188,21 +180,38 @@ class DateTileStyle { } return DateTileStyle( textStyle: WidgetStateProperty.lerp( - a?.textStyle, b?.textStyle, t, TextStyle.lerp), + a?.textStyle, + b?.textStyle, + t, + TextStyle.lerp, + ), backgroundColor: WidgetStateProperty.lerp( - a?.backgroundColor, b?.backgroundColor, t, Color.lerp), + a?.backgroundColor, + b?.backgroundColor, + t, + Color.lerp, + ), forgroundColor: WidgetStateProperty.lerp( - a?.forgroundColor, b?.forgroundColor, t, Color.lerp), + a?.forgroundColor, + b?.forgroundColor, + t, + Color.lerp, + ), shape: WidgetStateProperty.lerp( - a?.shape, b?.shape, t, OutlinedBorder.lerp), + a?.shape, + b?.shape, + t, + OutlinedBorder.lerp, + ), side: _lerpSides(a?.side, b?.side, t), ); } static WidgetStateProperty? _lerpSides( - WidgetStateProperty? a, - WidgetStateProperty? b, - double t) { + WidgetStateProperty? a, + WidgetStateProperty? b, + double t, + ) { if (a == null && b == null) { return null; } @@ -210,15 +219,11 @@ class DateTileStyle { } } -enum _DateTileVariant { - outlined, - filled, - defualt, -} +enum _DateTileVariant { outlined, filled, defualt } class DateTile extends StatefulWidget { const DateTile({super.key, this.style, required this.date, this.onTap}) - : _variant = _DateTileVariant.defualt; + : _variant = _DateTileVariant.defualt; final DateTileStyle? style; final DateTime date; @@ -228,12 +233,8 @@ class DateTile extends StatefulWidget { bool get enabled => onTap != null; - const DateTile.filled({ - super.key, - this.style, - required this.date, - this.onTap, - }) : _variant = _DateTileVariant.filled; + const DateTile.filled({super.key, this.style, required this.date, this.onTap}) + : _variant = _DateTileVariant.filled; const DateTile.outlined({ super.key, @@ -308,22 +309,28 @@ class _DateTileState extends State with TickerProviderStateMixin { } T? resolve( - WidgetStateProperty? Function(DateTileStyle? style) getProperty) { + WidgetStateProperty? Function(DateTileStyle? style) getProperty, + ) { return effectiveValue((DateTileStyle? style) { return getProperty(style)?.resolve(statesController.value); }); } - final TextStyle? resolvedTextStyle = - resolve((DateTileStyle? style) => style?.textStyle); - final Color? resolvedBackgroundColor = - resolve((DateTileStyle? style) => style?.backgroundColor); - final Color? resolvedForgroundColor = - resolve((DateTileStyle? style) => style?.forgroundColor); - final OutlinedBorder? resolvedShape = - resolve((DateTileStyle? style) => style?.shape); - final BorderSide? resolvedSide = - resolve((DateTileStyle? style) => style?.side); + final TextStyle? resolvedTextStyle = resolve( + (DateTileStyle? style) => style?.textStyle, + ); + final Color? resolvedBackgroundColor = resolve( + (DateTileStyle? style) => style?.backgroundColor, + ); + final Color? resolvedForgroundColor = resolve( + (DateTileStyle? style) => style?.forgroundColor, + ); + final OutlinedBorder? resolvedShape = resolve( + (DateTileStyle? style) => style?.shape, + ); + final BorderSide? resolvedSide = resolve( + (DateTileStyle? style) => style?.side, + ); return GestureDetector( onTap: widget.onTap, @@ -337,14 +344,17 @@ class _DateTileState extends State with TickerProviderStateMixin { child: InkWell( statesController: statesController, child: Padding( - padding: - const EdgeInsets.symmetric(vertical: 8.0, horizontal: 11.0), + padding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 11.0, + ), child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(_nameOfDay(widget.date.weekday)), - Text(widget.date.day.toString()), - ]), + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(_nameOfDay(widget.date.weekday)), + Text(widget.date.day.toString()), + ], + ), ), ), ), @@ -431,6 +441,7 @@ class _OutlinedDateTileStyle extends DateTileStyle { @override WidgetStateProperty get side { return WidgetStateProperty.all( - BorderSide(color: _colorScheme.primary, width: 1.0)); + BorderSide(color: _colorScheme.primary, width: 1.0), + ); } } diff --git a/lib/presentation/login/components/google_sign_in_button/apple_sign_in_button_mobile.dart b/lib/presentation/login/components/google_sign_in_button/apple_sign_in_button_mobile.dart deleted file mode 100644 index 5237ee65..00000000 --- a/lib/presentation/login/components/google_sign_in_button/apple_sign_in_button_mobile.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter/material.dart' hide IconAlignment; - -class AppleSignInButton extends StatelessWidget { - const AppleSignInButton({super.key, this.onPressed}); - - final VoidCallback? onPressed; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 358, - height: 54, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(14), - child: Image.asset( - 'appleid_button.png', - package: 'assets', - width: 358, - height: 54, - fit: BoxFit.cover, - ), - ), - ), - ); - } -} diff --git a/lib/presentation/login/components/google_sign_in_button/google_sign_in_button_mobile.dart b/lib/presentation/login/components/google_sign_in_button/google_sign_in_button_mobile.dart deleted file mode 100644 index 0629e513..00000000 --- a/lib/presentation/login/components/google_sign_in_button/google_sign_in_button_mobile.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; - -class GoogleSignInButton extends StatelessWidget { - const GoogleSignInButton({super.key, this.onPressed}); - - final VoidCallback? onPressed; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 358, - height: 54, - child: ElevatedButton( - onPressed: onPressed, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - disabledBackgroundColor: Colors.white, - foregroundColor: Colors.black, - disabledForegroundColor: Colors.black, - elevation: 1, - shadowColor: Colors.black26, - disabledMouseCursor: SystemMouseCursors.basic, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - side: BorderSide(color: Color(0xFFDADCE0), width: 1), - ), - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SvgPicture.asset( - 'google_icon.svg', - package: 'assets', - semanticsLabel: 'Google Icon', - fit: BoxFit.contain, - width: 20, - height: 20, - ), - SizedBox(width: 10), - Flexible( - child: Text( - 'Sign in with Google', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w600, - fontSize: 21, - height: 1.4, - letterSpacing: 0, - color: Colors.black87, - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/presentation/login/components/google_sign_in_button/google_sign_in_button_web.dart b/lib/presentation/login/components/google_sign_in_button/google_sign_in_button_web.dart deleted file mode 100644 index 63cacc76..00000000 --- a/lib/presentation/login/components/google_sign_in_button/google_sign_in_button_web.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/widgets.dart'; -import 'package:google_sign_in_web/web_only.dart'; -import 'package:on_time_front/core/di/di_setup.dart'; -import 'package:on_time_front/core/services/google_authentication_service.dart'; -import 'package:on_time_front/domain/entities/google_auth_credential.dart'; -import 'package:on_time_front/domain/repositories/user_repository.dart'; - -class GoogleSignInButton extends StatefulWidget { - const GoogleSignInButton({super.key, this.onPressed}); - - final VoidCallback? onPressed; - - @override - State createState() => _GoogleSignInButtonState(); -} - -class _GoogleSignInButtonState extends State { - final googleAuthenticationService = getIt.get(); - late final Stream _authenticationCredentials; - StreamSubscription? - _authenticationCredentialsSubscription; - - @override - void initState() { - _authenticationCredentials = - googleAuthenticationService.authenticationCredentials; - unawaited(googleAuthenticationService.initialize()); - _authenticationCredentialsSubscription = _authenticationCredentials.listen(( - credential, - ) { - unawaited(getIt.get().signInWithGoogle(credential)); - }); - super.initState(); - } - - @override - void dispose() { - unawaited(_authenticationCredentialsSubscription?.cancel()); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return renderButton(); - } -} diff --git a/lib/presentation/login/components/google_sign_in_button/shared.dart b/lib/presentation/login/components/google_sign_in_button/shared.dart deleted file mode 100644 index ab888f48..00000000 --- a/lib/presentation/login/components/google_sign_in_button/shared.dart +++ /dev/null @@ -1,3 +0,0 @@ -export 'unsupported.dart' - if (dart.library.html) 'google_sign_in_button_web.dart' - if (dart.library.io) 'google_sign_in_button_mobile.dart'; diff --git a/lib/presentation/login/components/google_sign_in_button/unsupported.dart b/lib/presentation/login/components/google_sign_in_button/unsupported.dart deleted file mode 100644 index 83086202..00000000 --- a/lib/presentation/login/components/google_sign_in_button/unsupported.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flutter/widgets.dart'; - -class GoogleSignInButton extends StatelessWidget { - const GoogleSignInButton({super.key, this.onPressed}); - - final VoidCallback? onPressed; - - @override - Widget build(BuildContext context) { - return SizedBox.shrink(); - } -} diff --git a/lib/presentation/login/screens/sign_in_main_screen.dart b/lib/presentation/login/screens/sign_in_main_screen.dart deleted file mode 100644 index 31e14ee9..00000000 --- a/lib/presentation/login/screens/sign_in_main_screen.dart +++ /dev/null @@ -1,211 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:flutter/foundation.dart'; -import 'dart:io' show Platform; -import 'package:on_time_front/core/di/di_setup.dart'; -import 'package:on_time_front/core/logging/app_logger.dart'; -import 'package:on_time_front/core/services/google_authentication_service.dart'; -import 'package:on_time_front/domain/repositories/user_repository.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'; -import 'package:sign_in_with_apple/sign_in_with_apple.dart'; - -import '../components/google_sign_in_button/shared.dart'; -import '../components/google_sign_in_button/apple_sign_in_button_mobile.dart'; - -typedef SocialSignInAction = Future Function(); - -class SignInMainScreen extends StatefulWidget { - const SignInMainScreen({super.key, this.onAppleSignIn, this.onGoogleSignIn}); - - final SocialSignInAction? onAppleSignIn; - final SocialSignInAction? onGoogleSignIn; - - @override - State createState() => _SignInMainScreenState(); -} - -class _SignInMainScreenState extends State { - bool _isSigningIn = false; - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _Title(), - SizedBox(height: 48), - _CharacterImage(), - SizedBox(height: 41), - if (!kIsWeb && Platform.isIOS) ...[ - AppleSignInButton( - onPressed: _isSigningIn - ? null - : () => _startSignIn( - widget.onAppleSignIn ?? _defaultAppleSignIn, - ), - ), - SizedBox(height: 16), - ], - GoogleSignInButton( - onPressed: _isSigningIn - ? null - : () => _startSignIn( - widget.onGoogleSignIn ?? _defaultGoogleSignIn, - ), - ), - ], - ), - ), - ); - } - - Future _startSignIn(SocialSignInAction signIn) async { - if (_isSigningIn) { - return; - } - - setState(() { - _isSigningIn = true; - }); - - try { - await signIn(); - } catch (error, stackTrace) { - if (_isUserCancellation(error)) { - AppLogger.debug( - 'Social sign-in canceled errorType=${error.runtimeType}', - ); - return; - } - - AppLogger.debug( - 'Social sign-in failed errorType=${error.runtimeType} ' - 'stackTrace=$stackTrace', - ); - if (mounted) { - _restoreSignInButtons(); - await _showSignInFailureDialog(); - } - } finally { - if (mounted && _isSigningIn) { - _restoreSignInButtons(); - } - } - } - - void _restoreSignInButtons() { - setState(() { - _isSigningIn = false; - }); - } - - Future _defaultGoogleSignIn() async { - final userRepository = getIt.get(); - final credential = await getIt - .get() - .authenticate(); - await userRepository.signInWithGoogle(credential); - } - - Future _defaultAppleSignIn() async { - final userRepository = getIt.get(); - final credential = await SignInWithApple.getAppleIDCredential( - scopes: [ - AppleIDAuthorizationScopes.email, - AppleIDAuthorizationScopes.fullName, - ], - ); - - final fullNameRaw = - '${credential.givenName ?? ''} ${credential.familyName ?? ''}'.trim(); - final fullName = fullNameRaw.isNotEmpty ? fullNameRaw : 'Apple User'; - - final identityToken = credential.identityToken; - final authorizationCode = credential.authorizationCode; - if (identityToken == null) { - throw Exception('Apple Sign In Failed: Missing credentials'); - } - - await userRepository.signInWithApple( - idToken: identityToken, - authCode: authorizationCode, - fullName: fullName, - email: credential.email, - ); - } - - bool _isUserCancellation(Object error) { - return error is GoogleAuthenticationCanceledException || - error is SignInWithAppleAuthorizationException && - error.code == AuthorizationErrorCode.canceled; - } - - Future _showSignInFailureDialog() { - final l10n = AppLocalizations.of(context)!; - - return showTwoActionDialog( - context, - config: TwoActionDialogConfig( - title: l10n.signInFailedTitle, - description: l10n.signInFailedDescription, - primaryAction: DialogActionConfig( - label: l10n.ok, - variant: ModalWideButtonVariant.destructive, - ), - ), - ); - } -} - -class _Title extends StatelessWidget { - const _Title(); - - @override - Widget build(BuildContext context) { - return Column( - spacing: 28, - children: [ - Image.asset('logo.png', package: 'assets', width: 167), - Text( - AppLocalizations.of(context)!.signInSlogan, - style: TextStyle(fontSize: 20, fontWeight: FontWeight.w400), - ), - ], - ); - } -} - -class _CharacterImage extends StatelessWidget { - const _CharacterImage(); - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 241, - child: SvgPicture.asset('characters/character.svg', package: 'assets'), - ); - } -} - -// class _SocialSignInButtonRow extends StatelessWidget { -// const _SocialSignInButtonRow(); - -// @override -// Widget build(BuildContext context) { -// final UserRepository authenticationRepository = getIt.get(); -// return Row( -// mainAxisAlignment: MainAxisAlignment.center, -// children: [ -// GoogleSignInButton( -// onPressed: () async { -// await authenticationRepository.signInWithGoogle(); -// }, -// ), -// ], -// ); -// } -// } diff --git a/lib/presentation/my_page/my_data_screen.dart b/lib/presentation/my_page/my_data_screen.dart new file mode 100644 index 00000000..19cbbb61 --- /dev/null +++ b/lib/presentation/my_page/my_data_screen.dart @@ -0,0 +1,329 @@ +import 'package:flutter/material.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'; + +class MyDataScreen extends StatefulWidget { + const MyDataScreen({super.key}); + + @override + State createState() => _MyDataScreenState(); +} + +class _MyDataScreenState extends State { + bool _busy = false; + BackupFreshnessStatus? _freshness; + + @override + void initState() { + super.initState(); + _loadFreshness(); + } + + Future _loadFreshness() async { + final value = await getIt().getFreshness(); + if (mounted) setState(() => _freshness = value); + } + + @override + Widget build(BuildContext context) { + final freshness = _freshness; + return Scaffold( + appBar: AppBar(title: const Text('내 데이터')), + body: ListView( + padding: const EdgeInsets.all(16), + 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, + ), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 12), + ListTile( + enabled: !_busy, + leading: const Icon(Icons.lock_outline), + title: const Text('암호화 백업 내보내기'), + subtitle: const Text('선택한 파일 위치에만 저장합니다.'), + onTap: _export, + ), + ListTile( + enabled: !_busy, + leading: const Icon(Icons.restore), + title: const Text('백업에서 복원'), + subtitle: const Text('미리 확인한 뒤 현재 데이터를 완전히 교체합니다.'), + onTap: _restore, + ), + const Divider(height: 32), + ListTile( + 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 데이터와 알람을 모두 삭제합니다.'), + onTap: _reset, + ), + if (_busy) + const Padding( + padding: EdgeInsets.only(top: 24), + child: Center(child: CircularProgressIndicator()), + ), + ], + ), + ); + } + + String _freshnessLabel(BackupFreshnessStatus? status) { + if (status == null) return '확인 중'; + return switch (status.freshness) { + BackupFreshness.neverExported => '아직 내보낸 백업이 없습니다.', + BackupFreshness.noChanges => '마지막 백업 이후 변경 사항이 없습니다.', + BackupFreshness.unexportedChanges => '백업되지 않은 변경 사항이 있습니다.', + }; + } + + Future _export() async { + final password = await _askPassword(confirm: true); + if (password == null) return; + await _run(() async { + final saved = await getIt().exportToUserSelectedFile( + password, + ); + if (!mounted || !saved) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('암호화 백업을 저장했습니다.'))); + await _loadFreshness(); + }); + } + + Future _restore() async { + final password = await _askPassword(confirm: false); + if (password == null) return; + await _run(() async { + final candidate = await getIt().selectAndPreviewRestore( + password, + ); + 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; + await getIt().applyRestore(candidate); + await getIt()(); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('백업을 복원했습니다.'))); + await _loadFreshness(); + }); + } + + Future _reset() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('모든 로컬 데이터를 삭제할까요?'), + content: const Text( + '일정, 준비 단계, 기록, 설정, 알람과 기기 암호화 키가 삭제됩니다. ' + '이미 내보낸 백업 파일은 삭제되지 않습니다. 이 작업은 되돌릴 수 없습니다.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('취소'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + ), + onPressed: () => Navigator.pop(context, true), + child: const Text('모두 삭제'), + ), + ], + ), + ); + if (confirmed != true) return; + setState(() => _busy = true); + try { + await getIt().reset(); + if (mounted) context.go('/resetComplete'); + } catch (error) { + if (!mounted) return; + setState(() => _busy = false); + _showError(error); + } + } + + Future _askPassword({required bool confirm}) async { + final first = TextEditingController(); + final second = TextEditingController(); + String? error; + final result = await 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('계속'), + ), + ], + ), + ), + ); + first.dispose(); + second.dispose(); + return result; + } + + Future _run(Future Function() action) async { + setState(() => _busy = true); + try { + await action(); + } catch (error) { + if (mounted) _showError(error); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + void _showError(Object error) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('작업을 완료하지 못했습니다: $error'))); + } +} + +class LocalDataResetCompleteScreen extends StatelessWidget { + const LocalDataResetCompleteScreen({super.key}); + + @override + Widget build(BuildContext context) => const Scaffold( + body: SafeArea( + child: Center( + child: Padding( + padding: EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle_outline, size: 64), + SizedBox(height: 20), + Text( + '로컬 데이터를 삭제했습니다.', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600), + ), + SizedBox(height: 12), + Text( + 'OnTime을 완전히 종료한 뒤 다시 열면 새 로컬 프로필로 시작합니다.', + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ); +} diff --git a/lib/presentation/my_page/my_page_modal/delete_user_modal.dart b/lib/presentation/my_page/my_page_modal/delete_user_modal.dart deleted file mode 100644 index 44c2a688..00000000 --- a/lib/presentation/my_page/my_page_modal/delete_user_modal.dart +++ /dev/null @@ -1,288 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:on_time_front/core/di/di_setup.dart'; -import 'package:on_time_front/core/logging/app_logger.dart'; -import 'package:on_time_front/core/validation/backend_constraints.dart'; -import 'package:on_time_front/domain/repositories/user_repository.dart'; -import 'package:on_time_front/domain/use-cases/delete_user_use_case.dart'; -import 'package:on_time_front/l10n/app_localizations.dart'; -import 'package:on_time_front/presentation/shared/components/custom_alert_dialog.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'; -import 'package:on_time_front/presentation/shared/components/two_button_delete_dialog.dart'; - -class DeleteUserModal { - DeleteUserModal({ - DeleteUserUseCase? deleteUserUseCase, - UserRepository? userRepository, - }) : _deleteUserUseCase = deleteUserUseCase, - _userRepository = userRepository; - - final DeleteUserUseCase? _deleteUserUseCase; - final UserRepository? _userRepository; - - Future showDeleteUserModal( - BuildContext context, { - required VoidCallback onConfirm, - }) async { - final l10n = AppLocalizations.of(context)!; - - final shouldDelete = await showTwoButtonDeleteDialog( - context, - title: l10n.deleteAccountConfirmTitle, - description: l10n.deleteAccountConfirmDescription, - cancelText: l10n.keepUsing, - confirmText: l10n.deleteAnyway, - ); - - if (shouldDelete == true && context.mounted) { - await _showDeleteFeedbackModal(context, onConfirm); - } - } - - Future _showDeleteFeedbackModal( - BuildContext context, - VoidCallback onConfirm, - ) async { - final result = await showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) { - return _DeleteFeedbackDialog( - onDelete: (feedbackMessage) async { - await _deleteAccount(feedbackMessage); - }, - ); - }, - ); - - if (result == true) { - onConfirm(); - } - } - - Future _deleteAccount(String feedbackMessage) async { - final deleteUserUseCase = _deleteUserUseCase ?? getIt(); - await deleteUserUseCase(feedbackMessage); - - try { - final userRepository = _userRepository ?? getIt(); - await userRepository.signOut(); - } catch (error) { - AppLogger.debug( - 'Sign out after delete user failed errorType=${error.runtimeType}', - ); - } - } -} - -class _DeleteFeedbackDialog extends StatefulWidget { - const _DeleteFeedbackDialog({required this.onDelete}); - - final Future Function(String feedbackMessage) onDelete; - - @override - State<_DeleteFeedbackDialog> createState() => _DeleteFeedbackDialogState(); -} - -class _DeleteFeedbackDialogState extends State<_DeleteFeedbackDialog> { - final TextEditingController _controller = TextEditingController(); - final FocusNode _focusNode = FocusNode(); - bool _isDeleting = false; - - @override - void initState() { - super.initState(); - _focusNode.addListener(_handleFocusChanged); - } - - @override - void dispose() { - _focusNode.removeListener(_handleFocusChanged); - _focusNode.dispose(); - _controller.dispose(); - super.dispose(); - } - - void _handleFocusChanged() { - setState(() {}); - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - final l10n = AppLocalizations.of(context)!; - final screenWidth = MediaQuery.sizeOf(context).width; - final dialogWidth = (screenWidth - 32).clamp(0.0, 277.0).toDouble(); - - return PopScope( - canPop: !_isDeleting, - child: CustomAlertDialog( - title: Text( - l10n.deleteFeedbackTitle, - style: textTheme.titleMedium?.copyWith( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w600, - fontSize: 18, - height: 1.4, - color: colorScheme.onSurface, - ), - ), - innerPadding: const EdgeInsets.fromLTRB(16, 18, 16, 18), - titleContentSpacing: 8, - contentActionsSpacing: 16, - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - l10n.deleteFeedbackDescription, - style: textTheme.bodyMedium?.copyWith( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w400, - height: 1.4, - fontSize: 14, - color: colorScheme.outline, - ), - ), - const SizedBox(height: 16), - Container( - width: 249, - height: 160, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: colorScheme.outlineVariant, width: 1), - ), - child: TextField( - controller: _controller, - focusNode: _focusNode, - enabled: !_isDeleting, - inputFormatters: [ - LengthLimitingTextInputFormatter( - BackendConstraints.maxLongTextLength, - ), - ], - maxLines: null, - expands: true, - textAlign: TextAlign.start, - textAlignVertical: TextAlignVertical.top, - cursorColor: colorScheme.outline, - style: textTheme.bodyMedium?.copyWith( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w400, - height: 1.4, - fontSize: 14, - color: colorScheme.onSurface, - ), - decoration: InputDecoration( - hintText: _focusNode.hasFocus - ? '' - : l10n.deleteFeedbackPlaceholder, - hintStyle: textTheme.bodyMedium?.copyWith( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w400, - height: 1.4, - fontSize: 14, - color: colorScheme.outlineVariant, - ), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - isCollapsed: true, - contentPadding: EdgeInsets.zero, - ), - ), - ), - ], - ), - actions: [ - SizedBox( - width: dialogWidth, - child: Row( - children: [ - ModalWideButton( - layout: ModalWideButtonLayout.flex, - text: l10n.keepUsingLong, - variant: ModalWideButtonVariant.neutral, - textStyle: TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w600, - fontSize: 14, - height: 1.4, - color: colorScheme.outline, - ), - height: 43, - onPressed: _isDeleting - ? null - : () => Navigator.of(context).pop(false), - ), - const SizedBox(width: 8), - ModalWideButton( - layout: ModalWideButtonLayout.flex, - text: l10n.sendFeedbackAndDelete, - variant: ModalWideButtonVariant.destructive, - textStyle: TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w600, - fontSize: 14, - height: 1.4, - color: colorScheme.onError, - ), - height: 43, - isLoading: _isDeleting, - onPressed: _submitDelete, - ), - ], - ), - ), - ], - ), - ); - } - - Future _submitDelete() async { - if (_isDeleting) { - return; - } - - setState(() { - _isDeleting = true; - }); - - try { - await widget.onDelete(_controller.text); - } catch (error) { - AppLogger.debug('Delete user failed errorType=${error.runtimeType}'); - if (!mounted) { - return; - } - - final l10n = AppLocalizations.of(context)!; - await showTwoActionDialog( - context, - config: TwoActionDialogConfig( - title: l10n.error, - primaryAction: DialogActionConfig( - label: l10n.ok, - variant: ModalWideButtonVariant.destructive, - ), - ), - ); - if (!mounted) { - return; - } - - setState(() { - _isDeleting = false; - }); - return; - } - - if (mounted) { - Navigator.of(context).pop(true); - } - } -} diff --git a/lib/presentation/my_page/my_page_modal/logout_modal.dart b/lib/presentation/my_page/my_page_modal/logout_modal.dart deleted file mode 100644 index bfb0098e..00000000 --- a/lib/presentation/my_page/my_page_modal/logout_modal.dart +++ /dev/null @@ -1,29 +0,0 @@ -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/app/bloc/auth/auth_bloc.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'; - -Future showLogoutModal(BuildContext context) async { - final l10n = AppLocalizations.of(context)!; - - final result = await showTwoActionDialog( - context, - config: TwoActionDialogConfig( - title: l10n.logOutConfirm, - secondaryAction: DialogActionConfig( - label: l10n.cancel, - variant: ModalWideButtonVariant.neutral, - ), - primaryAction: DialogActionConfig( - label: l10n.logOut, - variant: ModalWideButtonVariant.destructive, - ), - ), - ); - - if (result == DialogActionResult.primary && context.mounted) { - context.read().add(const AuthSignOutPressed()); - } -} diff --git a/lib/presentation/my_page/my_page_screen.dart b/lib/presentation/my_page/my_page_screen.dart index 145f8d99..41bf340e 100644 --- a/lib/presentation/my_page/my_page_screen.dart +++ b/lib/presentation/my_page/my_page_screen.dart @@ -1,11 +1,8 @@ -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:on_time_front/core/constants/external_links.dart'; import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; +import 'package:on_time_front/core/services/detailed_notification_preference_service.dart'; import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; import 'package:on_time_front/core/services/notification_service.dart'; import 'package:on_time_front/domain/entities/alarm_delivery_policy.dart'; @@ -16,34 +13,18 @@ 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/reconcile_alarms_use_case.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/app/cubit/analytics_preference_cubit.dart'; -import 'package:on_time_front/presentation/my_page/my_page_modal/delete_user_modal.dart'; -import 'package:on_time_front/presentation/my_page/my_page_modal/logout_modal.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'; -typedef PrivacyPolicyLauncher = Future Function(Uri uri); - class MyPageScreen extends StatelessWidget { - const MyPageScreen({ - super.key, - PrivacyPolicyLauncher? openPrivacyPolicy, - NotificationService? notificationService, - AnalyticsPreferenceCubit? analyticsPreferenceCubit, - }) : _openPrivacyPolicy = openPrivacyPolicy, - _notificationService = notificationService, - _analyticsPreferenceCubit = analyticsPreferenceCubit; - - final PrivacyPolicyLauncher? _openPrivacyPolicy; + const MyPageScreen({super.key, NotificationService? notificationService}) + : _notificationService = notificationService; + final NotificationService? _notificationService; - final AnalyticsPreferenceCubit? _analyticsPreferenceCubit; @override Widget build(BuildContext context) { - final signedIn = - context.read().state.status == AuthStatus.authenticated; - final content = Scaffold( + return Scaffold( backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow, appBar: AppBar( title: Text( @@ -56,35 +37,15 @@ class MyPageScreen extends StatelessWidget { child: Column( spacing: 12, children: [ - _FrameView( - title: AppLocalizations.of(context)!.myAccount, - child: _MyAccountView(), - ), const _FrameView(title: '알람 설정', child: _AlarmStatusView()), _FrameView( - title: AppLocalizations.of(context)!.accountSettings, + title: '내 데이터', child: Column( spacing: 25, children: [ _SettingTile( - title: AppLocalizations.of(context)!.logOut, - onTap: () async { - await showLogoutModal(context); - }, - ), - _SettingTile( - title: AppLocalizations.of(context)!.deleteAccount, - onTap: () async { - final deleteUserModal = DeleteUserModal(); - await deleteUserModal.showDeleteUserModal( - context, - onConfirm: () { - if (context.mounted) { - context.go('/signIn'); - } - }, - ); - }, + title: '백업, 복원 및 로컬 데이터 초기화', + onTap: () => context.push('/myData'), ), ], ), @@ -104,6 +65,7 @@ class MyPageScreen extends StatelessWidget { if (updatedPreparation != null) {} }, ), + const _DetailedNotificationTile(), _SettingTile( title: AppLocalizations.of(context)!.allowAppNotifications, onTap: () async { @@ -113,15 +75,9 @@ class MyPageScreen extends StatelessWidget { ); }, ), - const _AnalyticsPreferenceTile(), _SettingTile( title: AppLocalizations.of(context)!.privacyPolicy, - onTap: () async { - await _handlePrivacyPolicyTap( - context, - _openPrivacyPolicy ?? _openPrivacyPolicyExternally, - ); - }, + onTap: () => context.push('/privacyPolicy'), ), ], ), @@ -130,93 +86,55 @@ class MyPageScreen extends StatelessWidget { ), ), ); - if (_analyticsPreferenceCubit != null) { - final analyticsPreferenceCubit = _analyticsPreferenceCubit; - return BlocProvider.value( - value: analyticsPreferenceCubit..load(signedIn: signedIn), - child: content, - ); - } - return BlocProvider( - create: (_) => - getIt.get()..load(signedIn: signedIn), - child: content, - ); } } -class _AnalyticsPreferenceTile extends StatelessWidget { - const _AnalyticsPreferenceTile(); +class _DetailedNotificationTile extends StatefulWidget { + const _DetailedNotificationTile(); @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - final colorScheme = Theme.of(context).colorScheme; - final signedIn = - context.read().state.status == AuthStatus.authenticated; - return BlocBuilder( - builder: (context, state) { - final isUpdating = - state.status == AnalyticsPreferenceStatus.loading || - state.status == AnalyticsPreferenceStatus.updating; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - AppLocalizations.of(context)!.helpImproveOnTime, - style: textTheme.bodyLarge, - ), - ), - Switch( - key: const Key('analyticsPreferenceSwitch'), - value: state.enabled, - activeThumbColor: colorScheme.primary, - onChanged: isUpdating - ? null - : (value) { - context.read().update( - enabled: value, - signedIn: signedIn, - ); - }, - ), - ], - ); - }, - ); - } + State<_DetailedNotificationTile> createState() => + _DetailedNotificationTileState(); } -Future _openPrivacyPolicyExternally(Uri uri) { - return launchUrl(uri, mode: LaunchMode.externalApplication); -} +class _DetailedNotificationTileState + extends State<_DetailedNotificationTile> { + bool _enabled = false; + bool _loading = true; -Future _handlePrivacyPolicyTap( - BuildContext context, - PrivacyPolicyLauncher openPrivacyPolicy, -) async { - var opened = false; - try { - opened = await openPrivacyPolicy(ExternalLinks.privacyPolicyUri); - } catch (_) { - opened = false; + @override + void initState() { + super.initState(); + _load(); } - if (opened || !context.mounted) return; + Future _load() async { + final enabled = await getIt() + .getEnabled(); + if (mounted) { + setState(() { + _enabled = enabled; + _loading = false; + }); + } + } - final l10n = AppLocalizations.of(context)!; - await showTwoActionDialog( - context, - config: TwoActionDialogConfig( - title: l10n.error, - description: l10n.privacyPolicyOpenError, - primaryAction: DialogActionConfig( - label: l10n.ok, - variant: ModalWideButtonVariant.primary, - ), - ), - ); + Future _change(bool enabled) async { + setState(() => _enabled = enabled); + await getIt().setEnabled(enabled); + await getIt()(); + } + + @override + Widget build(BuildContext context) { + return SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('알림에 일정 이름 표시'), + subtitle: const Text('기본값은 잠금 화면에 상세 내용을 표시하지 않습니다.'), + value: _enabled, + onChanged: _loading ? null : _change, + ); + } } class _AlarmStatusView extends StatefulWidget { @@ -474,54 +392,6 @@ bool _shouldRecoverNativeAlarmPermission( ); } -class _MyAccountView extends StatelessWidget { - const _MyAccountView(); - - @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - final colorScheme = Theme.of(context).colorScheme; - return BlocBuilder( - builder: (context, state) { - if (state.status == AuthStatus.authenticated) { - final userName = state.user.nameOrNull; - final userEmail = state.user.emailOrNull; - return Padding( - padding: - const EdgeInsets.symmetric(horizontal: 10.0) + - EdgeInsets.only(bottom: 9), - child: Row( - spacing: 20, - children: [ - CircleAvatar( - radius: 30, - backgroundImage: Image.asset( - 'profile.png', - package: 'assets', - ).image, - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(userName ?? '', style: textTheme.titleMedium), - Text( - userEmail ?? '', - style: textTheme.bodyMedium!.copyWith( - color: colorScheme.outline, - ), - ), - ], - ), - ], - ), - ); - } - return const SizedBox.shrink(); - }, - ); - } -} - class _FrameView extends StatelessWidget { const _FrameView({required this.title, required this.child}); diff --git a/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart b/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart index 579588e6..1693bf5a 100644 --- a/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart +++ b/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart @@ -1,7 +1,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:injectable/injectable.dart'; -import 'package:on_time_front/core/dio/api_error_message.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/use-cases/get_default_preparation_use_case.dart'; import 'package:on_time_front/domain/use-cases/update_default_preparation_use_case.dart'; @@ -119,7 +118,7 @@ class DefaultPreparationSpareTimeFormBloc emit( state.copyWith( status: DefaultPreparationSpareTimeStatus.error, - errorMessage: ApiErrorMessage.fromException(e) ?? e.toString(), + errorMessage: e.toString(), ), ); } diff --git a/lib/presentation/my_page/privacy_policy_screen.dart b/lib/presentation/my_page/privacy_policy_screen.dart new file mode 100644 index 00000000..26120745 --- /dev/null +++ b/lib/presentation/my_page/privacy_policy_screen.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +class PrivacyPolicyScreen extends StatelessWidget { + const PrivacyPolicyScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('개인정보 처리방침')), + body: const SelectionArea( + child: SingleChildScrollView( + padding: EdgeInsets.all(20), + child: Text( + 'OnTime 로컬 전용 개인정보 처리방침\n\n' + '시행일: 2026년 8월 28일\n\n' + '1. 저장되는 정보\n' + 'OnTime은 사용자가 입력한 일정, 장소, 준비 단계, 시간 기록, 정시 도착 집계와 앱 설정을 현재 기기의 암호화된 로컬 저장소에 보관합니다. 이름, 이메일, 소셜 로그인 정보나 서버 계정을 수집하지 않습니다.\n\n' + '2. 외부 전송\n' + 'OnTime은 앱 사용 데이터, 분석 이벤트, 인증 정보 또는 푸시 토큰을 OnTime 서버나 분석 서비스로 전송하지 않습니다. 알림과 알람은 운영체제의 기기 내 기능으로 예약됩니다.\n\n' + '3. 백업\n' + '백업은 사용자가 직접 실행할 때만 생성됩니다. 백업 파일은 사용자가 지정한 위치에 저장되며, 사용자가 입력한 백업 비밀번호로 암호화됩니다. OnTime은 비밀번호나 백업 파일 위치를 저장하지 않으므로 분실한 비밀번호를 복구할 수 없습니다.\n\n' + '4. 삭제\n' + '내 데이터 화면의 로컬 데이터 초기화를 실행하면 이 설치가 소유한 데이터, 알람 등록 정보와 기기 암호화 키가 삭제됩니다. 사용자가 외부 위치로 내보낸 백업 파일은 직접 삭제해야 합니다.\n\n' + '5. 운영체제 기능\n' + '파일 선택, 로컬 알림, 알람과 앱 권한 처리는 Android 또는 iOS가 제공합니다. 운영체제나 사용자가 선택한 외부 파일 제공자의 처리에는 해당 서비스의 정책이 적용됩니다.\n\n' + '6. 문의\n' + '앱 배포 페이지에 표시된 개발자 연락처를 이용할 수 있습니다.', + style: TextStyle(fontSize: 15, height: 1.55), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/notification_allow/screens/notification_allow_screen.dart b/lib/presentation/notification_allow/screens/notification_allow_screen.dart index 287e76f3..a591e4f3 100644 --- a/lib/presentation/notification_allow/screens/notification_allow_screen.dart +++ b/lib/presentation/notification_allow/screens/notification_allow_screen.dart @@ -1,4 +1,3 @@ -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/flutter_svg.dart'; diff --git a/lib/presentation/onboarding/components/onboarding_page_view_layout.dart b/lib/presentation/onboarding/components/onboarding_page_view_layout.dart index f9f2c255..74f5b294 100644 --- a/lib/presentation/onboarding/components/onboarding_page_view_layout.dart +++ b/lib/presentation/onboarding/components/onboarding_page_view_layout.dart @@ -28,8 +28,10 @@ class _OnboardingPageViewLayoutState extends State { SizedBox( width: double.infinity, child: Padding( - padding: - const EdgeInsets.symmetric(vertical: 27.0, horizontal: 8.0), + padding: const EdgeInsets.symmetric( + vertical: 27.0, + horizontal: 8.0, + ), child: OnboardingTitle( title: widget.title, subTitle: widget.subTitle, @@ -37,9 +39,7 @@ class _OnboardingPageViewLayoutState extends State { ), ), ), - 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 8a5966c8..f9976d79 100644 --- a/lib/presentation/onboarding/components/onboarding_title.dart +++ b/lib/presentation/onboarding/components/onboarding_title.dart @@ -20,19 +20,21 @@ class OnboardingTitle extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ RichText( - text: TextSpan( - text: title, - style: textTheme.titleLarge, - children: hint != null - ? [ - TextSpan( - text: hint, - style: textTheme.bodyLarge?.copyWith( - color: AppColors.grey.shade600, - ), - ) - ] - : [])), + text: TextSpan( + text: title, + style: textTheme.titleLarge, + children: hint != null + ? [ + TextSpan( + text: hint, + style: textTheme.bodyLarge?.copyWith( + color: AppColors.grey.shade600, + ), + ), + ] + : [], + ), + ), SizedBox(height: 8.0), subTitle ?? const SizedBox.shrink(), ], diff --git a/lib/presentation/onboarding/cubit/onboarding_cubit.dart b/lib/presentation/onboarding/cubit/onboarding_cubit.dart index d2d6af07..9490575f 100644 --- a/lib/presentation/onboarding/cubit/onboarding_cubit.dart +++ b/lib/presentation/onboarding/cubit/onboarding_cubit.dart @@ -9,9 +9,8 @@ part 'onboarding_state.dart'; @Injectable() class OnboardingCubit extends Cubit { - OnboardingCubit( - this._createDefaultPreparationUseCase, - ) : super(OnboardingState()); + OnboardingCubit(this._createDefaultPreparationUseCase) + : super(OnboardingState()); final OnboardUseCase _createDefaultPreparationUseCase; @@ -27,13 +26,15 @@ class OnboardingCubit extends Cubit { List? preparationStepList, Duration? spareTime, }) { - emit(state.copyWith( - preparationStepList: preparationStepList, spareTime: spareTime)); + emit( + state.copyWith( + preparationStepList: preparationStepList, + spareTime: spareTime, + ), + ); } - void onboardingFormValidated({ - required bool isValid, - }) { + void onboardingFormValidated({required bool isValid}) { emit(state.copyWith(isValid: isValid)); } } diff --git a/lib/presentation/onboarding/cubit/onboarding_state.dart b/lib/presentation/onboarding/cubit/onboarding_state.dart index 6fa51c00..214867d8 100644 --- a/lib/presentation/onboarding/cubit/onboarding_state.dart +++ b/lib/presentation/onboarding/cubit/onboarding_state.dart @@ -1,11 +1,12 @@ part of 'onboarding_cubit.dart'; class OnboardingState extends Equatable { - const OnboardingState( - {this.preparationStepList = const [], - this.spareTime, - this.note, - this.isValid = false}); + const OnboardingState({ + this.preparationStepList = const [], + this.spareTime, + this.note, + this.isValid = false, + }); final List preparationStepList; final Duration? spareTime; final String? note; @@ -28,12 +29,14 @@ class OnboardingState extends Equatable { PreparationEntity toEntity() { return PreparationEntity( preparationStepList: preparationStepList - .map((step) => PreparationStepEntity( - id: step.id, - preparationName: step.preparationName, - preparationTime: step.preparationTime, - nextPreparationId: step.nextPreparationId, - )) + .map( + (step) => PreparationStepEntity( + id: step.id, + preparationName: step.preparationName, + preparationTime: step.preparationTime, + nextPreparationId: step.nextPreparationId, + ), + ) .toList(), ); } @@ -72,6 +75,10 @@ class OnboardingPreparationStepState extends Equatable { } @override - List get props => - [id, preparationName, preparationTime, nextPreparationId]; + List get props => [ + id, + preparationName, + preparationTime, + nextPreparationId, + ]; } 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 d89d5a27..324ab724 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,10 +1,7 @@ import 'package:flutter/material.dart'; class CreateIconButton extends StatelessWidget { - const CreateIconButton({ - super.key, - required this.onCreationRequested, - }); + const CreateIconButton({super.key, required this.onCreationRequested}); final VoidCallback onCreationRequested; @@ -13,8 +10,9 @@ class CreateIconButton extends StatelessWidget { final ColorScheme colorScheme = Theme.of(context).colorScheme; return IconButton( style: ButtonStyle( - backgroundColor: - WidgetStateProperty.all(colorScheme.surfaceContainerHigh), + backgroundColor: WidgetStateProperty.all( + colorScheme.surfaceContainerHigh, + ), ), onPressed: () { onCreationRequested(); diff --git a/lib/presentation/onboarding/preparation_name_select/components/preparation_create_list.dart b/lib/presentation/onboarding/preparation_name_select/components/preparation_create_list.dart index 28f18561..d509daf6 100644 --- a/lib/presentation/onboarding/preparation_name_select/components/preparation_create_list.dart +++ b/lib/presentation/onboarding/preparation_name_select/components/preparation_create_list.dart @@ -7,16 +7,17 @@ import 'package:on_time_front/presentation/onboarding/preparation_name_select/cu import 'package:on_time_front/presentation/onboarding/preparation_name_select/cubit/preparation_step_name/preparation_step_name_cubit.dart'; class PreparationCreateList extends StatelessWidget { - const PreparationCreateList( - {super.key, - required this.preparationNameState, - required this.onNameChanged, - required this.onSelectionChanged, - required this.onCreationRequested}); + const PreparationCreateList({ + super.key, + required this.preparationNameState, + required this.onNameChanged, + required this.onSelectionChanged, + required this.onCreationRequested, + }); final PreparationNameState preparationNameState; final void Function({required int index, required String value}) - onNameChanged; + onNameChanged; final void Function({required int index}) onSelectionChanged; final VoidCallback onCreationRequested; @@ -37,36 +38,39 @@ class PreparationCreateList extends StatelessWidget { preparationNameState.status == PreparationNameStatus.adding ? BlocProvider( create: (context) => PreparationStepNameCubit( - PreparationStepNameState(), - preparationNameCubit: - context.read()), - child: BlocBuilder(builder: (context, state) { - return PreparationNameSelectField( - isAdding: true, - preparationStep: state, - onNameChanged: (value) { - context - .read() - .nameChanged(value); - }, - onSelectionChanged: () { - context - .read() - .selectionToggled(); - }, - onNameSaved: () { - context - .read() - .preparationStepSaved(); - }, - ); - }), + PreparationStepNameState(), + preparationNameCubit: context.read(), + ), + child: + BlocBuilder< + PreparationStepNameCubit, + PreparationStepNameState + >( + builder: (context, state) { + return PreparationNameSelectField( + isAdding: true, + preparationStep: state, + onNameChanged: (value) { + context + .read() + .nameChanged(value); + }, + onSelectionChanged: () { + context + .read() + .selectionToggled(); + }, + onNameSaved: () { + context + .read() + .preparationStepSaved(); + }, + ); + }, + ), ) : SizedBox.shrink(), - SizedBox( - height: 28.0, - ), + SizedBox(height: 28.0), Center( child: SizedBox( height: 30, 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 a722a4f7..e6bc7287 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 @@ -72,7 +72,8 @@ class _PreparationNameSelectFieldState child: Center( child: TextFormField( scrollPadding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom + 56), + bottom: MediaQuery.of(context).viewInsets.bottom + 56, + ), initialValue: widget.preparationStep.preparationName.value, onChanged: widget.onNameChanged, onFieldSubmitted: (value) => widget.onNameSaved?.call(), diff --git a/lib/presentation/onboarding/preparation_name_select/components/preparation_select_list.dart b/lib/presentation/onboarding/preparation_name_select/components/preparation_select_list.dart index c5498ec0..2cdfdad7 100644 --- a/lib/presentation/onboarding/preparation_name_select/components/preparation_select_list.dart +++ b/lib/presentation/onboarding/preparation_name_select/components/preparation_select_list.dart @@ -1,4 +1,3 @@ - import 'package:flutter/widgets.dart'; import 'package:on_time_front/presentation/onboarding/preparation_name_select/components/preparation_name_select_field.dart'; import 'package:on_time_front/presentation/onboarding/preparation_name_select/cubit/preparation_step_name/preparation_step_name_cubit.dart'; diff --git a/lib/presentation/onboarding/preparation_name_select/cubit/preparation_name/preparation_name_cubit.dart b/lib/presentation/onboarding/preparation_name_select/cubit/preparation_name/preparation_name_cubit.dart index f469bd69..39997e92 100644 --- a/lib/presentation/onboarding/preparation_name_select/cubit/preparation_name/preparation_name_cubit.dart +++ b/lib/presentation/onboarding/preparation_name_select/cubit/preparation_name/preparation_name_cubit.dart @@ -8,62 +8,61 @@ import 'package:on_time_front/presentation/onboarding/preparation_name_select/in part 'preparation_name_state.dart'; class PreparationNameCubit extends Cubit { - PreparationNameCubit({ - required this.onboardingCubit, - }) : super(PreparationNameState()) { + PreparationNameCubit({required this.onboardingCubit}) + : super(PreparationNameState()) { initialize(); } final OnboardingCubit onboardingCubit; void initialize() { - List preparationStepList = - onboardingCubit.state.preparationStepList.map( - (e) { - return PreparationStepNameState( - preparationId: e.id, - preparationName: PreparationNameInputModel.dirty(e.preparationName), - isSelected: true, - ); - }, - ).toList(); + List preparationStepList = onboardingCubit + .state + .preparationStepList + .map((e) { + return PreparationStepNameState( + preparationId: e.id, + preparationName: PreparationNameInputModel.dirty(e.preparationName), + isSelected: true, + ); + }) + .toList(); if (preparationStepList.isEmpty) { preparationStepList = onBoardingPreparationSuggestion; } - emit(state.copyWith( - status: PreparationNameStatus.initial, - isValid: false, - preparationStepList: preparationStepList, - )); + emit( + state.copyWith( + status: PreparationNameStatus.initial, + isValid: false, + preparationStepList: preparationStepList, + ), + ); } void preparationStepCreated(PreparationStepNameState stepState) { if (state.status == PreparationNameStatus.adding) { final List preparationStepList; if (stepState.preparationName.isValid) { - preparationStepList = [ - ...state.preparationStepList, - stepState, - ]; + preparationStepList = [...state.preparationStepList, stepState]; } else { preparationStepList = state.preparationStepList; } final isValid = _validate(preparationStepList); - emit(state.copyWith( - preparationStepList: preparationStepList, - status: PreparationNameStatus.initial, - isValid: isValid, - )); + emit( + state.copyWith( + preparationStepList: preparationStepList, + status: PreparationNameStatus.initial, + isValid: isValid, + ), + ); onboardingCubit.onboardingFormValidated(isValid: isValid); } } - void preparationStepNameChanged({ - required int index, - required String value, - }) { - final preparationStepList = - List.from(state.preparationStepList); + void preparationStepNameChanged({required int index, required String value}) { + final preparationStepList = List.from( + state.preparationStepList, + ); final preparationStep = preparationStepList[index]; final updatedPreparationStep = preparationStep.copyWith( preparationName: PreparationNameInputModel.dirty(value), @@ -73,16 +72,17 @@ class PreparationNameCubit extends Cubit { final isValid = _validate(preparationStepList); emit( state.copyWith( - preparationStepList: preparationStepList, isValid: isValid), + preparationStepList: preparationStepList, + isValid: isValid, + ), ); onboardingCubit.onboardingFormValidated(isValid: isValid); } - void preparationStepSelectionChanged({ - required int index, - }) { - final preparationStepList = - List.from(state.preparationStepList); + void preparationStepSelectionChanged({required int index}) { + final preparationStepList = List.from( + state.preparationStepList, + ); final preparationStep = preparationStepList[index]; final updatedPreparationStep = preparationStep.copyWith( isSelected: !preparationStep.isSelected, @@ -90,10 +90,12 @@ class PreparationNameCubit extends Cubit { preparationStepList[index] = updatedPreparationStep; final isValid = _validate(preparationStepList); - emit(state.copyWith( - preparationStepList: preparationStepList, - isValid: isValid, - )); + emit( + state.copyWith( + preparationStepList: preparationStepList, + isValid: isValid, + ), + ); onboardingCubit.onboardingFormValidated(isValid: isValid); } @@ -103,7 +105,7 @@ class PreparationNameCubit extends Cubit { void preparationSaved() { final List - onboardingPreparationStepStateList = []; + onboardingPreparationStepStateList = []; final selectedList = state.preparationStepList .where((element) => element.isSelected) .toList(); @@ -116,32 +118,40 @@ class PreparationNameCubit extends Cubit { j++; } if (j >= onboardingState.preparationStepList.length) { - onboardingPreparationStepStateList.add(OnboardingPreparationStepState( - id: selectedList[i].preparationId, - preparationName: selectedList[i].preparationName.value, - nextPreparationId: i == selectedList.length - 1 - ? null - : selectedList[i + 1].preparationId, - )); + onboardingPreparationStepStateList.add( + OnboardingPreparationStepState( + id: selectedList[i].preparationId, + preparationName: selectedList[i].preparationName.value, + nextPreparationId: i == selectedList.length - 1 + ? null + : selectedList[i + 1].preparationId, + ), + ); continue; } onboardingPreparationStepStateList.add( - onboardingState.preparationStepList[j].copyWith( - preparationName: selectedList[i].preparationName.value, - nextPreparationId: i == selectedList.length - 1 - ? '' - : selectedList[i + 1].preparationId)); + onboardingState.preparationStepList[j].copyWith( + preparationName: selectedList[i].preparationName.value, + nextPreparationId: i == selectedList.length - 1 + ? '' + : selectedList[i + 1].preparationId, + ), + ); } onboardingCubit.onboardingFormChanged( - preparationStepList: onboardingPreparationStepStateList); + preparationStepList: onboardingPreparationStepStateList, + ); } bool _validate(List preparationStepList) { - final selectedPreparationStepList = - preparationStepList.where((element) => element.isSelected).toList(); - final isValid = selectedPreparationStepList.isNotEmpty && + final selectedPreparationStepList = preparationStepList + .where((element) => element.isSelected) + .toList(); + final isValid = + selectedPreparationStepList.isNotEmpty && Formz.validate( - selectedPreparationStepList.map((e) => e.preparationName).toList()); + selectedPreparationStepList.map((e) => e.preparationName).toList(), + ); return isValid; } } diff --git a/lib/presentation/onboarding/preparation_name_select/cubit/preparation_step_name/preparation_step_name_cubit.dart b/lib/presentation/onboarding/preparation_name_select/cubit/preparation_step_name/preparation_step_name_cubit.dart index c3d8792c..b52d105a 100644 --- a/lib/presentation/onboarding/preparation_name_select/cubit/preparation_step_name/preparation_step_name_cubit.dart +++ b/lib/presentation/onboarding/preparation_name_select/cubit/preparation_step_name/preparation_step_name_cubit.dart @@ -16,14 +16,16 @@ class PreparationStepNameCubit extends Cubit { void nameChanged(String value) { final preparationName = PreparationNameInputModel.dirty(value); - emit(state.copyWith( - preparationName: preparationName, isValid: preparationName.isValid)); + emit( + state.copyWith( + preparationName: preparationName, + isValid: preparationName.isValid, + ), + ); } void selectionToggled() { - emit(state.copyWith( - isSelected: !state.isSelected, - )); + emit(state.copyWith(isSelected: !state.isSelected)); } void preparationStepSaved() { diff --git a/lib/presentation/onboarding/preparation_name_select/cubit/preparation_step_name/preparation_step_name_state.dart b/lib/presentation/onboarding/preparation_name_select/cubit/preparation_step_name/preparation_step_name_state.dart index 8949b93e..5d1ce2f9 100644 --- a/lib/presentation/onboarding/preparation_name_select/cubit/preparation_step_name/preparation_step_name_state.dart +++ b/lib/presentation/onboarding/preparation_name_select/cubit/preparation_step_name/preparation_step_name_state.dart @@ -28,6 +28,10 @@ class PreparationStepNameState extends Equatable { } @override - List get props => - [preparationId, preparationName, isValid, isSelected]; + List get props => [ + preparationId, + preparationName, + isValid, + isSelected, + ]; } diff --git a/lib/presentation/onboarding/preparation_name_select/screens/preparation_name_form.dart b/lib/presentation/onboarding/preparation_name_select/screens/preparation_name_form.dart index 6780e8aa..4785bf9e 100644 --- a/lib/presentation/onboarding/preparation_name_select/screens/preparation_name_form.dart +++ b/lib/presentation/onboarding/preparation_name_select/screens/preparation_name_form.dart @@ -6,9 +6,7 @@ import 'package:on_time_front/presentation/onboarding/preparation_name_select/co import 'package:on_time_front/presentation/onboarding/preparation_name_select/cubit/preparation_name/preparation_name_cubit.dart'; class PreparationNameForm extends StatefulWidget { - const PreparationNameForm({ - super.key, - }); + const PreparationNameForm({super.key}); @override State createState() => _PreparationNameFormState(); @@ -32,8 +30,9 @@ class _PreparationNameFormState extends State { onCreationRequested: context .read() .preparationStepCreationRequested, - onNameChanged: - context.read().preparationStepNameChanged, + onNameChanged: context + .read() + .preparationStepNameChanged, onSelectionChanged: context .read() .preparationStepSelectionChanged, diff --git a/lib/presentation/onboarding/preparation_order/components/preparation_reorderable_list.dart b/lib/presentation/onboarding/preparation_order/components/preparation_reorderable_list.dart index 68baaef6..a7e966d0 100644 --- a/lib/presentation/onboarding/preparation_order/components/preparation_reorderable_list.dart +++ b/lib/presentation/onboarding/preparation_order/components/preparation_reorderable_list.dart @@ -3,10 +3,11 @@ import 'package:on_time_front/presentation/onboarding/preparation_order/componen import 'package:on_time_front/presentation/onboarding/preparation_order/cubit/preparation_order_cubit.dart'; class PreparationReorderableList extends StatelessWidget { - const PreparationReorderableList( - {super.key, - required this.preparationOrderingList, - required this.onReorder}); + const PreparationReorderableList({ + super.key, + required this.preparationOrderingList, + required this.onReorder, + }); final List preparationOrderingList; final Function(int oldIndex, int newIndex) onReorder; @@ -14,13 +15,14 @@ class PreparationReorderableList extends StatelessWidget { @override Widget build(BuildContext context) { Widget proxyDecorator( - Widget child, int index, Animation animation) { + Widget child, + int index, + Animation animation, + ) { return AnimatedBuilder( animation: animation, builder: (BuildContext context, Widget? child) { - return SizedBox( - child: child, - ); + return SizedBox(child: child); }, child: child, ); @@ -37,8 +39,9 @@ class PreparationReorderableList extends StatelessWidget { key: ValueKey(preparationOrderingList[index].preparationId), padding: const EdgeInsets.only(bottom: 8.0), child: ReorderableTile( - preparationStepOrderState: preparationOrderingList[index], - index: index), + preparationStepOrderState: preparationOrderingList[index], + index: index, + ), ), onReorderItem: (oldIndex, newIndex) { final legacyNewIndex = oldIndex < newIndex ? newIndex + 1 : newIndex; diff --git a/lib/presentation/onboarding/preparation_order/cubit/preparation_order_cubit.dart b/lib/presentation/onboarding/preparation_order/cubit/preparation_order_cubit.dart index bf7ccb59..584a7b9d 100644 --- a/lib/presentation/onboarding/preparation_order/cubit/preparation_order_cubit.dart +++ b/lib/presentation/onboarding/preparation_order/cubit/preparation_order_cubit.dart @@ -5,9 +5,8 @@ import 'package:on_time_front/presentation/onboarding/cubit/onboarding_cubit.dar part 'preparation_order_state.dart'; class PreparationOrderCubit extends Cubit { - PreparationOrderCubit({ - required this.onboardingCubit, - }) : super(PreparationOrderState()) { + PreparationOrderCubit({required this.onboardingCubit}) + : super(PreparationOrderState()) { initialize(); } @@ -24,8 +23,9 @@ class PreparationOrderCubit extends Cubit { } final List preparationStepList = List.from(state.preparationStepList); - final PreparationStepOrderState item = - preparationStepList.removeAt(oldIndex); + final PreparationStepOrderState item = preparationStepList.removeAt( + oldIndex, + ); preparationStepList.insert(newIndex, item); emit(state.copyWith(preparationStepList: preparationStepList)); } diff --git a/lib/presentation/onboarding/preparation_order/cubit/preparation_order_state.dart b/lib/presentation/onboarding/preparation_order/cubit/preparation_order_state.dart index 16270b08..4a4d3287 100644 --- a/lib/presentation/onboarding/preparation_order/cubit/preparation_order_state.dart +++ b/lib/presentation/onboarding/preparation_order/cubit/preparation_order_state.dart @@ -1,9 +1,7 @@ part of 'preparation_order_cubit.dart'; class PreparationOrderState extends Equatable { - const PreparationOrderState({ - this.preparationStepList = const [], - }); + const PreparationOrderState({this.preparationStepList = const []}); final List preparationStepList; @@ -22,8 +20,10 @@ class PreparationOrderState extends Equatable { // Check if the order does not exist // If all the nextPreparationId is null, it means the order does not exist - bool orderNotExists = - onboardingPreparationStepList.fold(true, (bool flag, element) { + bool orderNotExists = onboardingPreparationStepList.fold(true, ( + bool flag, + element, + ) { if (flag) { return element.nextPreparationId == null; } @@ -33,8 +33,12 @@ class PreparationOrderState extends Equatable { if (orderNotExists) { return PreparationOrderState( preparationStepList: onboardingPreparationStepList - .map((e) => - PreparationStepOrderState.fromOnboardingPreparationStepState(e)) + .map( + (e) => + PreparationStepOrderState.fromOnboardingPreparationStepState( + e, + ), + ) .toList(), ); } @@ -45,8 +49,10 @@ class PreparationOrderState extends Equatable { if (onboardingPreparationStepList[j].nextPreparationId == nextPreparationId) { preparationStepList.add( - PreparationStepOrderState.fromOnboardingPreparationStepState( - onboardingPreparationStepList[j])); + PreparationStepOrderState.fromOnboardingPreparationStepState( + onboardingPreparationStepList[j], + ), + ); nextPreparationId = onboardingPreparationStepList[j].id; break; } @@ -60,12 +66,15 @@ class PreparationOrderState extends Equatable { OnboardingState toOnboardingState() { final List preparationStepList = []; for (int i = 0; i < this.preparationStepList.length; i++) { - preparationStepList.add(OnboardingPreparationStepState( + preparationStepList.add( + OnboardingPreparationStepState( id: this.preparationStepList[i].preparationId, preparationName: this.preparationStepList[i].preparationName, nextPreparationId: i == this.preparationStepList.length - 1 ? null - : this.preparationStepList[i + 1].preparationId)); + : this.preparationStepList[i + 1].preparationId, + ), + ); } return OnboardingState(preparationStepList: preparationStepList); } @@ -84,7 +93,8 @@ class PreparationStepOrderState extends Equatable { final String preparationName; static PreparationStepOrderState fromOnboardingPreparationStepState( - OnboardingPreparationStepState state) { + OnboardingPreparationStepState state, + ) { return PreparationStepOrderState( preparationId: state.id, preparationName: state.preparationName, 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 0aaf495c..d0dcf513 100644 --- a/lib/presentation/onboarding/preparation_order/screens/preparation_order_form.dart +++ b/lib/presentation/onboarding/preparation_order/screens/preparation_order_form.dart @@ -6,9 +6,7 @@ import 'package:on_time_front/presentation/onboarding/preparation_order/cubit/pr import 'package:on_time_front/l10n/app_localizations.dart'; class PreparationOrderForm extends StatefulWidget { - const PreparationOrderForm({ - super.key, - }); + const PreparationOrderForm({super.key}); @override State createState() => _PreparationOrderFormState(); @@ -30,9 +28,10 @@ class _PreparationOrderFormState extends State { return PreparationReorderableList( preparationOrderingList: state.preparationStepList, onReorder: (oldIndex, newIndex) { - context - .read() - .preparationOrderChanged(oldIndex, newIndex); + context.read().preparationOrderChanged( + oldIndex, + newIndex, + ); }, ); }, 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 e2882d38..d8bc86c6 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 @@ -30,9 +30,10 @@ class _PreparationTimeInputFieldListState itemBuilder: (context, index) { final value = widget.preparationTimeList[index]; return PreparationTimeTile( - value: value, - index: index, - onPreparationTimeChanged: widget.onPreparationTimeChanged); + value: value, + index: index, + onPreparationTimeChanged: widget.onPreparationTimeChanged, + ); }, ), ); 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 ec782741..df52d355 100644 --- a/lib/presentation/onboarding/preparation_time/components/preparation_time_tile.dart +++ b/lib/presentation/onboarding/preparation_time/components/preparation_time_tile.dart @@ -37,8 +37,10 @@ class PreparationTimeTile extends StatelessWidget { borderRadius: BorderRadius.circular(4), ), child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8.0, vertical: 1.0), + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 1.0, + ), child: Text( (value.preparationTime.value.inMinutes < 10 ? '0' : '') + (value.preparationTime.value.inMinutes < 0 diff --git a/lib/presentation/onboarding/preparation_time/cubit/preparation_time_cubit.dart b/lib/presentation/onboarding/preparation_time/cubit/preparation_time_cubit.dart index 07e10698..44c4d930 100644 --- a/lib/presentation/onboarding/preparation_time/cubit/preparation_time_cubit.dart +++ b/lib/presentation/onboarding/preparation_time/cubit/preparation_time_cubit.dart @@ -7,23 +7,26 @@ import 'package:on_time_front/presentation/onboarding/preparation_time/input_mod part 'preparation_time_state.dart'; class PreparationTimeCubit extends Cubit { - PreparationTimeCubit({ - required this.onboardingCubit, - }) : super(PreparationTimeState()) { + PreparationTimeCubit({required this.onboardingCubit}) + : super(PreparationTimeState()) { initialize(); } final OnboardingCubit onboardingCubit; void initialize() { - final preparationTimeState = - PreparationTimeState.fromOnboardingState(onboardingCubit.state); + final preparationTimeState = PreparationTimeState.fromOnboardingState( + onboardingCubit.state, + ); - emit(state.copyWith( - preparationTimeList: preparationTimeState.preparationTimeList, - )); + emit( + state.copyWith( + preparationTimeList: preparationTimeState.preparationTimeList, + ), + ); onboardingCubit.onboardingFormValidated( - isValid: preparationTimeState.isValid); + isValid: preparationTimeState.isValid, + ); } void preparationTimeChanged(int index, Duration preparationTime) { @@ -32,15 +35,14 @@ class PreparationTimeCubit extends Cubit { preparationTimeList[index] = preparationTimeList[index].copyWith( preparationTime: PreparationTimeInputModel.dirty(preparationTime), ); - emit(state.copyWith( - preparationTimeList: preparationTimeList, - )); + emit(state.copyWith(preparationTimeList: preparationTimeList)); onboardingCubit.onboardingFormValidated(isValid: state.isValid); } void preparationTimeSaved() { - final newList = - state.toOnboardingState(onboardingCubit.state).preparationStepList; + final newList = state + .toOnboardingState(onboardingCubit.state) + .preparationStepList; onboardingCubit.onboardingFormChanged(preparationStepList: newList); } } diff --git a/lib/presentation/onboarding/preparation_time/cubit/preparation_time_state.dart b/lib/presentation/onboarding/preparation_time/cubit/preparation_time_state.dart index e674e16f..8e8c3fd0 100644 --- a/lib/presentation/onboarding/preparation_time/cubit/preparation_time_state.dart +++ b/lib/presentation/onboarding/preparation_time/cubit/preparation_time_state.dart @@ -1,13 +1,12 @@ part of 'preparation_time_cubit.dart'; class PreparationTimeState extends Equatable { - const PreparationTimeState({ - this.preparationTimeList = const [], - }); + const PreparationTimeState({this.preparationTimeList = const []}); final List preparationTimeList; bool get isValid => Formz.validate( - preparationTimeList.map((e) => e.preparationTime).toList()); + preparationTimeList.map((e) => e.preparationTime).toList(), + ); PreparationTimeState copyWith({ List? preparationTimeList, @@ -20,15 +19,16 @@ class PreparationTimeState extends Equatable { static PreparationTimeState fromOnboardingState(OnboardingState state) { final preparationTimeList = state.preparationStepList - .map((e) => - PreparationStepTimeState.fromOnboardingPreparationStepState(e)) + .map( + (e) => PreparationStepTimeState.fromOnboardingPreparationStepState(e), + ) .toList(); return PreparationTimeState(preparationTimeList: preparationTimeList); } OnboardingState toOnboardingState(OnboardingState oldState) { final List - onboardingPreparationStepStateList = []; + onboardingPreparationStepStateList = []; int j = 0; for (int i = 0; i < preparationTimeList.length; i++) { while (j < oldState.preparationStepList.length && @@ -39,10 +39,11 @@ class PreparationTimeState extends Equatable { if (j == oldState.preparationStepList.length) { continue; } - onboardingPreparationStepStateList - .add(oldState.preparationStepList[j].copyWith( - preparationTime: preparationTimeList[i].preparationTime.value, - )); + onboardingPreparationStepStateList.add( + oldState.preparationStepList[j].copyWith( + preparationTime: preparationTimeList[i].preparationTime.value, + ), + ); } return oldState.copyWith( preparationStepList: onboardingPreparationStepStateList, @@ -75,7 +76,8 @@ class PreparationStepTimeState extends Equatable { } static PreparationStepTimeState fromOnboardingPreparationStepState( - OnboardingPreparationStepState state) { + OnboardingPreparationStepState state, + ) { return PreparationStepTimeState( preparationId: state.id, preparationName: state.preparationName, diff --git a/lib/presentation/onboarding/preparation_time/input_models/preparation_time_input_model.dart b/lib/presentation/onboarding/preparation_time/input_models/preparation_time_input_model.dart index 43f6feb6..204e245b 100644 --- a/lib/presentation/onboarding/preparation_time/input_models/preparation_time_input_model.dart +++ b/lib/presentation/onboarding/preparation_time/input_models/preparation_time_input_model.dart @@ -1,5 +1,5 @@ import 'package:formz/formz.dart'; -import 'package:on_time_front/core/validation/backend_constraints.dart'; +import 'package:on_time_front/core/validation/local_input_limits.dart'; /// Validation errors for the [PreparationTimeInputModel] [FormzInput]. enum PreparationTimeValidationError { zero, negative, tooLarge } @@ -20,7 +20,7 @@ class PreparationTimeInputModel if (minutes == 0) { return PreparationTimeValidationError.zero; } - if (minutes > BackendConstraints.maxMinuteValue) { + if (minutes > LocalInputLimits.maxMinuteValue) { return PreparationTimeValidationError.tooLarge; } return null; 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 d838ea2e..6cce3e0b 100644 --- a/lib/presentation/onboarding/preparation_time/screens/preparation_time_form.dart +++ b/lib/presentation/onboarding/preparation_time/screens/preparation_time_form.dart @@ -28,9 +28,10 @@ class _PreparationTimeFormState extends State { return PreparationTimeInputFieldList( preparationTimeList: state.preparationTimeList, onPreparationTimeChanged: (index, value) { - context - .read() - .preparationTimeChanged(index, value); + context.read().preparationTimeChanged( + index, + value, + ); }, ); }, diff --git a/lib/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_cubit.dart b/lib/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_cubit.dart index 7083de28..c00f1274 100644 --- a/lib/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_cubit.dart +++ b/lib/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_cubit.dart @@ -5,9 +5,8 @@ import 'package:on_time_front/presentation/onboarding/cubit/onboarding_cubit.dar part 'schedule_spare_time_state.dart'; class ScheduleSpareTimeCubit extends Cubit { - ScheduleSpareTimeCubit({ - required this.onboardingCubit, - }) : super(ScheduleSpareTimeState()); + ScheduleSpareTimeCubit({required this.onboardingCubit}) + : super(ScheduleSpareTimeState()); final OnboardingCubit onboardingCubit; final Duration lowerBound = Duration(minutes: 10); diff --git a/lib/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_state.dart b/lib/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_state.dart index c6c5b0e6..3c221ee4 100644 --- a/lib/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_state.dart +++ b/lib/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_state.dart @@ -1,24 +1,17 @@ part of 'schedule_spare_time_cubit.dart'; class ScheduleSpareTimeState extends Equatable { - ScheduleSpareTimeState({ - Duration? spareTime, - }) : spareTime = spareTime ?? Duration(minutes: 10); + ScheduleSpareTimeState({Duration? spareTime}) + : spareTime = spareTime ?? Duration(minutes: 10); final Duration spareTime; factory ScheduleSpareTimeState.fromOnboardingState(OnboardingState state) { - return ScheduleSpareTimeState( - spareTime: state.spareTime, - ); + return ScheduleSpareTimeState(spareTime: state.spareTime); } - ScheduleSpareTimeState copyWith({ - Duration? spareTime, - }) { - return ScheduleSpareTimeState( - spareTime: spareTime ?? this.spareTime, - ); + ScheduleSpareTimeState copyWith({Duration? spareTime}) { + return ScheduleSpareTimeState(spareTime: spareTime ?? this.spareTime); } @override diff --git a/lib/presentation/onboarding/schedule_spare_time/screens/schedule_spare_time_form.dart b/lib/presentation/onboarding/schedule_spare_time/screens/schedule_spare_time_form.dart index 3c135592..eb078ee8 100644 --- a/lib/presentation/onboarding/schedule_spare_time/screens/schedule_spare_time_form.dart +++ b/lib/presentation/onboarding/schedule_spare_time/screens/schedule_spare_time_form.dart @@ -4,9 +4,7 @@ import 'package:on_time_front/presentation/onboarding/components/onboarding_page import 'package:on_time_front/presentation/onboarding/schedule_spare_time/components/shcedule_spare_time_field.dart'; class ScheduleSpareTimeForm extends StatefulWidget { - const ScheduleSpareTimeForm({ - super.key, - }); + const ScheduleSpareTimeForm({super.key}); @override State createState() => _ScheduleSpareTimeFormState(); @@ -25,9 +23,7 @@ class _ScheduleSpareTimeFormState extends State { subTitle: RichText( text: TextSpan( text: '${AppLocalizations.of(context)!.setSpareTimeDescription}\n', - style: textTheme.titleSmall?.copyWith( - color: colorScheme.outline, - ), + style: textTheme.titleSmall?.copyWith(color: colorScheme.outline), children: [ TextSpan( text: AppLocalizations.of(context)!.setSpareTimeWarning, @@ -42,8 +38,9 @@ class _ScheduleSpareTimeFormState extends State { child: ScheduleSpareTimeField( lowerBound: lowerBound, spareTime: spareTime, - minimumWarningMessage: - AppLocalizations.of(context)!.spareTimeMinimumWarning, + minimumWarningMessage: AppLocalizations.of( + context, + )!.spareTimeMinimumWarning, onSpareTimeDecreased: () { setState(() { final updatedSpareTime = spareTime - Duration(minutes: 10); diff --git a/lib/presentation/onboarding/screens/onboarding_start_screen.dart b/lib/presentation/onboarding/screens/onboarding_start_screen.dart index 9d62a79e..5e442409 100644 --- a/lib/presentation/onboarding/screens/onboarding_start_screen.dart +++ b/lib/presentation/onboarding/screens/onboarding_start_screen.dart @@ -47,13 +47,18 @@ class _Title extends StatelessWidget { return Column( mainAxisSize: MainAxisSize.min, children: [ - Text(AppLocalizations.of(context)!.welcome, - key: Key('onboarding_start_title'), style: textTheme.headlineSmall), + Text( + AppLocalizations.of(context)!.welcome, + key: Key('onboarding_start_title'), + style: textTheme.headlineSmall, + ), SizedBox(height: 9), - Text(AppLocalizations.of(context)!.onboardingStartSubtitle, - textAlign: TextAlign.center, - key: Key('onboarding_start_subtitle'), - style: textTheme.titleExtraSmall), + Text( + AppLocalizations.of(context)!.onboardingStartSubtitle, + textAlign: TextAlign.center, + key: Key('onboarding_start_subtitle'), + style: textTheme.titleExtraSmall, + ), ], ); } diff --git a/lib/presentation/schedule_create/bloc/schedule_form_bloc.dart b/lib/presentation/schedule_create/bloc/schedule_form_bloc.dart index b919a483..1e66fc1e 100644 --- a/lib/presentation/schedule_create/bloc/schedule_form_bloc.dart +++ b/lib/presentation/schedule_create/bloc/schedule_form_bloc.dart @@ -1,7 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; -import 'package:on_time_front/core/dio/api_error_message.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/schedule_entity.dart'; @@ -96,6 +95,7 @@ class ScheduleFormBloc extends Bloc { event.scheduleTime.hour, event.scheduleTime.minute, ), + occurrenceOffsetSeconds: event.occurrenceOffsetSeconds, maxAvailableTime: event.maxAvailableTime, previousScheduleName: event.previousScheduleName, ), @@ -168,7 +168,7 @@ class ScheduleFormBloc extends Bloc { emit( state.copyWith( submissionStatus: ScheduleFormSubmissionStatus.failure, - submissionError: ApiErrorMessage.fromException(e) ?? e.toString(), + submissionError: e.toString(), ), ); } @@ -204,7 +204,7 @@ class ScheduleFormBloc extends Bloc { emit( state.copyWith( submissionStatus: ScheduleFormSubmissionStatus.failure, - submissionError: ApiErrorMessage.fromException(e) ?? e.toString(), + submissionError: e.toString(), ), ); } @@ -231,6 +231,8 @@ class ScheduleFormBloc extends Bloc { placeName: draft.placeName, scheduleName: draft.scheduleName, scheduleTime: draft.scheduleTime, + timeZoneId: draft.timeZoneId, + occurrenceOffsetSeconds: draft.occurrenceOffsetSeconds, moveTime: draft.moveTime, isChanged: draft.preparationChanged ? IsPreparationChanged.changed diff --git a/lib/presentation/schedule_create/bloc/schedule_form_event.dart b/lib/presentation/schedule_create/bloc/schedule_form_event.dart index a505cacf..e540e4db 100644 --- a/lib/presentation/schedule_create/bloc/schedule_form_event.dart +++ b/lib/presentation/schedule_create/bloc/schedule_form_event.dart @@ -44,12 +44,14 @@ final class ScheduleFormScheduleNameChanged extends ScheduleFormEvent { final class ScheduleFormScheduleDateTimeChanged extends ScheduleFormEvent { final DateTime scheduleDate; final DateTime scheduleTime; + final int occurrenceOffsetSeconds; final Duration? maxAvailableTime; final String? previousScheduleName; const ScheduleFormScheduleDateTimeChanged({ required this.scheduleDate, required this.scheduleTime, + required this.occurrenceOffsetSeconds, this.maxAvailableTime, this.previousScheduleName, }); @@ -58,6 +60,7 @@ final class ScheduleFormScheduleDateTimeChanged extends ScheduleFormEvent { List get props => [ scheduleDate, scheduleTime, + occurrenceOffsetSeconds, maxAvailableTime ?? const Duration(days: -999999), previousScheduleName ?? '', ]; diff --git a/lib/presentation/schedule_create/bloc/schedule_form_state.dart b/lib/presentation/schedule_create/bloc/schedule_form_state.dart index 31d65e12..0fd3ab8f 100644 --- a/lib/presentation/schedule_create/bloc/schedule_form_state.dart +++ b/lib/presentation/schedule_create/bloc/schedule_form_state.dart @@ -17,6 +17,8 @@ final class ScheduleFormState extends Equatable { final String? placeName; final String? scheduleName; final DateTime? scheduleTime; + final String timeZoneId; + final int? occurrenceOffsetSeconds; final Duration? moveTime; final IsPreparationChanged isChanged; final Duration? scheduleSpareTime; @@ -36,6 +38,8 @@ final class ScheduleFormState extends Equatable { this.placeName, this.scheduleName, this.scheduleTime, + this.timeZoneId = 'UTC', + this.occurrenceOffsetSeconds, this.moveTime, this.isChanged = IsPreparationChanged.unchanged, this.scheduleSpareTime, @@ -56,6 +60,8 @@ final class ScheduleFormState extends Equatable { String? placeName, String? scheduleName, DateTime? scheduleTime, + String? timeZoneId, + int? occurrenceOffsetSeconds, Duration? moveTime, IsPreparationChanged? isChanged, Duration? scheduleSpareTime, @@ -77,6 +83,9 @@ final class ScheduleFormState extends Equatable { placeName: placeName ?? this.placeName, scheduleName: scheduleName ?? this.scheduleName, scheduleTime: scheduleTime ?? this.scheduleTime, + timeZoneId: timeZoneId ?? this.timeZoneId, + occurrenceOffsetSeconds: + occurrenceOffsetSeconds ?? this.occurrenceOffsetSeconds, moveTime: moveTime ?? this.moveTime, isChanged: isChanged ?? this.isChanged, scheduleSpareTime: scheduleSpareTime ?? this.scheduleSpareTime, @@ -106,6 +115,10 @@ final class ScheduleFormState extends Equatable { ), scheduleName: state.scheduleName!, scheduleTime: state.scheduleTime!, + timeZoneId: state.timeZoneId, + occurrenceOffsetSeconds: + state.occurrenceOffsetSeconds ?? + state.scheduleTime!.timeZoneOffset.inSeconds, moveTime: state.moveTime!, isChanged: !(state.isChanged == IsPreparationChanged.unchanged), scheduleSpareTime: state.scheduleSpareTime, @@ -124,6 +137,8 @@ final class ScheduleFormState extends Equatable { placeName, scheduleName, scheduleTime, + timeZoneId, + occurrenceOffsetSeconds, moveTime, isChanged, scheduleSpareTime, diff --git a/lib/presentation/schedule_create/components/message_bubble.dart b/lib/presentation/schedule_create/components/message_bubble.dart index b2088547..974d9efe 100644 --- a/lib/presentation/schedule_create/components/message_bubble.dart +++ b/lib/presentation/schedule_create/components/message_bubble.dart @@ -1,16 +1,9 @@ import 'package:flutter/material.dart'; -enum MessageBubbleType { - warning, - error, -} +enum MessageBubbleType { warning, error } class MessageBubble extends StatelessWidget { - const MessageBubble({ - super.key, - required this.message, - required this.type, - }); + const MessageBubble({super.key, required this.message, required this.type}); final String message; final MessageBubbleType type; @@ -24,8 +17,9 @@ class MessageBubble extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), - color: - isError ? colorScheme.errorContainer : colorScheme.primaryContainer, + color: isError + ? colorScheme.errorContainer + : colorScheme.primaryContainer, ), child: Row( children: [ @@ -41,11 +35,11 @@ class MessageBubble extends StatelessWidget { child: Text( message, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: isError - ? colorScheme.onErrorContainer - : colorScheme.onPrimaryContainer, - fontWeight: FontWeight.w500, - ), + color: isError + ? colorScheme.onErrorContainer + : colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w500, + ), ), ), ], diff --git a/lib/presentation/schedule_create/components/preparation_reorderable_list_form_field.dart b/lib/presentation/schedule_create/components/preparation_reorderable_list_form_field.dart index 763224d3..2aedc309 100644 --- a/lib/presentation/schedule_create/components/preparation_reorderable_list_form_field.dart +++ b/lib/presentation/schedule_create/components/preparation_reorderable_list_form_field.dart @@ -1,42 +1,43 @@ import 'package:flutter/material.dart'; class PreparationReorderableListFormField extends FormField> { - PreparationReorderableListFormField( - {super.key, - super.onSaved, - super.initialValue, - required this.itemCount, - required this.itemBuilder}) - : super( - builder: (FormFieldState> field) { - Widget proxyDecorator( - Widget child, int index, Animation animation) { - return AnimatedBuilder( - animation: animation, - builder: (BuildContext context, Widget? child) { - return SizedBox( - child: child, - ); - }, - child: child, - ); - } + PreparationReorderableListFormField({ + super.key, + super.onSaved, + super.initialValue, + required this.itemCount, + required this.itemBuilder, + }) : super( + builder: (FormFieldState> field) { + Widget proxyDecorator( + Widget child, + int index, + Animation animation, + ) { + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + return SizedBox(child: child); + }, + child: child, + ); + } - return ReorderableListView.builder( - physics: NeverScrollableScrollPhysics(), - shrinkWrap: true, - proxyDecorator: proxyDecorator, - itemCount: itemCount, - itemBuilder: (context, index) => - itemBuilder(context, field.value![index]), - onReorderItem: (oldIndex, newIndex) { - final item = field.value!.removeAt(oldIndex); - field.value!.insert(newIndex, item); - field.didChange(field.value!); - }, - ); - }, - ); + return ReorderableListView.builder( + physics: NeverScrollableScrollPhysics(), + shrinkWrap: true, + proxyDecorator: proxyDecorator, + itemCount: itemCount, + itemBuilder: (context, index) => + itemBuilder(context, field.value![index]), + onReorderItem: (oldIndex, newIndex) { + final item = field.value!.removeAt(oldIndex); + field.value!.insert(newIndex, item); + field.didChange(field.value!); + }, + ); + }, + ); final int itemCount; final IndexedWidgetBuilder itemBuilder; diff --git a/lib/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_cubit.dart b/lib/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_cubit.dart index 5ee756ff..ac410311 100644 --- a/lib/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_cubit.dart +++ b/lib/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_cubit.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:formz/formz.dart'; import 'package:injectable/injectable.dart'; import 'package:on_time_front/core/logging/app_logger.dart'; +import 'package:on_time_front/core/time/civil_time_resolver.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'; @@ -25,25 +26,33 @@ class ScheduleDateTimeCubit extends Cubit { final ScheduleFormBloc scheduleFormBloc; final LoadAdjacentScheduleWithPreparationUseCase - _loadAdjacentSchedulesWithPreparationUseCase; + _loadAdjacentSchedulesWithPreparationUseCase; final GetAdjacentSchedulesWithPreparationUseCase - _getNextScheduleWithPreparationUseCase; + _getNextScheduleWithPreparationUseCase; void initialize() { - final scheduleDateTimeState = - ScheduleDateTimeState.fromScheduleFormState(scheduleFormBloc.state); - emit(state.copyWith( - scheduleDate: scheduleDateTimeState.scheduleDate, - scheduleTime: scheduleDateTimeState.scheduleTime, - )); + final scheduleDateTimeState = ScheduleDateTimeState.fromScheduleFormState( + scheduleFormBloc.state, + ); + emit( + state.copyWith( + scheduleDate: scheduleDateTimeState.scheduleDate, + scheduleTime: scheduleDateTimeState.scheduleTime, + timeZoneId: scheduleDateTimeState.timeZoneId, + selectedOccurrenceOffsetSeconds: + scheduleDateTimeState.selectedOccurrenceOffsetSeconds, + ), + ); + _resolveCivilTime(); // Check for schedule overlap if both date and time are valid if (scheduleDateTimeState.scheduleDate.isValid && scheduleDateTimeState.scheduleTime.isValid) { scheduleFormBloc.add(ScheduleFormValidated(isValid: false)); // Load adjacent schedules first, then check overlap - _loadAdjacentSchedules(scheduleDateTimeState.scheduleDate.value!) - .then((_) => checkScheduleOverlap()); + _loadAdjacentSchedules( + scheduleDateTimeState.scheduleDate.value!, + ).then((_) => checkScheduleOverlap()); } else { scheduleFormBloc.add(ScheduleFormValidated(isValid: state.isValid)); } @@ -52,7 +61,15 @@ class ScheduleDateTimeCubit extends Cubit { Future scheduleDateChanged(DateTime scheduleDate) async { final ScheduleDateInputModel scheduleDateInputModel = ScheduleDateInputModel.dirty(scheduleDate); - emit(state.copyWith(scheduleDate: scheduleDateInputModel)); + emit( + state.copyWith( + scheduleDate: scheduleDateInputModel, + civilTimeResolved: false, + occurrenceOffsetOptions: const [], + selectedOccurrenceOffsetSeconds: null, + ), + ); + _resolveCivilTime(); // Always load nextSchedule when date changes if (scheduleDateInputModel.isValid) { @@ -70,7 +87,15 @@ class ScheduleDateTimeCubit extends Cubit { Future scheduleTimeChanged(DateTime scheduleTime) async { final ScheduleTimeInputModel scheduleTimeInputModel = ScheduleTimeInputModel.dirty(scheduleTime); - emit(state.copyWith(scheduleTime: scheduleTimeInputModel)); + emit( + state.copyWith( + scheduleTime: scheduleTimeInputModel, + civilTimeResolved: false, + occurrenceOffsetOptions: const [], + selectedOccurrenceOffsetSeconds: null, + ), + ); + _resolveCivilTime(); // Never load nextSchedule, only check overlap if (state.scheduleDate.isValid && scheduleTimeInputModel.isValid) { @@ -84,6 +109,42 @@ class ScheduleDateTimeCubit extends Cubit { scheduleFormBloc.add(ScheduleFormValidated(isValid: state.isValid)); } + void occurrenceOffsetSelected(int offsetSeconds) { + if (!state.occurrenceOffsetOptions.contains(offsetSeconds)) return; + emit(state.copyWith(selectedOccurrenceOffsetSeconds: offsetSeconds)); + scheduleFormBloc.add(ScheduleFormValidated(isValid: state.isValid)); + } + + void _resolveCivilTime() { + final selected = state.selectedScheduleDateTime; + if (selected == null) { + emit( + state.copyWith( + civilTimeResolved: false, + occurrenceOffsetOptions: const [], + selectedOccurrenceOffsetSeconds: null, + ), + ); + return; + } + + final options = CivilTimeResolver.resolve( + selected, + state.timeZoneId, + ).map((occurrence) => occurrence.offsetSeconds).toList(); + final existing = state.selectedOccurrenceOffsetSeconds; + final selectedOffset = options.length == 1 + ? options.single + : (options.contains(existing) ? existing : null); + emit( + state.copyWith( + civilTimeResolved: true, + occurrenceOffsetOptions: options, + selectedOccurrenceOffsetSeconds: selectedOffset, + ), + ); + } + Future _loadAdjacentSchedules(DateTime scheduleDate) async { try { // Calculate date range: previous day, selected day, and next day @@ -92,7 +153,7 @@ class ScheduleDateTimeCubit extends Cubit { final startDate = dateRange.startDate; final endDate = dateRange.endDate; - // Load schedules from server + // Load adjacent schedules from the encrypted local database. await _loadAdjacentSchedulesWithPreparationUseCase( startDate: startDate, endDate: endDate, @@ -134,17 +195,19 @@ class ScheduleDateTimeCubit extends Cubit { // Find adjacent schedules (previous and next) with preparation from stream AppLogger.debug( - 'Checking overlap for: $selectedDateTime, currentScheduleId: $currentScheduleId'); + 'Checking overlap for: $selectedDateTime, currentScheduleId: $currentScheduleId', + ); final AdjacentSchedulesWithPreparationEntity adjacentSchedules = await _getNextScheduleWithPreparationUseCase( - selectedDateTime: selectedDateTime, - currentScheduleId: currentScheduleId, - startDate: startDate, - endDate: endDate, - ); + selectedDateTime: selectedDateTime, + currentScheduleId: currentScheduleId, + startDate: startDate, + endDate: endDate, + ); AppLogger.debug( - 'Previous schedule found: ${adjacentSchedules.hasPrevious}, Next schedule found: ${adjacentSchedules.hasNext}'); + 'Previous schedule found: ${adjacentSchedules.hasPrevious}, Next schedule found: ${adjacentSchedules.hasNext}', + ); // Check overlap with next schedule if (adjacentSchedules.hasNext && adjacentSchedules.nextSchedule != null) { @@ -155,8 +218,9 @@ class ScheduleDateTimeCubit extends Cubit { final nextPreparationStartTime = nextSchedule.preparationStartTime; // Calculate time difference - final timeDifference = - nextPreparationStartTime.difference(selectedDateTime); + final timeDifference = nextPreparationStartTime.difference( + selectedDateTime, + ); final minutesDifference = timeDifference.inMinutes; AppLogger.debug( @@ -175,18 +239,19 @@ class ScheduleDateTimeCubit extends Cubit { if (minutesDifference > 0) { AppLogger.debug('Showing warning with $minutesDifference minutes'); // User requested to show only error when overlap, no warning for next schedule - emit(state.copyWith( - clearOverlap: true, - )); + emit(state.copyWith(clearOverlap: true)); } else { // Already overlapping - show as error AppLogger.debug( - 'Showing error - already overlapping (minutesDifference: $minutesDifference)'); - emit(state.copyWith( - isOverlapping: true, - nextScheduleName: nextSchedule.scheduleName, - nextPreparationStartTime: nextPreparationStartTime, - )); + 'Showing error - already overlapping (minutesDifference: $minutesDifference)', + ); + emit( + state.copyWith( + isOverlapping: true, + nextScheduleName: nextSchedule.scheduleName, + nextPreparationStartTime: nextPreparationStartTime, + ), + ); } } else { // No next schedule found, clear next overlap @@ -205,8 +270,9 @@ class ScheduleDateTimeCubit extends Cubit { // Calculate time difference // If negative, selected time is before previous schedule ends (overlapping) // If positive, selected time is after previous schedule ends (no overlap) - final timeDifference = - selectedDateTime.difference(previousScheduleEndTime); + final timeDifference = selectedDateTime.difference( + previousScheduleEndTime, + ); final minutesDifference = timeDifference.inMinutes; AppLogger.debug( @@ -226,21 +292,24 @@ class ScheduleDateTimeCubit extends Cubit { // But if it does, we treat it as available time being negative? // Or just show it as available time (which will be negative) AppLogger.debug( - 'Showing error - overlapping with previous schedule (minutesDifference: $minutesDifference)'); - emit(state.copyWith( - previousOverlapDuration: - timeDifference, // Keep negative duration? Or abs? - // If we remove isPreviousOverlapping, we just store the duration. - // The state will decide if it's a warning based on duration value. - // But wait, hasPreviousOverlapMessage logic: - // return previousOverlapDuration!.inMinutes < 180; - // If negative, it is < 180, so it returns true (warning). - // But negative means overlap, which should be error? - // The user said "impossible to overlap with previous schedule". - // So we assume minutesDifference >= 0 always? - // If so, we just handle the >= 0 case. - previousScheduleName: previousSchedule.scheduleName, - )); + 'Showing error - overlapping with previous schedule (minutesDifference: $minutesDifference)', + ); + emit( + state.copyWith( + previousOverlapDuration: + timeDifference, // Keep negative duration? Or abs? + // If we remove isPreviousOverlapping, we just store the duration. + // The state will decide if it's a warning based on duration value. + // But wait, hasPreviousOverlapMessage logic: + // return previousOverlapDuration!.inMinutes < 180; + // If negative, it is < 180, so it returns true (warning). + // But negative means overlap, which should be error? + // The user said "impossible to overlap with previous schedule". + // So we assume minutesDifference >= 0 always? + // If so, we just handle the >= 0 case. + previousScheduleName: previousSchedule.scheduleName, + ), + ); } else { // No overlap with previous schedule // Show warning only if available time is small (e.g., less than 3 hours) @@ -249,26 +318,30 @@ class ScheduleDateTimeCubit extends Cubit { if (isSmallTime) { AppLogger.debug( - 'Showing warning - small available time from previous schedule (minutesDifference: $minutesDifference)'); - emit(state.copyWith( - previousOverlapDuration: timeDifference, - previousScheduleName: previousSchedule.scheduleName, - )); + 'Showing warning - small available time from previous schedule (minutesDifference: $minutesDifference)', + ); + emit( + state.copyWith( + previousOverlapDuration: timeDifference, + previousScheduleName: previousSchedule.scheduleName, + ), + ); } else { AppLogger.debug( - 'Not showing warning - available time is large (minutesDifference: $minutesDifference)'); - emit(state.copyWith( - previousOverlapDuration: timeDifference, - previousScheduleName: previousSchedule.scheduleName, - // clearPreviousOverlap: true, // Do not clear if we want to keep the value - )); + 'Not showing warning - available time is large (minutesDifference: $minutesDifference)', + ); + emit( + state.copyWith( + previousOverlapDuration: timeDifference, + previousScheduleName: previousSchedule.scheduleName, + // clearPreviousOverlap: true, // Do not clear if we want to keep the value + ), + ); } } } else { // No previous schedule found, clear previous overlap - emit(state.copyWith( - clearPreviousOverlap: true, - )); + emit(state.copyWith(clearPreviousOverlap: true)); } } catch (e) { // On error, clear both overlaps @@ -280,9 +353,7 @@ class ScheduleDateTimeCubit extends Cubit { } bool scheduleDateTimeSubmitted() { - if (state.scheduleDate.isValid && - state.scheduleTime.isValid && - state.isOverlapping == false) { + if (state.isValid) { // If not overlapping, previousOverlapDuration holds the available time (if any) // If it is null, it means no previous schedule or cleared. // But wait, if we cleared it because it was large, we lost it? @@ -290,12 +361,15 @@ class ScheduleDateTimeCubit extends Cubit { // But then the warning would show. // I need to update ScheduleDateTimeState.hasPreviousOverlapMessage to only show if small. - scheduleFormBloc.add(ScheduleFormScheduleDateTimeChanged( - scheduleDate: state.scheduleDate.value!, - scheduleTime: state.scheduleTime.value!, - maxAvailableTime: state.previousOverlapDuration, - previousScheduleName: state.previousScheduleName, - )); + scheduleFormBloc.add( + ScheduleFormScheduleDateTimeChanged( + scheduleDate: state.scheduleDate.value!, + scheduleTime: state.scheduleTime.value!, + occurrenceOffsetSeconds: state.selectedOccurrenceOffsetSeconds!, + maxAvailableTime: state.previousOverlapDuration, + previousScheduleName: state.previousScheduleName, + ), + ); return true; } diff --git a/lib/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_state.dart b/lib/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_state.dart index 67500c3f..9c507e52 100644 --- a/lib/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_state.dart +++ b/lib/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_state.dart @@ -1,6 +1,8 @@ part of 'schedule_date_time_cubit.dart'; class ScheduleDateTimeState extends Equatable { + static const _unset = Object(); + const ScheduleDateTimeState({ this.scheduleDate = const ScheduleDateInputModel.pure(), this.scheduleTime = const ScheduleTimeInputModel.pure(), @@ -9,6 +11,10 @@ class ScheduleDateTimeState extends Equatable { this.nextPreparationStartTime, this.previousOverlapDuration, this.previousScheduleName, + this.timeZoneId = 'UTC', + this.civilTimeResolved = false, + this.occurrenceOffsetOptions = const [], + this.selectedOccurrenceOffsetSeconds, }); final ScheduleDateInputModel scheduleDate; @@ -18,11 +24,29 @@ class ScheduleDateTimeState extends Equatable { final DateTime? nextPreparationStartTime; final Duration? previousOverlapDuration; final String? previousScheduleName; + final String timeZoneId; + final bool civilTimeResolved; + final List occurrenceOffsetOptions; + final int? selectedOccurrenceOffsetSeconds; + + bool get isNonexistentCivilTime => + civilTimeResolved && occurrenceOffsetOptions.isEmpty; + + bool get requiresOccurrenceChoice => + civilTimeResolved && + occurrenceOffsetOptions.length > 1 && + selectedOccurrenceOffsetSeconds == null; + + bool get hasAmbiguousCivilTime => + civilTimeResolved && occurrenceOffsetOptions.length > 1; bool get isValid => Formz.validate([scheduleDate, scheduleTime]) && !isOverlapping && - !isPastScheduleTime; + !isPastScheduleTime && + !isNonexistentCivilTime && + !requiresOccurrenceChoice && + selectedOccurrenceOffsetSeconds != null; DateTime? get selectedScheduleDateTime { if (scheduleDate.value == null || scheduleTime.value == null) { @@ -107,6 +131,10 @@ class ScheduleDateTimeState extends Equatable { String? previousScheduleName, bool clearOverlap = false, bool clearPreviousOverlap = false, + String? timeZoneId, + bool? civilTimeResolved, + List? occurrenceOffsetOptions, + Object? selectedOccurrenceOffsetSeconds = _unset, }) { return ScheduleDateTimeState( scheduleDate: scheduleDate ?? this.scheduleDate, @@ -126,6 +154,14 @@ class ScheduleDateTimeState extends Equatable { previousScheduleName: clearPreviousOverlap ? null : (previousScheduleName ?? this.previousScheduleName), + timeZoneId: timeZoneId ?? this.timeZoneId, + civilTimeResolved: civilTimeResolved ?? this.civilTimeResolved, + occurrenceOffsetOptions: + occurrenceOffsetOptions ?? this.occurrenceOffsetOptions, + selectedOccurrenceOffsetSeconds: + identical(selectedOccurrenceOffsetSeconds, _unset) + ? this.selectedOccurrenceOffsetSeconds + : selectedOccurrenceOffsetSeconds as int?, ); } @@ -133,6 +169,8 @@ class ScheduleDateTimeState extends Equatable { return ScheduleDateTimeState( scheduleDate: ScheduleDateInputModel.pure(state.scheduleTime), scheduleTime: ScheduleTimeInputModel.pure(state.scheduleTime), + timeZoneId: state.timeZoneId, + selectedOccurrenceOffsetSeconds: state.occurrenceOffsetSeconds, ); } @@ -145,5 +183,9 @@ class ScheduleDateTimeState extends Equatable { nextPreparationStartTime ?? DateTime(0), previousOverlapDuration ?? const Duration(), previousScheduleName ?? '', + timeZoneId, + civilTimeResolved, + occurrenceOffsetOptions, + selectedOccurrenceOffsetSeconds ?? 0, ]; } 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 28137b0f..b2784df3 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 @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/core/time/civil_time_resolver.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_cubit.dart'; import 'package:on_time_front/presentation/shared/components/cupertino_picker_modal.dart'; import 'package:on_time_front/presentation/schedule_create/components/message_bubble.dart'; @@ -125,6 +126,56 @@ class ScheduleDateTimeForm extends StatelessWidget { type: MessageBubbleType.error, ), ), + if (state.isNonexistentCivilTime) + Padding( + padding: const EdgeInsets.only(top: 8.0, left: 16.0), + child: MessageBubble( + message: _dstGapMessage(context), + type: MessageBubbleType.error, + ), + ), + if (state.hasAmbiguousCivilTime) + Padding( + padding: const EdgeInsets.only(top: 12.0, left: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _dstOverlapMessage(context), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for ( + var index = 0; + index < state.occurrenceOffsetOptions.length; + index++ + ) + ChoiceChip( + label: Text( + _occurrenceLabel( + context, + index, + state.occurrenceOffsetOptions[index], + ), + ), + selected: + state.selectedOccurrenceOffsetSeconds == + state.occurrenceOffsetOptions[index], + onSelected: (_) => context + .read() + .occurrenceOffsetSelected( + state.occurrenceOffsetOptions[index], + ), + ), + ], + ), + ], + ), + ), if (state.isOverlapping) Padding( padding: const EdgeInsets.only(top: 8.0, left: 16.0), @@ -140,6 +191,25 @@ class ScheduleDateTimeForm extends StatelessWidget { } } +String _dstGapMessage(BuildContext context) { + return Localizations.localeOf(context).languageCode == 'ko' + ? '일광절약시간 변경으로 존재하지 않는 시각이에요. 다른 시간을 선택해주세요.' + : 'This time does not exist because of a daylight-saving change. Choose another time.'; +} + +String _dstOverlapMessage(BuildContext context) { + return Localizations.localeOf(context).languageCode == 'ko' + ? '이 시각은 두 번 발생해요. 사용할 시각을 선택해주세요.' + : 'This time occurs twice. Choose which occurrence to use.'; +} + +String _occurrenceLabel(BuildContext context, int index, int offsetSeconds) { + final occurrence = Localizations.localeOf(context).languageCode == 'ko' + ? (index == 0 ? '첫 번째' : '두 번째') + : (index == 0 ? 'First' : 'Second'); + return '$occurrence (${CivilTimeResolver.formatUtcOffset(offsetSeconds)})'; +} + String _localizedDateString(BuildContext context, DateTime date) { final locale = Localizations.localeOf(context).languageCode; if (locale == 'ko') { diff --git a/lib/presentation/schedule_create/schedule_name/cubit/schedule_name_cubit.dart b/lib/presentation/schedule_create/schedule_name/cubit/schedule_name_cubit.dart index 86018c32..95ee5606 100644 --- a/lib/presentation/schedule_create/schedule_name/cubit/schedule_name_cubit.dart +++ b/lib/presentation/schedule_create/schedule_name/cubit/schedule_name_cubit.dart @@ -7,37 +7,33 @@ import 'package:on_time_front/presentation/schedule_create/schedule_name/input_m part 'schedule_name_state.dart'; class ScheduleNameCubit extends Cubit { - ScheduleNameCubit({ - required this.scheduleFormBloc, - }) : super(ScheduleNameState()) { + ScheduleNameCubit({required this.scheduleFormBloc}) + : super(ScheduleNameState()) { initialize(); } final ScheduleFormBloc scheduleFormBloc; void initialize() { - final scheduleNameState = - ScheduleNameState.fromScheduleFormState(scheduleFormBloc.state); - emit(state.copyWith( - scheduleName: scheduleNameState.scheduleName, - )); + final scheduleNameState = ScheduleNameState.fromScheduleFormState( + scheduleFormBloc.state, + ); + emit(state.copyWith(scheduleName: scheduleNameState.scheduleName)); scheduleFormBloc.add(ScheduleFormValidated(isValid: state.isValid)); } void scheduleNameChanged(String scheduleName) { final ScheduleNameInputModel scheduleNameInputModel = ScheduleNameInputModel.dirty(scheduleName); - emit(state.copyWith( - scheduleName: scheduleNameInputModel, - )); + emit(state.copyWith(scheduleName: scheduleNameInputModel)); scheduleFormBloc.add(ScheduleFormValidated(isValid: state.isValid)); } void scheduleNameSubmitted() { if (state.scheduleName.isValid) { - scheduleFormBloc.add(ScheduleFormScheduleNameChanged( - scheduleName: state.scheduleName.value, - )); + scheduleFormBloc.add( + ScheduleFormScheduleNameChanged(scheduleName: state.scheduleName.value), + ); } } } diff --git a/lib/presentation/schedule_create/schedule_name/cubit/schedule_name_state.dart b/lib/presentation/schedule_create/schedule_name/cubit/schedule_name_state.dart index dc261921..6c4eceeb 100644 --- a/lib/presentation/schedule_create/schedule_name/cubit/schedule_name_state.dart +++ b/lib/presentation/schedule_create/schedule_name/cubit/schedule_name_state.dart @@ -9,12 +9,8 @@ class ScheduleNameState extends Equatable { bool get isValid => Formz.validate([scheduleName]); - ScheduleNameState copyWith({ - ScheduleNameInputModel? scheduleName, - }) { - return ScheduleNameState( - scheduleName: scheduleName ?? this.scheduleName, - ); + ScheduleNameState copyWith({ScheduleNameInputModel? scheduleName}) { + return ScheduleNameState(scheduleName: scheduleName ?? this.scheduleName); } static ScheduleNameState fromScheduleFormState(ScheduleFormState state) { @@ -24,9 +20,7 @@ class ScheduleNameState extends Equatable { } ScheduleFormState toScheduleFormState(ScheduleFormState oldState) { - return oldState.copyWith( - scheduleName: scheduleName.value, - ); + return oldState.copyWith(scheduleName: scheduleName.value); } @override diff --git a/lib/presentation/schedule_create/schedule_name/input_models/schedule_name_input_model.dart b/lib/presentation/schedule_create/schedule_name/input_models/schedule_name_input_model.dart index 9384d3e5..cfcc617c 100644 --- a/lib/presentation/schedule_create/schedule_name/input_models/schedule_name_input_model.dart +++ b/lib/presentation/schedule_create/schedule_name/input_models/schedule_name_input_model.dart @@ -1,5 +1,5 @@ import 'package:formz/formz.dart'; -import 'package:on_time_front/core/validation/backend_constraints.dart'; +import 'package:on_time_front/core/validation/local_input_limits.dart'; /// Validation errors for the [ScheduleNameInputModel] [FormzInput]. enum ScheduleNameValidationError { empty, tooLong } @@ -15,7 +15,7 @@ class ScheduleNameInputModel if (trimmedValue.isEmpty) { return ScheduleNameValidationError.empty; } - if (trimmedValue.length > BackendConstraints.maxScheduleNameLength) { + if (trimmedValue.length > LocalInputLimits.maxScheduleNameLength) { return ScheduleNameValidationError.tooLong; } return null; diff --git a/lib/presentation/schedule_create/schedule_name/screens/schedule_name_form.dart b/lib/presentation/schedule_create/schedule_name/screens/schedule_name_form.dart index fa7e6f79..3467fe44 100644 --- a/lib/presentation/schedule_create/schedule_name/screens/schedule_name_form.dart +++ b/lib/presentation/schedule_create/schedule_name/screens/schedule_name_form.dart @@ -4,9 +4,7 @@ import 'package:on_time_front/presentation/schedule_create/schedule_name/cubit/s import 'package:on_time_front/l10n/app_localizations.dart'; class ScheduleNameForm extends StatefulWidget { - const ScheduleNameForm({ - super.key, - }); + const ScheduleNameForm({super.key}); @override State createState() => _ScheduleNameFormState(); @@ -16,18 +14,19 @@ class _ScheduleNameFormState extends State { @override Widget build(BuildContext context) { return BlocBuilder( - builder: (context, state) { - return TextFormField( - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.appointmentName, - hintText: AppLocalizations.of(context)!.appointmentNameHint, - ), - textInputAction: TextInputAction.done, - initialValue: state.scheduleName.value, - onChanged: (scheduleName) { - context.read().scheduleNameChanged(scheduleName); - }, - ); - }); + builder: (context, state) { + return TextFormField( + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.appointmentName, + hintText: AppLocalizations.of(context)!.appointmentNameHint, + ), + textInputAction: TextInputAction.done, + initialValue: state.scheduleName.value, + onChanged: (scheduleName) { + context.read().scheduleNameChanged(scheduleName); + }, + ); + }, + ); } } diff --git a/lib/presentation/schedule_create/schedule_place_moving_time/cubit/schedule_place_moving_time_cubit.dart b/lib/presentation/schedule_create/schedule_place_moving_time/cubit/schedule_place_moving_time_cubit.dart index ba72eb07..2c71754e 100644 --- a/lib/presentation/schedule_create/schedule_place_moving_time/cubit/schedule_place_moving_time_cubit.dart +++ b/lib/presentation/schedule_create/schedule_place_moving_time/cubit/schedule_place_moving_time_cubit.dart @@ -12,14 +12,15 @@ part 'schedule_place_moving_time_state.dart'; class SchedulePlaceMovingTimeCubit extends Cubit { SchedulePlaceMovingTimeCubit({required this.scheduleFormBloc}) - : super(SchedulePlaceMovingTimeState()); + : super(SchedulePlaceMovingTimeState()); final ScheduleFormBloc scheduleFormBloc; void initialize() { final schedulePlaceMovingTimeState = SchedulePlaceMovingTimeState.fromScheduleFormState( - scheduleFormBloc.state); + scheduleFormBloc.state, + ); // Check for overlap using current form state values final formState = scheduleFormBloc.state; @@ -45,13 +46,15 @@ class SchedulePlaceMovingTimeCubit extends Cubit { } } - emit(state.copyWith( - placeName: schedulePlaceMovingTimeState.placeName, - moveTime: schedulePlaceMovingTimeState.moveTime, - overlapDuration: overlapDuration, - isOverlapping: isOverlapping, - clearOverlap: overlapDuration == null, - )); + emit( + state.copyWith( + placeName: schedulePlaceMovingTimeState.placeName, + moveTime: schedulePlaceMovingTimeState.moveTime, + overlapDuration: overlapDuration, + isOverlapping: isOverlapping, + clearOverlap: overlapDuration == null, + ), + ); scheduleFormBloc.add(ScheduleFormValidated(isValid: state.isValid)); } @@ -96,22 +99,26 @@ class SchedulePlaceMovingTimeCubit extends Cubit { } } - emit(state.copyWith( - moveTime: moveTimeInputModel, - overlapDuration: overlapDuration, - isOverlapping: isOverlapping, - clearOverlap: overlapDuration == null, - )); + emit( + state.copyWith( + moveTime: moveTimeInputModel, + overlapDuration: overlapDuration, + isOverlapping: isOverlapping, + clearOverlap: overlapDuration == null, + ), + ); scheduleFormBloc.add(ScheduleFormValidated(isValid: state.isValid)); } void schedulePlaceMovingTimeSubmitted() { if (state.placeName.isValid && state.moveTime.isValid) { - scheduleFormBloc - .add(ScheduleFormMoveTimeChanged(moveTime: state.moveTime.value)); - scheduleFormBloc - .add(ScheduleFormPlaceNameChanged(placeName: state.placeName.value)); + scheduleFormBloc.add( + ScheduleFormMoveTimeChanged(moveTime: state.moveTime.value), + ); + scheduleFormBloc.add( + ScheduleFormPlaceNameChanged(placeName: state.placeName.value), + ); } } } diff --git a/lib/presentation/schedule_create/schedule_place_moving_time/cubit/schedule_place_moving_time_state.dart b/lib/presentation/schedule_create/schedule_place_moving_time/cubit/schedule_place_moving_time_state.dart index 8dc9305d..50aa3898 100644 --- a/lib/presentation/schedule_create/schedule_place_moving_time/cubit/schedule_place_moving_time_state.dart +++ b/lib/presentation/schedule_create/schedule_place_moving_time/cubit/schedule_place_moving_time_state.dart @@ -48,27 +48,31 @@ class SchedulePlaceMovingTimeState extends Equatable { return SchedulePlaceMovingTimeState( placeName: placeName ?? this.placeName, moveTime: moveTime ?? this.moveTime, - overlapDuration: - clearOverlap ? null : (overlapDuration ?? this.overlapDuration), - isOverlapping: - clearOverlap ? false : (isOverlapping ?? this.isOverlapping), + overlapDuration: clearOverlap + ? null + : (overlapDuration ?? this.overlapDuration), + isOverlapping: clearOverlap + ? false + : (isOverlapping ?? this.isOverlapping), ); } static SchedulePlaceMovingTimeState fromScheduleFormState( - ScheduleFormState state) { + ScheduleFormState state, + ) { return SchedulePlaceMovingTimeState( placeName: SchedulePlaceInputModel.pure(state.placeName ?? ''), - moveTime: - ScheduleMovingTimeInputModel.pure(state.moveTime ?? Duration.zero), + moveTime: ScheduleMovingTimeInputModel.pure( + state.moveTime ?? Duration.zero, + ), ); } @override List get props => [ - placeName, - moveTime, - overlapDuration ?? const Duration(), - isOverlapping, - ]; + placeName, + moveTime, + overlapDuration ?? const Duration(), + isOverlapping, + ]; } diff --git a/lib/presentation/schedule_create/schedule_place_moving_time/input_models/schedule_moving_time_input_model.dart b/lib/presentation/schedule_create/schedule_place_moving_time/input_models/schedule_moving_time_input_model.dart index 3feb3368..83e2cf7a 100644 --- a/lib/presentation/schedule_create/schedule_place_moving_time/input_models/schedule_moving_time_input_model.dart +++ b/lib/presentation/schedule_create/schedule_place_moving_time/input_models/schedule_moving_time_input_model.dart @@ -1,5 +1,5 @@ import 'package:formz/formz.dart'; -import 'package:on_time_front/core/validation/backend_constraints.dart'; +import 'package:on_time_front/core/validation/local_input_limits.dart'; /// Validation errors for the [ScheduleMovingTimeInputModel] [FormzInput]. enum ScheduleMovingTimeValidationError { zero, negative, tooLarge } @@ -20,7 +20,7 @@ class ScheduleMovingTimeInputModel if (minutes == 0) { return ScheduleMovingTimeValidationError.zero; } - if (minutes > BackendConstraints.maxMinuteValue) { + if (minutes > LocalInputLimits.maxMinuteValue) { return ScheduleMovingTimeValidationError.tooLarge; } return null; diff --git a/lib/presentation/schedule_create/schedule_place_moving_time/input_models/schedule_place_input_model.dart b/lib/presentation/schedule_create/schedule_place_moving_time/input_models/schedule_place_input_model.dart index 9982fe40..432d8b58 100644 --- a/lib/presentation/schedule_create/schedule_place_moving_time/input_models/schedule_place_input_model.dart +++ b/lib/presentation/schedule_create/schedule_place_moving_time/input_models/schedule_place_input_model.dart @@ -1,9 +1,7 @@ import 'package:formz/formz.dart'; /// Validation errors for the [SchedulePlaceInputModel] [FormzInput]. -enum SchedulePlaceValidationError { - empty, -} +enum SchedulePlaceValidationError { empty } class SchedulePlaceInputModel extends FormzInput { diff --git a/lib/presentation/schedule_create/schedule_place_moving_time/screens/schedule_place_moving_time_form.dart b/lib/presentation/schedule_create/schedule_place_moving_time/screens/schedule_place_moving_time_form.dart index 7b0a0dd3..6a48b66e 100644 --- a/lib/presentation/schedule_create/schedule_place_moving_time/screens/schedule_place_moving_time_form.dart +++ b/lib/presentation/schedule_create/schedule_place_moving_time/screens/schedule_place_moving_time_form.dart @@ -33,38 +33,43 @@ class _SchedulePlaceMovingTimeFormState @override Widget build(BuildContext context) { - return BlocBuilder(builder: (context, state) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextFormField( - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.appointmentPlace, + return BlocBuilder< + SchedulePlaceMovingTimeCubit, + SchedulePlaceMovingTimeState + >( + builder: (context, state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.appointmentPlace, + ), + initialValue: state.placeName.value, + focusNode: _placeFocusNode, + textInputAction: TextInputAction.next, + onChanged: (newValue) { + context.read().placeNameChanged( + newValue, + ); + }, ), - initialValue: state.placeName.value, - focusNode: _placeFocusNode, - textInputAction: TextInputAction.next, - onChanged: (newValue) { - context - .read() - .placeNameChanged(newValue); - }, - ), - Row( - children: [ - Expanded( - child: TextField( - readOnly: true, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.travelTime), - focusNode: _timeFocusNode, - textInputAction: TextInputAction.done, - controller: TextEditingController( + Row( + children: [ + Expanded( + child: TextField( + readOnly: true, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.travelTime, + ), + focusNode: _timeFocusNode, + textInputAction: TextInputAction.done, + controller: TextEditingController( text: - '${state.moveTime.value.inHours}${AppLocalizations.of(context)!.hours} ${state.moveTime.value.inMinutes % 60}${AppLocalizations.of(context)!.minutes}'), - onTap: () { - context.showCupertinoTimerPickerModal( + '${state.moveTime.value.inHours}${AppLocalizations.of(context)!.hours} ${state.moveTime.value.inMinutes % 60}${AppLocalizations.of(context)!.minutes}', + ), + onTap: () { + context.showCupertinoTimerPickerModal( title: AppLocalizations.of(context)!.selectTime, mode: CupertinoTimerPickerMode.hm, initialValue: state.moveTime.value, @@ -73,24 +78,26 @@ class _SchedulePlaceMovingTimeFormState .read() .moveTimeChanged(newTime); }, - onDisposed: () {}); - }, + onDisposed: () {}, + ); + }, + ), ), - ), - ], - ), - if (state.hasOverlapMessage) - Padding( - padding: const EdgeInsets.only(top: 8.0, left: 16.0), - child: MessageBubble( - message: state.getOverlapMessage(context)!, - type: state.isOverlapError - ? MessageBubbleType.error - : MessageBubbleType.warning, - ), + ], ), - ], - ); - }); + if (state.hasOverlapMessage) + Padding( + padding: const EdgeInsets.only(top: 8.0, left: 16.0), + child: MessageBubble( + message: state.getOverlapMessage(context)!, + type: state.isOverlapError + ? MessageBubbleType.error + : MessageBubbleType.warning, + ), + ), + ], + ); + }, + ); } } diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_cubit.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_cubit.dart index ca7ab413..fdf418c1 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_cubit.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_cubit.dart @@ -11,9 +11,8 @@ import 'package:on_time_front/presentation/shared/constants/constants.dart'; part 'schedule_form_spare_time_state.dart'; class ScheduleFormSpareTimeCubit extends Cubit { - ScheduleFormSpareTimeCubit({ - required this.scheduleFormBloc, - }) : super(ScheduleFormSpareTimeState()); + ScheduleFormSpareTimeCubit({required this.scheduleFormBloc}) + : super(ScheduleFormSpareTimeState()); final ScheduleFormBloc scheduleFormBloc; @@ -41,28 +40,20 @@ class ScheduleFormSpareTimeCubit extends Cubit { if (minutesDifference <= 0) { // Already overlapping - show as error - return ( - overlapDuration: newTimeLeft.abs(), - isOverlapping: true, - ); + return (overlapDuration: newTimeLeft.abs(), isOverlapping: true); } else if (minutesDifference < scheduleOverlapWarningThresholdMinutes) { // Show warning if there's still time left - return ( - overlapDuration: newTimeLeft, - isOverlapping: false, - ); + return (overlapDuration: newTimeLeft, isOverlapping: false); } else { - return ( - overlapDuration: null, - isOverlapping: false, - ); + return (overlapDuration: null, isOverlapping: false); } } void initialize() { final schedulePlaceMovingTimeState = ScheduleFormSpareTimeState.fromScheduleFormState( - scheduleFormBloc.state); + scheduleFormBloc.state, + ); final formState = scheduleFormBloc.state; final overlapCheck = _checkOverlap( @@ -71,14 +62,16 @@ class ScheduleFormSpareTimeCubit extends Cubit { spareTime: schedulePlaceMovingTimeState.spareTime.value, ); - emit(state.copyWith( - spareTime: schedulePlaceMovingTimeState.spareTime, - preparation: schedulePlaceMovingTimeState.preparation, - totalPreparationTime: schedulePlaceMovingTimeState.totalPreparationTime, - overlapDuration: overlapCheck.overlapDuration, - isOverlapping: overlapCheck.isOverlapping, - clearOverlap: overlapCheck.overlapDuration == null, - )); + emit( + state.copyWith( + spareTime: schedulePlaceMovingTimeState.spareTime, + preparation: schedulePlaceMovingTimeState.preparation, + totalPreparationTime: schedulePlaceMovingTimeState.totalPreparationTime, + overlapDuration: overlapCheck.overlapDuration, + isOverlapping: overlapCheck.isOverlapping, + clearOverlap: overlapCheck.overlapDuration == null, + ), + ); scheduleFormBloc.add(ScheduleFormValidated(isValid: state.isValid)); } @@ -93,21 +86,25 @@ class ScheduleFormSpareTimeCubit extends Cubit { spareTime: value, ); - emit(state.copyWith( - spareTime: spareTime, - overlapDuration: overlapCheck.overlapDuration, - isOverlapping: overlapCheck.isOverlapping, - clearOverlap: overlapCheck.overlapDuration == null, - )); + emit( + state.copyWith( + spareTime: spareTime, + overlapDuration: overlapCheck.overlapDuration, + isOverlapping: overlapCheck.isOverlapping, + clearOverlap: overlapCheck.overlapDuration == null, + ), + ); scheduleFormBloc.add(ScheduleFormValidated(isValid: state.isValid)); } void scheduleSpareTimeSubmitted() { if (state.spareTime.isValid && state.spareTime.value != null) { - scheduleFormBloc.add(ScheduleFormScheduleSpareTimeChanged( - scheduleSpareTime: state.spareTime.value!, - )); + scheduleFormBloc.add( + ScheduleFormScheduleSpareTimeChanged( + scheduleSpareTime: state.spareTime.value!, + ), + ); } // preparation은 preparationChanged에서 이미 ScheduleFormPreparationChanged를 호출했으므로 여기서는 호출하지 않음 } @@ -124,17 +121,19 @@ class ScheduleFormSpareTimeCubit extends Cubit { spareTime: spareTime, ); - emit(state.copyWith( - preparation: preparation, - totalPreparationTime: totalPreparationTime, - overlapDuration: overlapCheck.overlapDuration, - isOverlapping: overlapCheck.isOverlapping, - clearOverlap: overlapCheck.overlapDuration == null, - )); - - scheduleFormBloc.add(ScheduleFormPreparationChanged( - preparation: preparation, - )); + emit( + state.copyWith( + preparation: preparation, + totalPreparationTime: totalPreparationTime, + overlapDuration: overlapCheck.overlapDuration, + isOverlapping: overlapCheck.isOverlapping, + clearOverlap: overlapCheck.overlapDuration == null, + ), + ); + + scheduleFormBloc.add( + ScheduleFormPreparationChanged(preparation: preparation), + ); scheduleFormBloc.add(ScheduleFormValidated(isValid: state.isValid)); } diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_state.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_state.dart index c82fd29e..988f80cc 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_state.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_state.dart @@ -52,18 +52,22 @@ class ScheduleFormSpareTimeState extends Equatable { spareTime: spareTime ?? this.spareTime, preparation: preparation ?? this.preparation, totalPreparationTime: totalPreparationTime ?? this.totalPreparationTime, - overlapDuration: - clearOverlap ? null : (overlapDuration ?? this.overlapDuration), - isOverlapping: - clearOverlap ? false : (isOverlapping ?? this.isOverlapping), + overlapDuration: clearOverlap + ? null + : (overlapDuration ?? this.overlapDuration), + isOverlapping: clearOverlap + ? false + : (isOverlapping ?? this.isOverlapping), ); } static ScheduleFormSpareTimeState fromScheduleFormState( - ScheduleFormState state) { + ScheduleFormState state, + ) { return ScheduleFormSpareTimeState( spareTime: ScheduleSpareTimeInputModel.pure( - state.scheduleSpareTime ?? Duration.zero), + state.scheduleSpareTime ?? Duration.zero, + ), preparation: state.preparation, totalPreparationTime: state.totalPreparationTime, ); @@ -71,10 +75,10 @@ class ScheduleFormSpareTimeState extends Equatable { @override List get props => [ - spareTime, - preparation, - totalPreparationTime, - overlapDuration ?? const Duration(), - isOverlapping, - ]; + spareTime, + preparation, + totalPreparationTime, + overlapDuration ?? const Duration(), + isOverlapping, + ]; } diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/input_models/schedule_spare_time_input_model.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/input_models/schedule_spare_time_input_model.dart index 8af8c9b5..19915b3d 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/input_models/schedule_spare_time_input_model.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/input_models/schedule_spare_time_input_model.dart @@ -1,5 +1,5 @@ import 'package:formz/formz.dart'; -import 'package:on_time_front/core/validation/backend_constraints.dart'; +import 'package:on_time_front/core/validation/local_input_limits.dart'; /// Validation errors for the [ScheduleMovingTimeInputModel] [FormzInput]. enum ScheduleSpareTimeValidationError { zero, empty, negative, tooLarge } @@ -19,7 +19,7 @@ class ScheduleSpareTimeInputModel return ScheduleSpareTimeValidationError.negative; } else if (minutes == 0) { return ScheduleSpareTimeValidationError.zero; - } else if (minutes > BackendConstraints.maxMinuteValue) { + } else if (minutes > LocalInputLimits.maxMinuteValue) { return ScheduleSpareTimeValidationError.tooLarge; } return null; 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 8fc3bd9a..bee5dd03 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 @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:flutter_swipe_action_cell/core/cell.dart'; import 'package:flutter_swipe_action_cell/core/controller.dart'; -import 'package:on_time_front/core/validation/backend_constraints.dart'; +import 'package:on_time_front/core/validation/local_input_limits.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/onboarding/preparation_name_select/input_models/preparation_name_input_model.dart'; import 'package:on_time_front/presentation/onboarding/preparation_time/input_models/preparation_time_input_model.dart'; @@ -191,7 +191,7 @@ class _PreparationFormListFieldState extends State { PreparationTimeValidationError.negative => l10n.preparationTimeMinimumError, PreparationTimeValidationError.tooLarge => - l10n.preparationTimeMaximumError(BackendConstraints.maxMinuteValue), + l10n.preparationTimeMaximumError(LocalInputLimits.maxMinuteValue), null => null, }; } diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_edit_draft_cubit.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_edit_draft_cubit.dart index 02bed71f..9d4a6466 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_edit_draft_cubit.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_edit_draft_cubit.dart @@ -14,4 +14,3 @@ class PreparationEditDraftCubit extends Cubit { void clear() => emit(null); } - diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_step_form_cubit.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_step_form_cubit.dart index 86b7617e..47c9d293 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_step_form_cubit.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_step_form_cubit.dart @@ -17,19 +17,27 @@ class PreparationStepFormCubit extends Cubit { void nameChanged(String value) { final preparationName = PreparationNameInputModel.dirty(value); - emit(state.copyWith( - preparationName: preparationName, isValid: preparationName.isValid)); + emit( + state.copyWith( + preparationName: preparationName, + isValid: preparationName.isValid, + ), + ); } void timeChanged(Duration value) { final preparationTime = PreparationTimeInputModel.dirty(value); - emit(state.copyWith( - preparationTime: preparationTime, isValid: preparationTime.isValid)); + emit( + state.copyWith( + preparationTime: preparationTime, + isValid: preparationTime.isValid, + ), + ); } void preparationStepSaved() { - preparationFormBloc.add(PreparationFormPreparationStepCreated( - preparationStep: state, - )); + preparationFormBloc.add( + PreparationFormPreparationStepCreated(preparationStep: state), + ); } } diff --git a/lib/presentation/shared/components/arc_indicator.dart b/lib/presentation/shared/components/arc_indicator.dart index d5ec148c..d8c258f1 100644 --- a/lib/presentation/shared/components/arc_indicator.dart +++ b/lib/presentation/shared/components/arc_indicator.dart @@ -6,10 +6,7 @@ class ArcIndicator extends CustomPainter { final double progress; // 전체 진행률 final double strokeWidth; // 호의 두께 - ArcIndicator({ - required this.progress, - required this.strokeWidth, - }); + ArcIndicator({required this.progress, required this.strokeWidth}); @override void paint(Canvas canvas, Size size) { @@ -39,13 +36,7 @@ class ArcIndicator extends CustomPainter { ); // 그래프 배경 호 - canvas.drawArc( - rect, - startAngle, - sweepAngle, - false, - backgroundPaint, - ); + canvas.drawArc(rect, startAngle, sweepAngle, false, backgroundPaint); // 그래프 채워진 호 canvas.drawArc( diff --git a/lib/presentation/shared/components/calendar/centered_calendar_header.dart b/lib/presentation/shared/components/calendar/centered_calendar_header.dart index a5160498..90c9c1ae 100644 --- a/lib/presentation/shared/components/calendar/centered_calendar_header.dart +++ b/lib/presentation/shared/components/calendar/centered_calendar_header.dart @@ -48,8 +48,9 @@ class CenteredCalendarHeader extends StatelessWidget { ), Flexible( child: Text( - DateFormat.yMMMM(AppLocalizations.of(context)!.localeName) - .format(focusedMonth), + DateFormat.yMMMM( + AppLocalizations.of(context)!.localeName, + ).format(focusedMonth), style: titleTextStyle, maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/presentation/shared/components/check_button.dart b/lib/presentation/shared/components/check_button.dart index 0270fdfe..f88b878b 100644 --- a/lib/presentation/shared/components/check_button.dart +++ b/lib/presentation/shared/components/check_button.dart @@ -17,19 +17,23 @@ class CheckButton extends StatelessWidget { @override Widget build(BuildContext context) { return FilledButton( - onPressed: onPressed, - style: ButtonStyle( - padding: WidgetStatePropertyAll(EdgeInsets.zero), - shape: WidgetStatePropertyAll(CircleBorder()), - backgroundColor: WidgetStatePropertyAll(isChecked + onPressed: onPressed, + style: ButtonStyle( + padding: WidgetStatePropertyAll(EdgeInsets.zero), + shape: WidgetStatePropertyAll(CircleBorder()), + backgroundColor: WidgetStatePropertyAll( + isChecked ? const Color.fromARGB(255, 0, 202, 120) - : const Color.fromARGB(255, 232, 232, 232)), - elevation: WidgetStateProperty.all(0), // Remove elevation changes - shadowColor: - WidgetStateProperty.all(Colors.transparent), // No shadow on press - surfaceTintColor: WidgetStateProperty.all(Colors.transparent), - overlayColor: WidgetStateProperty.all(Colors.transparent), + : const Color.fromARGB(255, 232, 232, 232), ), - child: svg); + elevation: WidgetStateProperty.all(0), // Remove elevation changes + shadowColor: WidgetStateProperty.all( + Colors.transparent, + ), // No shadow on press + surfaceTintColor: WidgetStateProperty.all(Colors.transparent), + overlayColor: WidgetStateProperty.all(Colors.transparent), + ), + child: svg, + ); } } diff --git a/lib/presentation/shared/components/custom_alert_dialog.dart b/lib/presentation/shared/components/custom_alert_dialog.dart index eabbac5c..abed29bc 100644 --- a/lib/presentation/shared/components/custom_alert_dialog.dart +++ b/lib/presentation/shared/components/custom_alert_dialog.dart @@ -99,8 +99,9 @@ class CustomAlertDialog extends StatelessWidget { label ??= MaterialLocalizations.of(context).alertDialogLabel; } - final double paddingScaleFactor = - _scalePadding(MediaQuery.textScalerOf(context).scale(14.0) / 14.0); + final double paddingScaleFactor = _scalePadding( + MediaQuery.textScalerOf(context).scale(14.0) / 14.0, + ); Widget? titleWidget; Widget? contentWidget; @@ -108,7 +109,8 @@ class CustomAlertDialog extends StatelessWidget { if (title != null) { titleWidget = DefaultTextStyle( - style: titleTextStyle ?? + style: + titleTextStyle ?? dialogTheme.titleTextStyle ?? defaults.titleTextStyle!, textAlign: titleTextAlign, @@ -122,7 +124,8 @@ class CustomAlertDialog extends StatelessWidget { if (content != null) { contentWidget = DefaultTextStyle( - style: contentTextStyle ?? + style: + contentTextStyle ?? dialogTheme.contentTextStyle ?? defaults.contentTextStyle!, textAlign: contentTextAlign, @@ -151,24 +154,18 @@ class CustomAlertDialog extends StatelessWidget { final SizedBox defaultContentActionsSpacing = const SizedBox(height: 18.0); final SizedBox effectiveTitleContentSpacing = titleContentSpacing == null ? defaultTitleContentSpacing - : SizedBox( - height: titleContentSpacing, - ); + : SizedBox(height: titleContentSpacing); final SizedBox effectiveContentActionsSpacing = contentActionsSpacing == null - ? defaultContentActionsSpacing - : SizedBox( - height: contentActionsSpacing, - ); + ? defaultContentActionsSpacing + : SizedBox(height: contentActionsSpacing); if (title != null) columnChildren.add(titleWidget!); if (title != null && content != null) { columnChildren.add(effectiveTitleContentSpacing); } if (content != null) columnChildren.add(contentWidget!); if ((title != null || content != null) && actions != null) { - columnChildren.add( - effectiveContentActionsSpacing, - ); + columnChildren.add(effectiveContentActionsSpacing); } if (actions != null) columnChildren.add(actionsWidget!); @@ -208,9 +205,7 @@ class CustomAlertDialog extends StatelessWidget { surfaceTintColor: surfaceTintColor, insetPadding: insetPadding, clipBehavior: clipBehavior, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), alignment: alignment, child: dialogChild, ); @@ -224,13 +219,14 @@ double _scalePadding(double textScaleFactor) { class _DialogDefaults extends DialogThemeData { _DialogDefaults(this.context) - : super( - alignment: Alignment.center, - elevation: 6.0, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(28.0))), - clipBehavior: Clip.none, - ); + : super( + alignment: Alignment.center, + elevation: 6.0, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(28.0)), + ), + clipBehavior: Clip.none, + ); final BuildContext context; late final ColorScheme _colors = Theme.of(context).colorScheme; @@ -249,9 +245,8 @@ class _DialogDefaults extends DialogThemeData { Color? get surfaceTintColor => Colors.transparent; @override - TextStyle? get titleTextStyle => _textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ); + TextStyle? get titleTextStyle => + _textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold); @override TextStyle? get contentTextStyle => _textTheme.bodyMedium; diff --git a/lib/presentation/shared/components/error_message_bubble.dart b/lib/presentation/shared/components/error_message_bubble.dart index 9c632899..a4827ad8 100644 --- a/lib/presentation/shared/components/error_message_bubble.dart +++ b/lib/presentation/shared/components/error_message_bubble.dart @@ -58,17 +58,15 @@ class ErrorMessageBubble extends StatelessWidget { Widget build(BuildContext context) { final tail = Padding( padding: padding, - child: _MessageBubbleTail( - isTop: tailPosition == TailPosition.top, - ), + child: _MessageBubbleTail(isTop: tailPosition == TailPosition.top), ); final body = DefaultTextStyle( style: Theme.of(context).textTheme.bodyLarge!.copyWith( - color: Theme.of(context).colorScheme.error, - decorationColor: Theme.of(context).colorScheme.error, - letterSpacing: 0, - ), + color: Theme.of(context).colorScheme.error, + decorationColor: Theme.of(context).colorScheme.error, + letterSpacing: 0, + ), child: _MessageBubbleBody( errorMessage: errorMessage, action: action, @@ -102,10 +100,7 @@ class _MessageBubbleTail extends StatelessWidget { color: colorScheme.errorContainer, isTop: isTop, ), - child: const SizedBox( - height: 15, - width: 15, - ), + child: const SizedBox(height: 15, width: 15), ); } } @@ -177,9 +172,7 @@ class _MessageBubbleBody extends StatelessWidget { backgroundColor: WidgetStateProperty.all( Colors.transparent, ), - overlayColor: WidgetStateProperty.all( - Colors.transparent, - ), + overlayColor: WidgetStateProperty.all(Colors.transparent), elevation: WidgetStateProperty.all(0), foregroundColor: WidgetStateProperty.all( Theme.of(context).colorScheme.error, @@ -194,7 +187,7 @@ class _MessageBubbleBody extends StatelessWidget { ), ), child: action!, - ) + ), ], ), ); diff --git a/lib/presentation/shared/components/loading_screen.dart b/lib/presentation/shared/components/loading_screen.dart index 8acaefa7..e772e994 100644 --- a/lib/presentation/shared/components/loading_screen.dart +++ b/lib/presentation/shared/components/loading_screen.dart @@ -8,10 +8,7 @@ class LoadingScreen extends StatelessWidget { return const Scaffold( backgroundColor: Color(0xff5C79FB), body: Center( - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 4.0, - ), + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 4.0), ), ); } diff --git a/lib/presentation/shared/components/step_progress.dart b/lib/presentation/shared/components/step_progress.dart index 650cd515..f530611b 100644 --- a/lib/presentation/shared/components/step_progress.dart +++ b/lib/presentation/shared/components/step_progress.dart @@ -2,11 +2,12 @@ import 'package:flutter/material.dart'; import 'package:on_time_front/presentation/shared/theme/theme.dart'; class StepProgress extends StatelessWidget { - const StepProgress( - {super.key, - required this.currentStep, - required this.totalSteps, - this.singleLine = false}); + const StepProgress({ + super.key, + required this.currentStep, + required this.totalSteps, + this.singleLine = false, + }); final int currentStep; final int totalSteps; @@ -67,12 +68,13 @@ class StepProgress extends StatelessWidget { return Padding( padding: EdgeInsets.symmetric( - horizontal: textWidth / 2 - circleRadius / 2 - 6), + horizontal: textWidth / 2 - circleRadius / 2 - 6, + ), child: Row( children: [ for (int i = 0; i < totalSteps - 1; i++) Expanded(child: _buildIndicator(context, i)), - _buildIndicator(context, totalSteps - 1) + _buildIndicator(context, totalSteps - 1), ], ), ); @@ -89,20 +91,16 @@ class StepProgress extends StatelessWidget { style: textTheme.bodyExtraSmall.copyWith( color: _getIndicatorColor(context, i), ), - ) + ), ], - ) + ), ], ); } } class _StepText extends StatelessWidget { - const _StepText({ - required this.step, - required this.singleLine, - this.style, - }); + const _StepText({required this.step, required this.singleLine, this.style}); final int step; final bool singleLine; @@ -125,10 +123,7 @@ class _IndicatorLine extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - height: 2, - color: color, - ); + return Container(height: 2, color: color); } } @@ -150,10 +145,7 @@ class _IndicatorCircle extends StatelessWidget { height: radius, decoration: BoxDecoration( shape: BoxShape.circle, - border: Border.all( - color: color, - width: 1.5, - ), + border: Border.all(color: color, width: 1.5), color: filled ? color : Colors.transparent, ), ); diff --git a/lib/presentation/shared/components/tile.dart b/lib/presentation/shared/components/tile.dart index cd1c01bd..db2d4637 100644 --- a/lib/presentation/shared/components/tile.dart +++ b/lib/presentation/shared/components/tile.dart @@ -2,13 +2,14 @@ import 'package:flutter/material.dart'; import 'package:on_time_front/presentation/shared/theme/tile_style.dart'; class Tile extends StatelessWidget { - const Tile( - {super.key, - this.statesController, - this.style, - this.leading, - this.trailing, - required this.child}); + const Tile({ + super.key, + this.statesController, + this.style, + this.leading, + this.trailing, + required this.child, + }); final WidgetStatesController? statesController; @@ -57,10 +58,7 @@ class Tile extends StatelessWidget { Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.start, - children: [ - leading ?? SizedBox.shrink(), - child, - ], + children: [leading ?? SizedBox.shrink(), child], ), ), trailing ?? SizedBox.shrink(), diff --git a/lib/presentation/shared/components/time_stepper.dart b/lib/presentation/shared/components/time_stepper.dart index 12d08950..ca2382a7 100644 --- a/lib/presentation/shared/components/time_stepper.dart +++ b/lib/presentation/shared/components/time_stepper.dart @@ -21,23 +21,22 @@ class TimeStepper extends StatelessWidget { final iconButtonStyle = ButtonStyle( backgroundColor: WidgetStatePropertyAll(Color(0xffe6e9f9)), foregroundColor: WidgetStatePropertyAll(colorScheme.primary), - shape: WidgetStatePropertyAll(CircleBorder( - side: BorderSide(color: colorScheme.primary, width: 1.0))), + shape: WidgetStatePropertyAll( + CircleBorder(side: BorderSide(color: colorScheme.primary, width: 1.0)), + ), ); 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), + style: iconButtonStyle, + onPressed: value > lowerBound ? onSpareTimeDecreased : null, + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 35.0), - child: Text( - '${value.inMinutes}분', - style: textTheme.titleSmall, - ), + child: Text('${value.inMinutes}분', style: textTheme.titleSmall), ), IconButton( icon: const Icon(Icons.add), diff --git a/lib/presentation/shared/components/two_action_dialog.dart b/lib/presentation/shared/components/two_action_dialog.dart index 90126d71..9fd098fb 100644 --- a/lib/presentation/shared/components/two_action_dialog.dart +++ b/lib/presentation/shared/components/two_action_dialog.dart @@ -2,11 +2,7 @@ import 'package:flutter/material.dart'; import 'package:on_time_front/presentation/shared/components/custom_alert_dialog.dart'; import 'package:on_time_front/presentation/shared/components/modal_wide_button.dart'; -enum DialogActionResult { - primary, - secondary, - dismissed, -} +enum DialogActionResult { primary, secondary, dismissed } class DialogActionConfig { const DialogActionConfig({ @@ -72,7 +68,7 @@ Future showTwoActionDialog( onSecondaryPressed: config.secondaryAction == null ? null : () => - Navigator.of(dialogContext).pop(DialogActionResult.secondary), + Navigator.of(dialogContext).pop(DialogActionResult.secondary), ); }, ); @@ -114,7 +110,8 @@ class TwoActionDialog extends StatelessWidget { ), ); - final bodyContent = customContent ?? + final bodyContent = + customContent ?? (config.description == null ? null : Text( diff --git a/lib/presentation/shared/constants/app_colors.dart b/lib/presentation/shared/constants/app_colors.dart index 433ecf7c..eb91e451 100644 --- a/lib/presentation/shared/constants/app_colors.dart +++ b/lib/presentation/shared/constants/app_colors.dart @@ -110,20 +110,18 @@ abstract final class AppColors { /// /// * [Theme.of], which allows you to select colors from the current theme /// rather than hard-coding colors in your build methods. - static const MaterialColor blue = MaterialColor( - _bluePrimaryValue, - { - 100: Color(0xFFF3F5FF), - 200: Color(0xFFDCE3FF), - 300: Color(0xFFB5C2FF), - 400: Color(0xFF839AFF), - 500: Color(_bluePrimaryValue), - 600: Color(0xFF4F69DF), - 700: Color(0xFF3D54BC), - 800: Color(0xFF2E4092), - 900: Color(0xFF212F6F), - }, - ); + static const MaterialColor blue = + MaterialColor(_bluePrimaryValue, { + 100: Color(0xFFF3F5FF), + 200: Color(0xFFDCE3FF), + 300: Color(0xFFB5C2FF), + 400: Color(0xFF839AFF), + 500: Color(_bluePrimaryValue), + 600: Color(0xFF4F69DF), + 700: Color(0xFF3D54BC), + 800: Color(0xFF2E4092), + 900: Color(0xFF212F6F), + }); static const int _bluePrimaryValue = 0xFF5C79FB; /// The green primary color and swatch. @@ -138,20 +136,18 @@ abstract final class AppColors { /// See also: /// * [Theme.of], which allows you to select colors from the current theme /// rather than hard-coding colors in your build methods. - static const MaterialColor green = MaterialColor( - _greenPrimaryValue, - { - 100: Color(0xFFE2FFF4), - 200: Color(0xFF7EFFCC), - 300: Color(0xFF50F6B4), - 400: Color(0xFF2EE49A), - 500: Color(_greenPrimaryValue), - 600: Color(0xFF00B15F), - 700: Color(0xFF0A9846), - 800: Color(0xFF007A28), - 900: Color(0xFF006614), - }, - ); + static const MaterialColor green = + MaterialColor(_greenPrimaryValue, { + 100: Color(0xFFE2FFF4), + 200: Color(0xFF7EFFCC), + 300: Color(0xFF50F6B4), + 400: Color(0xFF2EE49A), + 500: Color(_greenPrimaryValue), + 600: Color(0xFF00B15F), + 700: Color(0xFF0A9846), + 800: Color(0xFF007A28), + 900: Color(0xFF006614), + }); static const int _greenPrimaryValue = 0xFF00CA78; /// The yellow primary color and swatch. @@ -167,20 +163,18 @@ abstract final class AppColors { /// * [Theme.of], which allows you to select colors from the current theme /// rather than hard-coding colors in your build methods. - static const MaterialColor yellow = MaterialColor( - _yellowPrimaryValue, - { - 100: Color(0xFFFFF6DB), - 200: Color(0xFFFFF2C8), - 300: Color(0xFFFFEDAD), - 400: Color(0xFFFFE384), - 500: Color(_yellowPrimaryValue), - 600: Color(0xFFE9C54B), - 700: Color(0xFFCDAE44), - 800: Color(0xFFAA8F31), - 900: Color(0xFF826D24), - }, - ); + static const MaterialColor yellow = + MaterialColor(_yellowPrimaryValue, { + 100: Color(0xFFFFF6DB), + 200: Color(0xFFFFF2C8), + 300: Color(0xFFFFEDAD), + 400: Color(0xFFFFE384), + 500: Color(_yellowPrimaryValue), + 600: Color(0xFFE9C54B), + 700: Color(0xFFCDAE44), + 800: Color(0xFFAA8F31), + 900: Color(0xFF826D24), + }); static const int _yellowPrimaryValue = 0xFFFFD956; /// The red primary color and swatch. @@ -198,21 +192,18 @@ abstract final class AppColors { /// * [Theme.of], which allows you to select colors from the current theme /// rather than hard-coding colors in your build methods. - static const MaterialColor red = MaterialColor( - _redPrimaryValue, - { - 50: Color(0xFFFFEAE7), - 100: Color(0xFFFECBC0), - 200: Color(0xFFFEA899), - 300: Color(0xFFFE8671), - 400: Color(_redPrimaryValue), - 500: Color(0xFFFF4E39), - 600: Color(0xFFF54834), - 700: Color(0xFFE6412F), - 800: Color(0xFFD83B2B), - 900: Color(0xFFBF2E22), - }, - ); + static const MaterialColor red = MaterialColor(_redPrimaryValue, { + 50: Color(0xFFFFEAE7), + 100: Color(0xFFFECBC0), + 200: Color(0xFFFEA899), + 300: Color(0xFFFE8671), + 400: Color(_redPrimaryValue), + 500: Color(0xFFFF4E39), + 600: Color(0xFFF54834), + 700: Color(0xFFE6412F), + 800: Color(0xFFD83B2B), + 900: Color(0xFFBF2E22), + }); static const int _redPrimaryValue = 0xFFFF6953; /// The grey primary color and swatch. @@ -228,22 +219,20 @@ abstract final class AppColors { /// * [Theme.of], which allows you to select colors from the current theme /// rather than hard-coding colors in your build methods. - static const MaterialColor grey = MaterialColor( - _greyPrimaryValue, - { - 50: Color(0xFFF6F6F6), - 100: Color(0xFFF0F0F0), - 200: Color(0xFFE8E8E8), - 250: Color(0xFFDFDFDF), - 300: Color(0xFFC8C8C8), - 400: Color(0xFFB7B7B7), - 500: Color(_greyPrimaryValue), - 600: Color(0xFF777777), - 700: Color(0xFF545454), - 800: Color(0xFF383838), - 900: Color(0xFF2A2A2A), - 950: Color(0xFF111111), - }, - ); + static const MaterialColor grey = + MaterialColor(_greyPrimaryValue, { + 50: Color(0xFFF6F6F6), + 100: Color(0xFFF0F0F0), + 200: Color(0xFFE8E8E8), + 250: Color(0xFFDFDFDF), + 300: Color(0xFFC8C8C8), + 400: Color(0xFFB7B7B7), + 500: Color(_greyPrimaryValue), + 600: Color(0xFF777777), + 700: Color(0xFF545454), + 800: Color(0xFF383838), + 900: Color(0xFF2A2A2A), + 950: Color(0xFF111111), + }); static const int _greyPrimaryValue = 0xFF949494; } diff --git a/lib/presentation/shared/constants/constants.dart b/lib/presentation/shared/constants/constants.dart index 6553acef..f6cb9501 100644 --- a/lib/presentation/shared/constants/constants.dart +++ b/lib/presentation/shared/constants/constants.dart @@ -7,11 +7,7 @@ enum PreparationStateEnum { done, // 완료됨 } -enum SocialType { - normal, - google, - apple, -} +enum SocialType { normal, google, apple } SocialType socialTypeFromString(String? value) { if (value == null) return SocialType.normal; diff --git a/lib/presentation/shared/constants/early_late_text_images.dart b/lib/presentation/shared/constants/early_late_text_images.dart index ccef7a06..13526527 100644 --- a/lib/presentation/shared/constants/early_late_text_images.dart +++ b/lib/presentation/shared/constants/early_late_text_images.dart @@ -30,7 +30,7 @@ final Map>> earlyMessagesWithImages = { {"message": "심호흡 한 번 하고\n천천히 걸어갈 시간이 생겼어요.", "image": 'character.svg'}, { "message": "여유롭게 갈 수 있겠어요\n좋아하는 노래와 함께 산뜻하게 출발해봐요", - "image": 'character_headphone.svg' + "image": 'character_headphone.svg', }, ], Range(11, 15): [ @@ -45,7 +45,7 @@ final Map>> earlyMessagesWithImages = { {"message": "출발 전에 잊은 물건을\n챙길 기회가 생겼어요!", "image": "character.svg"}, { "message": "예정 시간보다 빠르게 도착해서\n장소를 한 바퀴 둘러볼 수 있어요.", - "image": "character.svg" + "image": "character.svg", }, ], Range(31, 40): [ @@ -53,10 +53,7 @@ final Map>> earlyMessagesWithImages = { {"message": "조금 더 준비된 모습으로\n상대를 만날 수 있어요.", "image": "character.svg"}, ], Range(41, 59): [ - { - "message": "영화 예고편을 보며\n시간을 보낼 수 있어요!", - "image": "character.svg", - }, + {"message": "영화 예고편을 보며\n시간을 보낼 수 있어요!", "image": "character.svg"}, {"message": "약속 장소에서\n조용히 책 몇 페이지를 읽을 수 있어요.", "image": "character.svg"}, ], Range.openEnded(60): [ @@ -94,7 +91,7 @@ Map getEarlyMessage(int value) { } return { "message": "정확히 시간을 맞춰 준비했어요! 혹시 몸에 시계라도 있나요?", - "image": 'character.svg' + "image": 'character.svg', }; } @@ -104,6 +101,6 @@ Map getLateMessage() { final selectedMessage = messages[Random().nextInt(messages.length)]; return { "message": selectedMessage, - "image": lateMessagesWithImages[selectedMessage] ?? 'character.svg' + "image": lateMessagesWithImages[selectedMessage] ?? 'character.svg', }; } diff --git a/lib/presentation/shared/router/go_router.dart b/lib/presentation/shared/router/go_router.dart index 695f1b11..a7e2eb74 100644 --- a/lib/presentation/shared/router/go_router.dart +++ b/lib/presentation/shared/router/go_router.dart @@ -13,9 +13,10 @@ import 'package:on_time_front/presentation/app/cubit/notification_gate_cubit.dar import 'package:on_time_front/presentation/early_late/screens/early_late_screen.dart'; import 'package:on_time_front/presentation/calendar/screens/calendar_screen.dart'; import 'package:on_time_front/presentation/home/screens/home_screen_tmp.dart'; -import 'package:on_time_front/presentation/login/screens/sign_in_main_screen.dart'; import 'package:on_time_front/presentation/moving/screens/moving_screen.dart'; import 'package:on_time_front/presentation/my_page/my_page_screen.dart'; +import 'package:on_time_front/presentation/my_page/my_data_screen.dart'; +import 'package:on_time_front/presentation/my_page/privacy_policy_screen.dart'; import 'package:on_time_front/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart'; import 'package:on_time_front/presentation/notification_allow/screens/notification_allow_screen.dart'; import 'package:on_time_front/presentation/onboarding/screens/onboarding_screen.dart'; @@ -29,6 +30,7 @@ import 'package:on_time_front/presentation/shared/router/app_route_transition.da import 'package:on_time_front/presentation/shared/router/route_arguments.dart'; import 'package:on_time_front/presentation/shared/utils/stream_to_listenable.dart'; import 'package:on_time_front/presentation/startup/screens/startup_screen.dart'; +import 'package:on_time_front/presentation/startup/screens/local_data_recovery_screen.dart'; final GlobalKey navigatorKey = GlobalKey(); @@ -54,6 +56,13 @@ GoRouter goRouterConfig( }, initialLocation: '/startup', routes: [ + GoRoute( + path: '/recovery', + pageBuilder: (context, state) => _buildAppRoutePage( + state: state, + child: const LocalDataRecoveryScreen(), + ), + ), GoRoute( path: '/startup', pageBuilder: (context, state) => _buildAppRoutePage( @@ -118,18 +127,29 @@ GoRouter goRouterConfig( ], ), GoRoute( - path: '/defaultPreparationSpareTimeEdit', + path: '/myData', + pageBuilder: (context, state) => + _buildAppRoutePage(state: state, child: const MyDataScreen()), + ), + GoRoute( + path: '/privacyPolicy', pageBuilder: (context, state) => _buildAppRoutePage( state: state, - child: PreparationSpareTimeEditScreen(), + child: const PrivacyPolicyScreen(), ), ), GoRoute( - path: '/signIn', + path: '/resetComplete', pageBuilder: (context, state) => _buildAppRoutePage( state: state, - transition: AppRouteTransition.fade, - child: SignInMainScreen(), + child: const LocalDataResetCompleteScreen(), + ), + ), + GoRoute( + path: '/defaultPreparationSpareTimeEdit', + pageBuilder: (context, state) => _buildAppRoutePage( + state: state, + child: PreparationSpareTimeEditScreen(), ), ), GoRoute( @@ -238,8 +258,10 @@ String? appRedirectLocation({ required AlarmGateState alarmGateState, required String path, }) { + if (path == '/resetComplete') return null; final isStartupRoute = path == '/startup'; - final isPublicRoute = isStartupRoute || path == '/signIn'; + final isRecoveryRoute = path == '/recovery'; + final isPublicRoute = isStartupRoute || isRecoveryRoute; final isOnboardingRoute = path == '/onboarding' || path == '/onboarding/start'; final isNotificationRoute = path == '/allowNotification'; @@ -248,10 +270,10 @@ String? appRedirectLocation({ isPublicRoute || isOnboardingRoute || isNotificationRoute || isAlarmRoute; switch (authStatus) { + case AuthStatus.recovery: + return isRecoveryRoute ? null : '/recovery'; case AuthStatus.loading: return isStartupRoute ? null : '/startup'; - case AuthStatus.unauthenticated: - return path == '/signIn' ? null : '/signIn'; case AuthStatus.authenticated: if (notificationGateState.status == NotificationGateStatus.required) { return isNotificationRoute ? null : '/allowNotification'; diff --git a/lib/presentation/shared/router/route_arguments.dart b/lib/presentation/shared/router/route_arguments.dart index ff3f2c9a..1f147a77 100644 --- a/lib/presentation/shared/router/route_arguments.dart +++ b/lib/presentation/shared/router/route_arguments.dart @@ -68,10 +68,7 @@ Map? scheduleStartRouteExtraFromState(GoRouterState state) { final extra = routeExtraMap(state.extra); final queryExtra = _scheduleStartExtraFromQuery(state.uri.queryParameters); if (queryExtra == null) return extra; - return { - ...queryExtra, - ...?extra, - }; + return {...queryExtra, ...?extra}; } class EarlyLateRouteArguments { @@ -84,9 +81,7 @@ class EarlyLateRouteArguments { final bool isLate; } -EarlyLateRouteArguments? earlyLateRouteArgumentsFromState( - GoRouterState state, -) { +EarlyLateRouteArguments? earlyLateRouteArgumentsFromState(GoRouterState state) { return parseEarlyLateRouteArguments( extra: state.extra, queryParameters: state.uri.queryParameters, @@ -98,16 +93,15 @@ EarlyLateRouteArguments? parseEarlyLateRouteArguments({ Map queryParameters = const {}, }) { final extraMap = routeExtraMap(extra); - final earlyLateTime = _intValue(extraMap?['earlyLateTime']) ?? + final earlyLateTime = + _intValue(extraMap?['earlyLateTime']) ?? _intValue(queryParameters['earlyLateTime']); - final isLate = routeBoolValue(extraMap?['isLate']) ?? + final isLate = + routeBoolValue(extraMap?['isLate']) ?? routeBoolValue(queryParameters['isLate']); if (earlyLateTime == null || isLate == null) return null; - return EarlyLateRouteArguments( - earlyLateTime: earlyLateTime, - isLate: isLate, - ); + return EarlyLateRouteArguments(earlyLateTime: earlyLateTime, isLate: isLate); } String earlyLateRouteLocation({ diff --git a/lib/presentation/shared/theme/button_styles.dart b/lib/presentation/shared/theme/button_styles.dart index 407a7ca5..14bd4ed7 100644 --- a/lib/presentation/shared/theme/button_styles.dart +++ b/lib/presentation/shared/theme/button_styles.dart @@ -6,53 +6,58 @@ class AppButtonStyles { // Method to create button styles with the provided theme data static ButtonStyle _baseButtonStyle(TextTheme textTheme) => ButtonStyle( - padding: WidgetStatePropertyAll(const EdgeInsets.all(16.0)), - visualDensity: VisualDensity.standard, - textStyle: WidgetStatePropertyAll(textTheme.titleMedium), - shape: WidgetStatePropertyAll( - RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - elevation: const WidgetStatePropertyAll(0), - ); + padding: WidgetStatePropertyAll(const EdgeInsets.all(16.0)), + visualDensity: VisualDensity.standard, + textStyle: WidgetStatePropertyAll(textTheme.titleMedium), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + elevation: const WidgetStatePropertyAll(0), + ); // Primary button style (colorScheme.primary) static ButtonStyle elevatedPrimary( - ColorScheme colorScheme, TextTheme textTheme) { + ColorScheme colorScheme, + TextTheme textTheme, + ) { return _baseButtonStyle(textTheme).copyWith( - backgroundColor: WidgetStateProperty.resolveWith( - (Set states) { - if (states.contains(WidgetState.disabled)) { - return colorScheme.surfaceDim; - } else { - return colorScheme.primary; - } - }, - ), + backgroundColor: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (states.contains(WidgetState.disabled)) { + return colorScheme.surfaceDim; + } else { + return colorScheme.primary; + } + }), foregroundColor: WidgetStateProperty.all(colorScheme.onPrimary), ); } // Primary container variant style (colorScheme.primaryContainer) static ButtonStyle elevatedSecondary( - ColorScheme colorScheme, TextTheme textTheme) { + ColorScheme colorScheme, + TextTheme textTheme, + ) { return _baseButtonStyle(textTheme).copyWith( - backgroundColor: WidgetStateProperty.resolveWith( - (Set states) { - if (states.contains(WidgetState.disabled)) { - return colorScheme.surfaceDim; - } else { - return colorScheme.primaryContainer; - } - }, - ), - foregroundColor: WidgetStateProperty.resolveWith( - (Set states) { - if (states.contains(WidgetState.disabled)) { - return colorScheme.onSurface.withValues(alpha: 0.38); - } else { - return colorScheme.onPrimaryContainer; - } - }, - ), + backgroundColor: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (states.contains(WidgetState.disabled)) { + return colorScheme.surfaceDim; + } else { + return colorScheme.primaryContainer; + } + }), + foregroundColor: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (states.contains(WidgetState.disabled)) { + return colorScheme.onSurface.withValues(alpha: 0.38); + } else { + return colorScheme.onPrimaryContainer; + } + }), ); } @@ -60,15 +65,15 @@ class AppButtonStyles { return _baseButtonStyle(textTheme).copyWith( textStyle: WidgetStateProperty.all(textTheme.titleLarge), padding: WidgetStatePropertyAll(const EdgeInsets.all(0.0)), - foregroundColor: WidgetStateProperty.resolveWith( - (Set states) { - if (states.contains(WidgetState.disabled)) { - return colorScheme.outlineVariant.withValues(alpha: 0.38); - } else { - return colorScheme.primary; - } - }, - ), + foregroundColor: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (states.contains(WidgetState.disabled)) { + return colorScheme.outlineVariant.withValues(alpha: 0.38); + } else { + return colorScheme.primary; + } + }), ); } diff --git a/lib/presentation/shared/theme/calendar_theme.dart b/lib/presentation/shared/theme/calendar_theme.dart index 61b99659..fda41a7d 100644 --- a/lib/presentation/shared/theme/calendar_theme.dart +++ b/lib/presentation/shared/theme/calendar_theme.dart @@ -46,7 +46,9 @@ class CalendarTheme extends ThemeExtension { @override ThemeExtension lerp( - covariant ThemeExtension? other, double t) { + covariant ThemeExtension? other, + double t, + ) { if (other is! CalendarTheme) { return this; } diff --git a/lib/presentation/shared/theme/input_decoration_theme.dart b/lib/presentation/shared/theme/input_decoration_theme.dart index 4b251749..a8598e25 100644 --- a/lib/presentation/shared/theme/input_decoration_theme.dart +++ b/lib/presentation/shared/theme/input_decoration_theme.dart @@ -6,50 +6,39 @@ class AppInputDecorationTheme { AppInputDecorationTheme._(); static InputDecorationTheme create( - ColorScheme colorScheme, TextTheme textTheme) { + ColorScheme colorScheme, + TextTheme textTheme, + ) { return InputDecorationTheme( // Content padding - contentPadding: - const EdgeInsets.symmetric(vertical: 16.0, horizontal: 0.0), + contentPadding: const EdgeInsets.symmetric( + vertical: 16.0, + horizontal: 0.0, + ), // Border styling border: UnderlineInputBorder( - borderSide: BorderSide( - color: colorScheme.outline, - width: 1.0, - ), + borderSide: BorderSide(color: colorScheme.outline, width: 1.0), ), // Enabled border (normal state) enabledBorder: UnderlineInputBorder( - borderSide: BorderSide( - color: colorScheme.outline, - width: 1.0, - ), + borderSide: BorderSide(color: colorScheme.outline, width: 1.0), ), // Focused border (blue underline) focusedBorder: UnderlineInputBorder( - borderSide: BorderSide( - color: colorScheme.primary, - width: 2.0, - ), + borderSide: BorderSide(color: colorScheme.primary, width: 2.0), ), // Error border (red underline) errorBorder: UnderlineInputBorder( - borderSide: BorderSide( - color: colorScheme.error, - width: 2.0, - ), + borderSide: BorderSide(color: colorScheme.error, width: 2.0), ), // Focused error border focusedErrorBorder: UnderlineInputBorder( - borderSide: BorderSide( - color: colorScheme.error, - width: 2.0, - ), + borderSide: BorderSide(color: colorScheme.error, width: 2.0), ), // Disabled border @@ -73,9 +62,7 @@ class AppInputDecorationTheme { color: colorScheme.outlineVariant, ), - errorStyle: textTheme.bodySmall?.copyWith( - color: colorScheme.error, - ), + errorStyle: textTheme.bodySmall?.copyWith(color: colorScheme.error), helperStyle: textTheme.bodySmall?.copyWith( color: colorScheme.outlineVariant, diff --git a/lib/presentation/shared/theme/text_theme.dart b/lib/presentation/shared/theme/text_theme.dart index 176dd8f7..aea15560 100644 --- a/lib/presentation/shared/theme/text_theme.dart +++ b/lib/presentation/shared/theme/text_theme.dart @@ -62,29 +62,29 @@ TextTheme _textTheme = TextTheme( extension CustomTextThemeExtension on TextTheme { TextStyle get headlineExtraSmall => TextStyle( - fontSize: 28, - color: _colorScheme.onSurface, - fontWeight: FontWeight.w500, - fontStyle: FontStyle.normal, - height: 1.3, - ); + fontSize: 28, + color: _colorScheme.onSurface, + fontWeight: FontWeight.w500, + fontStyle: FontStyle.normal, + height: 1.3, + ); TextStyle get titleExtraLarge => TextStyle( - fontSize: 24, - color: _colorScheme.onSurface, - fontWeight: FontWeight.w600, - height: 1.4, - ); + fontSize: 24, + color: _colorScheme.onSurface, + fontWeight: FontWeight.w600, + height: 1.4, + ); TextStyle get titleExtraSmall => TextStyle( - fontSize: 14, - color: _colorScheme.onSurface, - fontWeight: FontWeight.w500, - height: 1.4, - ); + fontSize: 14, + color: _colorScheme.onSurface, + fontWeight: FontWeight.w500, + height: 1.4, + ); TextStyle get bodyExtraSmall => TextStyle( - fontSize: 12, - color: _colorScheme.onSurface, - fontWeight: FontWeight.w400, - height: 1.4, - ); + fontSize: 12, + color: _colorScheme.onSurface, + fontWeight: FontWeight.w400, + height: 1.4, + ); } diff --git a/lib/presentation/shared/theme/theme.dart b/lib/presentation/shared/theme/theme.dart index d3575185..9d0f5df6 100644 --- a/lib/presentation/shared/theme/theme.dart +++ b/lib/presentation/shared/theme/theme.dart @@ -24,15 +24,16 @@ ThemeData themeData = ThemeData( elevatedButtonTheme: ElevatedButtonThemeData( style: AppButtonStyles.elevatedPrimary(_colorScheme, _textTheme), ), - inputDecorationTheme: - AppInputDecorationTheme.create(_colorScheme, _textTheme), + inputDecorationTheme: AppInputDecorationTheme.create( + _colorScheme, + _textTheme, + ), dialogTheme: DialogThemeData( backgroundColor: _colorScheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + titleTextStyle: _textTheme.titleMedium!.copyWith( + fontWeight: FontWeight.w600, ), - titleTextStyle: - _textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600), contentTextStyle: _textTheme.bodyMedium, ), extensions: >[ @@ -46,9 +47,7 @@ ThemeData themeData = ThemeData( DateTileThemeData( style: DateTileStyle( shape: WidgetStatePropertyAll( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), + RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), ), textStyle: WidgetStatePropertyAll(_textTheme.bodyLarge), ), diff --git a/lib/presentation/shared/theme/tile_style.dart b/lib/presentation/shared/theme/tile_style.dart index 45c11023..da119efb 100644 --- a/lib/presentation/shared/theme/tile_style.dart +++ b/lib/presentation/shared/theme/tile_style.dart @@ -30,13 +30,14 @@ class TileStyle extends ThemeExtension { /// The margin of the tile. final EdgeInsetsGeometry? margin; @override - TileStyle copyWith( - {Color? backgroundColor, - BorderRadius? borderRadius, - EdgeInsetsGeometry? padding, - EdgeInsetsGeometry? margin, - Size? minimumSize, - Size? maximumSize}) { + TileStyle copyWith({ + Color? backgroundColor, + BorderRadius? borderRadius, + EdgeInsetsGeometry? padding, + EdgeInsetsGeometry? margin, + Size? minimumSize, + Size? maximumSize, + }) { return TileStyle( backgroundColor: backgroundColor ?? this.backgroundColor, borderRadius: borderRadius ?? this.borderRadius, diff --git a/lib/presentation/shared/utils/login_platform.dart b/lib/presentation/shared/utils/login_platform.dart index 478018ea..7736c267 100644 --- a/lib/presentation/shared/utils/login_platform.dart +++ b/lib/presentation/shared/utils/login_platform.dart @@ -1,5 +1 @@ -enum LoginPlatform { - google, - kakao, - none, -} +enum LoginPlatform { google, kakao, none } diff --git a/lib/presentation/startup/screens/local_data_recovery_screen.dart b/lib/presentation/startup/screens/local_data_recovery_screen.dart new file mode 100644 index 00000000..47b130ac --- /dev/null +++ b/lib/presentation/startup/screens/local_data_recovery_screen.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:on_time_front/core/database/local_data_reset_service.dart'; +import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; + +class LocalDataRecoveryScreen extends StatefulWidget { + const LocalDataRecoveryScreen({super.key}); + + @override + State createState() => + _LocalDataRecoveryScreenState(); +} + +class _LocalDataRecoveryScreenState extends State { + bool _busy = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.storage_rounded, size: 64), + const SizedBox(height: 20), + Text( + '로컬 데이터를 열 수 없습니다.', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + const Text( + 'OnTime은 데이터를 자동으로 삭제하지 않았습니다. 잠시 후 다시 시도하거나, ' + '복구할 수 없는 경우에만 모든 로컬 데이터를 초기화하세요.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _busy + ? null + : () { + context.read().add( + const AuthUserSubscriptionRequested(), + ); + }, + child: const Text('다시 시도'), + ), + TextButton( + onPressed: _busy ? null : _confirmReset, + child: Text( + '모든 로컬 데이터 초기화', + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + if (_busy) + const Padding( + padding: EdgeInsets.only(top: 12), + child: CircularProgressIndicator(), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Future _confirmReset() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('복구하지 않고 삭제할까요?'), + content: const Text('현재 설치의 모든 데이터와 암호화 키가 삭제되며 되돌릴 수 없습니다.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('취소'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('모두 삭제'), + ), + ], + ), + ); + if (confirmed != true) return; + setState(() => _busy = true); + await getIt().reset(); + if (mounted) context.go('/resetComplete'); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index a35cce61..2e3d085c 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,18 +6,26 @@ #include "generated_plugin_registrant.h" +#include #include +#include +#include #include -#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) sodium_libs_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "SodiumLibsPlugin"); + sodium_libs_plugin_register_with_registrar(sodium_libs_registrar); + g_autoptr(FlPluginRegistrar) sqlcipher_flutter_libs_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); + sqlite3_flutter_libs_plugin_register_with_registrar(sqlcipher_flutter_libs_registrar); g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); - g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); - url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2aa89bb0..aa0b9c82 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,9 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux flutter_secure_storage_linux + sodium_libs + sqlcipher_flutter_libs sqlite3_flutter_libs - url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index b85addad..dfcd99e8 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,34 +5,24 @@ import FlutterMacOS import Foundation -import firebase_analytics -import firebase_core -import firebase_messaging -import flutter_appauth +import file_selector_macos import flutter_local_notifications import flutter_secure_storage_darwin -import google_sign_in_ios import package_info_plus import path_provider_foundation import shared_preferences_foundation -import sign_in_with_apple +import sodium_libs +import sqlcipher_flutter_libs import sqlite3_flutter_libs -import url_launcher_macos -import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin")) - FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) - FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) - FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) - FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin")) + SodiumLibsPlugin.register(with: registry.registrar(forPlugin: "SodiumLibsPlugin")) + Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) - UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) - WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index dee6a6e7..eb4f86aa 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,14 +9,6 @@ packages: url: "https://pub.dev" source: hosted version: "88.0.0" - _flutterfire_internals: - dependency: transitive - description: - name: _flutterfire_internals - sha256: "78f98c1f9c4dbbd22c2bb7b7f17c4a5c06150e8b2cb791a0947979ad0d3dabd5" - url: "https://pub.dev" - source: hosted - version: "1.3.73" analyzer: dependency: transitive description: @@ -49,14 +41,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" - asn1lib: - dependency: transitive - description: - name: asn1lib - sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" - url: "https://pub.dev" - source: hosted - version: "1.6.5" assets: dependency: "direct main" description: @@ -216,6 +200,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" crypto: dependency: transitive description: @@ -224,6 +216,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -248,22 +248,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" - dio: - dependency: "direct main" - description: - name: dio - sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c - url: "https://pub.dev" - source: hosted - version: "5.9.2" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" - url: "https://pub.dev" - source: hosted - version: "2.1.2" dotted_line: dependency: "direct main" description: @@ -296,14 +280,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.8" - encrypt: - dependency: transitive - description: - name: encrypt - sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" - url: "https://pub.dev" - source: hosted - version: "5.0.3" equatable: dependency: "direct main" description: @@ -344,78 +320,70 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" - firebase_analytics: + file_selector: dependency: "direct main" description: - name: firebase_analytics - sha256: "1c46136d9226e7e070013582097d997248a34cf94856c7e95f4fdf346bf4a397" + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a url: "https://pub.dev" source: hosted - version: "12.4.3" - firebase_analytics_platform_interface: + version: "1.1.0" + file_selector_android: dependency: transitive description: - name: firebase_analytics_platform_interface - sha256: "0b4ae09407352ec462a2403b8addf18bdf94a35e0e0c9dda198274516c32456a" + name: file_selector_android + sha256: "7c76473740e33a11343c8fce88166049230850d2f19cd8a652ea935fcb8c9206" url: "https://pub.dev" source: hosted - version: "6.0.3" - firebase_analytics_web: + version: "0.5.2+10" + file_selector_ios: dependency: transitive description: - name: firebase_analytics_web - sha256: e66c02ab6491393767b197dfb37908178295cfdb1c5d30e33cad9d7276d1c5fa + name: file_selector_ios + sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c" url: "https://pub.dev" source: hosted - version: "0.6.1+9" - firebase_core: - dependency: "direct main" - description: - name: firebase_core - sha256: d2625088d8f8836a7a74a7eb94a5372d70ad88382602ba2dcc02805c294d0d16 - url: "https://pub.dev" - source: hosted - version: "4.11.0" - firebase_core_platform_interface: + version: "0.5.3+6" + file_selector_linux: dependency: transitive description: - name: firebase_core_platform_interface - sha256: "913e7c96ef83a80ad7e1c3f8a059167b3de23b5d5e07fa3ed8f11abe24de98b6" + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab url: "https://pub.dev" source: hosted - version: "7.1.0" - firebase_core_web: + version: "0.9.4+1" + file_selector_macos: dependency: transitive description: - name: firebase_core_web - sha256: "30ba3ae56f5beb2cea836033201570612c911661889f815eca73b6056c7b55bf" + name: file_selector_macos + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 url: "https://pub.dev" source: hosted - version: "3.9.0" - firebase_messaging: - dependency: "direct main" + version: "0.9.5+1" + file_selector_platform_interface: + dependency: transitive description: - name: firebase_messaging - sha256: ce21a510e5a9aed67a0404476981e19ec0361a0301eeba547dc93dc2e7dec99a + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" url: "https://pub.dev" source: hosted - version: "16.4.1" - firebase_messaging_platform_interface: + version: "2.7.0" + file_selector_web: dependency: transitive description: - name: firebase_messaging_platform_interface - sha256: e10f6d521e7ed663d0ea2f4ec7de4c6729f8c2ce25d32faf6d6b4219da8515c2 + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" url: "https://pub.dev" source: hosted - version: "4.9.0" - firebase_messaging_web: + version: "0.9.5" + file_selector_windows: dependency: transitive description: - name: firebase_messaging_web - sha256: "7ab45dfaf8efcd1a769baa9b8debbd0da281f5d3fc07274296b564396a980292" + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec url: "https://pub.dev" source: hosted - version: "4.2.1" + version: "0.9.3+6" fixnum: dependency: transitive description: @@ -429,22 +397,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_appauth: - dependency: "direct main" - description: - name: flutter_appauth - sha256: d8be972036909e99c022bbb8edcad126dbd2cbaceaa2eb85e35791b599f3e9cf - url: "https://pub.dev" - source: hosted - version: "12.0.1" - flutter_appauth_platform_interface: - dependency: transitive - description: - name: flutter_appauth_platform_interface - sha256: b7c7d4f288af7b3119a9db0ea00cf5e93135d0e83c3687172848bc5c4fdec992 - url: "https://pub.dev" - source: hosted - version: "12.0.1" flutter_bloc: dependency: "direct main" description: @@ -628,54 +580,6 @@ packages: url: "https://pub.dev" source: hosted version: "17.0.0" - google_identity_services_web: - dependency: transitive - description: - name: google_identity_services_web - sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" - url: "https://pub.dev" - source: hosted - version: "0.3.3+1" - google_sign_in: - dependency: "direct main" - description: - name: google_sign_in - sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" - url: "https://pub.dev" - source: hosted - version: "7.2.0" - google_sign_in_android: - dependency: transitive - description: - name: google_sign_in_android - sha256: "799165f4c0621ed233bccdded4c2e92739bc1fe73e970163b2f7493b301adad3" - url: "https://pub.dev" - source: hosted - version: "7.2.1" - google_sign_in_ios: - dependency: transitive - description: - name: google_sign_in_ios - sha256: d9d80f953a244a099a40df1ff6aadc10ee375e6a098bbd5d55be332ce26db18c - url: "https://pub.dev" - source: hosted - version: "6.2.1" - google_sign_in_platform_interface: - dependency: "direct main" - description: - name: google_sign_in_platform_interface - sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - google_sign_in_web: - dependency: "direct main" - description: - name: google_sign_in_web - sha256: "2fc1f941e6443b2d6984f4056a727a3eaeab15d8ee99ba7125d79029be75a1da" - url: "https://pub.dev" - source: hosted - version: "1.1.0" graphs: dependency: transitive description: @@ -684,8 +588,16 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: - dependency: "direct main" + dependency: transitive description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -756,14 +668,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.dev" - source: hosted - version: "0.7.2" json_annotation: dependency: "direct main" description: @@ -780,78 +684,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.11.2" - kakao_flutter_sdk: - dependency: "direct main" - description: - name: kakao_flutter_sdk - sha256: a29f8a2454b5d4e275e8cbb7d1755be47b422bb2c24ef1d1369990b2beebea54 - url: "https://pub.dev" - source: hosted - version: "1.10.0" - kakao_flutter_sdk_auth: - dependency: transitive - description: - name: kakao_flutter_sdk_auth - sha256: b610ebf2a74aafc543141f1edf7d3ed82fcabcc1317181895151b1fe8f9d62d9 - url: "https://pub.dev" - source: hosted - version: "1.10.0" - kakao_flutter_sdk_common: - dependency: transitive - description: - name: kakao_flutter_sdk_common - sha256: e00122b2a6158853adfbb8d51f71f11f7c2cbbbe21f29b0d7494b0de5ec5e4fd - url: "https://pub.dev" - source: hosted - version: "1.10.0" - kakao_flutter_sdk_friend: - dependency: transitive - description: - name: kakao_flutter_sdk_friend - sha256: "4183f362d40ee5867ccff11f825ee8c95db26f1a61d1de0cb500302ad1e0d499" - url: "https://pub.dev" - source: hosted - version: "1.10.0" - kakao_flutter_sdk_navi: - dependency: transitive - description: - name: kakao_flutter_sdk_navi - sha256: ebde471865b2662d456dbe77a8bd2da95e5accea4c7c996d34083710077cf858 - url: "https://pub.dev" - source: hosted - version: "1.10.0" - kakao_flutter_sdk_share: - dependency: transitive - description: - name: kakao_flutter_sdk_share - sha256: "0223e06abc03e4ec31db45d5512dfd74323ad2db91ad6604b4dd1efa7fbee3f2" - url: "https://pub.dev" - source: hosted - version: "1.10.0" - kakao_flutter_sdk_talk: - dependency: transitive - description: - name: kakao_flutter_sdk_talk - sha256: "01c3ceba3d777f1a8c9b108c66c7e1a6559662821564ef867f30441d35e3f7f7" - url: "https://pub.dev" - source: hosted - version: "1.10.0" - kakao_flutter_sdk_template: - dependency: transitive - description: - name: kakao_flutter_sdk_template - sha256: "92650907f56a48432e17dc2aa5b84b5e05d5368f413bd2614dff1a39b8f2694b" - url: "https://pub.dev" - source: hosted - version: "1.10.0" - kakao_flutter_sdk_user: - dependency: "direct main" - description: - name: kakao_flutter_sdk_user - sha256: "33d44bd2c25ea922078c6ab3eb33d978745d8f9c1920927f4745141e08ea5e57" - url: "https://pub.dev" - source: hosted - version: "1.10.0" leak_tracker: dependency: transitive description: @@ -965,7 +797,7 @@ packages: source: hosted version: "4.1.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" @@ -981,7 +813,7 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -1100,14 +932,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - pointycastle: - dependency: transitive - description: - name: pointycastle - sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" - url: "https://pub.dev" - source: hosted - version: "3.9.1" pool: dependency: transitive description: @@ -1236,30 +1060,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" - sign_in_with_apple: - dependency: "direct main" - description: - name: sign_in_with_apple - sha256: d284f8e235ab0c5be09a1e9146466912bae8f15f0bd5eb3c4cb999b57cd5c3f9 - url: "https://pub.dev" - source: hosted - version: "8.1.0" - sign_in_with_apple_platform_interface: - dependency: transitive - description: - name: sign_in_with_apple_platform_interface - sha256: "981bca52cf3bb9c3ad7ef44aace2d543e5c468bb713fd8dda4275ff76dfa6659" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - sign_in_with_apple_web: - dependency: transitive - description: - name: sign_in_with_apple_web - sha256: f316400827f52cafcf50d00e1a2e8a0abc534ca1264e856a81c5f06bd5b10fed - url: "https://pub.dev" - source: hosted - version: "3.0.0" simple_gesture_detector: dependency: transitive description: @@ -1273,6 +1073,22 @@ packages: description: flutter source: sdk version: "0.0.0" + sodium: + dependency: "direct main" + description: + name: sodium + sha256: "515b86c186f4caca49051caf858d878ca7cc4ff4542411e9febb50654eac8a62" + url: "https://pub.dev" + source: hosted + version: "3.4.6" + sodium_libs: + dependency: "direct main" + description: + name: sodium_libs + sha256: f3f9c516b4183226b7a08ca43a765ebc9e02cfd92e46e8a6cc490f98ffe73052 + url: "https://pub.dev" + source: hosted + version: "3.4.6+4" source_gen: dependency: transitive description: @@ -1297,6 +1113,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sqlcipher_flutter_libs: + dependency: "direct main" + description: + name: sqlcipher_flutter_libs + sha256: dd1fcc74d5baf3c36ad53e2652b2d06c9f8747494a3ccde0076e88b159dfe622 + url: "https://pub.dev" + source: hosted + version: "0.6.8" sqlite3: dependency: transitive description: @@ -1306,7 +1130,7 @@ packages: source: hosted version: "2.9.4" sqlite3_flutter_libs: - dependency: "direct main" + dependency: transitive description: name: sqlite3_flutter_libs sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad @@ -1353,6 +1177,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" + url: "https://pub.dev" + source: hosted + version: "3.4.1+2" table_calendar: dependency: "direct main" description: @@ -1393,70 +1225,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - url_launcher: + unorm_dart: dependency: "direct main" description: - name: url_launcher - sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 - url: "https://pub.dev" - source: hosted - version: "6.3.2" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - sha256: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e" - url: "https://pub.dev" - source: hosted - version: "6.3.20" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 - url: "https://pub.dev" - source: hosted - version: "6.3.4" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + name: unorm_dart + sha256: "0c69186b03ca6addab0774bcc0f4f17b88d4ce78d9d4d8f0619e30a99ead58e7" url: "https://pub.dev" source: hosted - version: "3.2.2" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f - url: "https://pub.dev" - source: hosted - version: "3.2.3" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" - url: "https://pub.dev" - source: hosted - version: "3.1.5" + version: "0.3.2" uuid: dependency: "direct main" description: @@ -1545,38 +1321,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" - webview_flutter: - dependency: transitive - description: - name: webview_flutter - sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 - url: "https://pub.dev" - source: hosted - version: "4.13.1" - webview_flutter_android: - dependency: transitive - description: - name: webview_flutter_android - sha256: "9a25f6b4313978ba1c2cda03a242eea17848174912cfb4d2d8ee84a556f248e3" - url: "https://pub.dev" - source: hosted - version: "4.10.1" - webview_flutter_platform_interface: - dependency: transitive - description: - name: webview_flutter_platform_interface - sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" - url: "https://pub.dev" - source: hosted - version: "2.14.0" - webview_flutter_wkwebview: - dependency: transitive - description: - name: webview_flutter_wkwebview - sha256: fb46db8216131a3e55bcf44040ca808423539bc6732e7ed34fb6d8044e3d512f - url: "https://pub.dev" - source: hosted - version: "3.23.0" win32: dependency: transitive description: @@ -1610,5 +1354,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.0 <4.0.0" - flutter: ">=3.41.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 50eb31f9..17070578 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.1+1 +version: 1.1.0+56 environment: sdk: ^3.8.0 @@ -45,20 +45,12 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - dio: ^5.7.0 - equatable: ^2.0.5 collection: ^1.18.0 uuid: ^4.5.1 flutter_svg: ^2.0.14 - google_sign_in: ^7.2.0 - kakao_flutter_sdk_user: ^1.9.6 - http: ^1.2.2 - url_launcher: ^6.3.1 shared_preferences: ^2.3.3 - kakao_flutter_sdk: ^1.9.6 - flutter_appauth: ^12.0.1 go_router: ^17.0.0 rxdart: ^0.28.0 table_calendar: ^3.1.3 @@ -71,19 +63,18 @@ dependencies: freezed_annotation: ^3.1.0 formz: ^0.8.0 dotted_line: ^3.2.3 - sqlite3_flutter_libs: ^0.5.31 - firebase_messaging: ^16.4.1 - firebase_core: ^4.11.0 + sqlcipher_flutter_libs: 0.6.8 flutter_local_notifications: ^20.1.0 timezone: ^0.10.0 - google_sign_in_web: ^1.1.0 - google_sign_in_platform_interface: ^3.1.0 intl: ^0.20.2 - - sign_in_with_apple: ^8.1.0 permission_handler: ^12.0.3 - firebase_analytics: ^12.4.3 package_info_plus: ^10.2.0 + path: ^1.9.1 + path_provider: ^2.1.5 + file_selector: ^1.0.4 + sodium_libs: 3.4.6+4 + sodium: 3.4.6 + unorm_dart: ^0.3.2 diff --git a/test/core/backup/backup_crypto_test.dart b/test/core/backup/backup_crypto_test.dart new file mode 100644 index 00000000..092d232d --- /dev/null +++ b/test/core/backup/backup_crypto_test.dart @@ -0,0 +1,60 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_crypto.dart'; +import '../../helpers/sodium_test_loader.dart'; + +void main() { + const password = 'correct horse battery'; + final plaintext = Uint8List.fromList(utf8.encode('offline schedule backup')); + + test('round trip restores the authenticated plaintext', () async { + final crypto = BackupCrypto(sodiumLoader: loadSodiumForTest); + + final encrypted = await crypto.encrypt( + plaintext: plaintext, + password: password, + ); + final restored = await crypto.decrypt( + container: encrypted, + password: password, + ); + + expect(restored, plaintext); + expect( + utf8.decode(encrypted, allowMalformed: true), + isNot(contains('offline schedule backup')), + ); + }); + + test('wrong password cannot produce backup contents', () async { + final crypto = BackupCrypto(sodiumLoader: loadSodiumForTest); + final encrypted = await crypto.encrypt( + plaintext: plaintext, + password: password, + ); + + await expectLater( + crypto.decrypt( + container: encrypted, + password: 'incorrect password 123', + ), + throwsFormatException, + ); + }); + + test('authenticated encryption detects a modified container', () async { + final crypto = BackupCrypto(sodiumLoader: loadSodiumForTest); + final encrypted = await crypto.encrypt( + plaintext: plaintext, + password: password, + ); + encrypted[encrypted.length - 1] ^= 1; + + await expectLater( + crypto.decrypt(container: encrypted, password: password), + throwsFormatException, + ); + }); +} diff --git a/test/core/backup/backup_password_test.dart b/test/core/backup/backup_password_test.dart new file mode 100644 index 00000000..69e38ec6 --- /dev/null +++ b/test/core/backup/backup_password_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/backup/backup_password.dart'; + +void main() { + test('normalizes canonically equivalent passwords to the same value', () { + final composed = BackupPassword.parse('é12345678901234'); + final decomposed = BackupPassword.parse('e\u030112345678901234'); + + expect(decomposed.normalized, composed.normalized); + expect(decomposed.utf8Bytes, composed.utf8Bytes); + }); + + test('preserves case and surrounding spaces', () { + final parsed = BackupPassword.parse(' AbCdEfGhIjKlM '); + + expect(parsed.normalized, ' AbCdEfGhIjKlM '); + }); + + test('rejects passwords outside the 15 to 128 code point boundary', () { + expect(() => BackupPassword.parse('12345678901234'), throwsFormatException); + expect( + () => BackupPassword.parse(List.filled(129, '가').join()), + throwsFormatException, + ); + }); +} diff --git a/test/core/backup/backup_service_test.dart b/test/core/backup/backup_service_test.dart new file mode 100644 index 00000000..2a16a82f --- /dev/null +++ b/test/core/backup/backup_service_test.dart @@ -0,0 +1,120 @@ +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_service.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/services/app_metadata_service.dart'; +import 'package:on_time_front/data/mappers/domain_persistence_mappers.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/entities/user_entity.dart'; + +import '../../helpers/sodium_test_loader.dart'; + +void main() { + const password = 'portable backup password'; + late AppDatabase database; + late BackupService service; + + setUp(() async { + database = AppDatabase.forTesting(NativeDatabase.memory()); + service = BackupService( + database, + _MetadataProvider(), + crypto: BackupCrypto(sodiumLoader: loadSodiumForTest), + ); + await database.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration(minutes: 7), + note: 'local note', + eligibleOutcomeCount: 4, + onTimeOutcomeCount: 3, + isOnboardingCompleted: true, + ), + ); + await database.preparationUserDao.createPreparationUser( + const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'default-step', + preparationName: 'Pack', + preparationTime: Duration(minutes: 5), + ), + ], + ), + 'local-profile', + ); + await database.scheduleDao.createSchedule(_schedule().toScheduleWithPlaceRow()); + }); + + tearDown(() => database.close()); + + test('preview authenticates backup and restore replaces active data', () async { + final encrypted = await service.createEncryptedBackup(password); + + await database.deleteAllDurableData(); + await database.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: 'replacement data', + ), + ); + + final candidate = await service.previewEncryptedBackup(encrypted, password); + expect(candidate.preview.scheduleCount, 1); + expect(candidate.preview.defaultPreparationStepCount, 1); + + await service.applyRestore(candidate); + + final restoredUser = (await database.userDao.getUserById('local-profile'))!; + final restoredSchedules = await database.scheduleDao.getScheduleList(); + expect(restoredUser.note, 'local note'); + expect(restoredUser.scoreOrNull, 75); + expect(restoredSchedules.single.schedule.id, 'schedule-1'); + }); + + test('wrong password leaves current database unchanged', () async { + final encrypted = await service.createEncryptedBackup(password); + await database.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: 'must survive', + ), + ); + + await expectLater( + service.previewEncryptedBackup(encrypted, 'wrong backup password'), + throwsFormatException, + ); + + expect( + (await database.userDao.getUserById('local-profile'))!.note, + 'must survive', + ); + }); +} + +ScheduleEntity _schedule() => ScheduleEntity( + id: 'schedule-1', + place: const PlaceEntity(id: 'place-1', placeName: 'Office'), + scheduleName: 'Meeting', + timeZoneId: 'Asia/Seoul', + occurrenceOffsetSeconds: 9 * 60 * 60, + scheduleTime: DateTime(2026, 9, 2, 10), + moveTime: const Duration(minutes: 20), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 5), + scheduleNote: '', +); + +class _MetadataProvider implements AppMetadataProvider { + @override + Future getMetadata() async => + const AppMetadata(version: '1.0.1', buildNumber: '1'); +} diff --git a/test/core/database/database_index_test.dart b/test/core/database/database_index_test.dart index 0c26600a..ea095bd1 100644 --- a/test/core/database/database_index_test.dart +++ b/test/core/database/database_index_test.dart @@ -29,29 +29,13 @@ void main() { }, ); - test('adds lookup indexes when upgrading a schema 3 database', () async { - expect(database.schemaVersion, 4); + test('rejects in-place upgrades across the one-way local cutover', () async { + expect(database.schemaVersion, 1); - await _dropExpectedLookupIndexes(database); - - for (final entry in _expectedLookupIndexesByTable.entries) { - expect( - await _indexNames(database, entry.key), - isNot(containsAll(entry.value)), - ); - } - - await database.migration.onUpgrade(database.createMigrator(), 3, 4); - - for (final entry in _expectedLookupIndexesByTable.entries) { - final indexNames = await _indexNames(database, entry.key); - - expect( - indexNames, - containsAll(entry.value), - reason: '${entry.key} indexes should be added by the 3 -> 4 migration', - ); - } + await expectLater( + database.migration.onUpgrade(database.createMigrator(), 0, 1), + throwsStateError, + ); }); } @@ -81,11 +65,3 @@ Future> _indexNames(AppDatabase database, String tableName) async { return {for (final row in rows) row.read('name')}; } - -Future _dropExpectedLookupIndexes(AppDatabase database) async { - for (final indexNames in _expectedLookupIndexesByTable.values) { - for (final indexName in indexNames) { - await database.customStatement('DROP INDEX IF EXISTS $indexName'); - } - } -} diff --git a/test/core/dio/api_error_message_test.dart b/test/core/dio/api_error_message_test.dart deleted file mode 100644 index b7285ee5..00000000 --- a/test/core/dio/api_error_message_test.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/core/dio/api_error_message.dart'; - -void main() { - test('extracts top-level backend validation message', () { - final message = ApiErrorMessage.fromResponseData({ - 'status': 'error', - 'code': 1002, - 'message': '유효하지 않은 입력값입니다.', - 'data': { - 'errors': [ - {'field': 'email', 'message': '이메일 형식이 올바르지 않습니다.'}, - ], - }, - }); - - expect(message, '유효하지 않은 입력값입니다.'); - }); - - test( - 'falls back to first field message when top-level message is absent', - () { - final message = ApiErrorMessage.fromResponseData({ - 'data': { - 'errors': [ - {'field': 'email', 'message': '이메일 형식이 올바르지 않습니다.'}, - ], - }, - }); - - expect(message, '이메일 형식이 올바르지 않습니다.'); - }, - ); -} diff --git a/test/core/dio/interceptors/logger_interceptor_test.dart b/test/core/dio/interceptors/logger_interceptor_test.dart deleted file mode 100644 index 070f6c13..00000000 --- a/test/core/dio/interceptors/logger_interceptor_test.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'dart:typed_data'; - -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/core/dio/interceptors/logger_interceptor.dart'; - -void main() { - late Dio dio; - late _LoggerAdapter adapter; - - setUp(() { - adapter = _LoggerAdapter(); - dio = Dio( - BaseOptions( - baseUrl: 'https://example.com', - receiveDataWhenStatusError: true, - ), - )..httpClientAdapter = adapter; - dio.interceptors.add(LoggerInterceptor()); - }); - - test('passes successful requests and responses through unchanged', () async { - adapter.nextStatusCode = 200; - - final response = await dio.post>( - '/appointments', - queryParameters: {'token': 'secret-token'}, - data: {'name': 'Design review'}, - options: Options(headers: {'Authorization': 'Bearer secret'}), - ); - - expect(response.statusCode, 200); - expect(response.data, {'message': 'ok'}); - expect(adapter.requestedPaths, ['/appointments']); - expect(adapter.requestedMethods, ['POST']); - }); - - test('passes Dio errors through to the caller', () async { - adapter.nextStatusCode = 500; - - await expectLater( - dio.get>( - '/broken', - options: Options(headers: {'Authorization-refresh': 'refresh'}), - ), - throwsA( - isA().having( - (error) => error.response?.statusCode, - 'statusCode', - 500, - ), - ), - ); - - expect(adapter.requestedPaths, ['/broken']); - expect(adapter.requestedMethods, ['GET']); - }); -} - -class _LoggerAdapter implements HttpClientAdapter { - int nextStatusCode = 200; - final requestedPaths = []; - final requestedMethods = []; - - @override - Future fetch( - RequestOptions options, - Stream? requestStream, - Future? cancelFuture, - ) async { - requestedPaths.add(options.path); - requestedMethods.add(options.method); - return ResponseBody.fromString( - '{"message":"ok"}', - nextStatusCode, - headers: { - Headers.contentTypeHeader: [Headers.jsonContentType], - }, - ); - } - - @override - void close({bool force = false}) {} -} diff --git a/test/core/dio/interceptors/token_interceptor_test.dart b/test/core/dio/interceptors/token_interceptor_test.dart deleted file mode 100644 index 41750546..00000000 --- a/test/core/dio/interceptors/token_interceptor_test.dart +++ /dev/null @@ -1,413 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:dio/dio.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/core/constants/endpoint.dart'; -import 'package:on_time_front/core/dio/interceptors/token_interceptor.dart'; -import 'package:on_time_front/core/dio/interceptors/token_session_invalidator.dart'; -import 'package:on_time_front/data/data_sources/token_local_data_source.dart'; -import 'package:on_time_front/domain/entities/token_entity.dart'; - -void main() { - late Dio dio; - late _FakeTokenLocalDataSource tokenLocalDataSource; - late _FakeTokenSessionInvalidator sessionInvalidator; - late _TokenRefreshAdapter adapter; - - setUp(() async { - tokenLocalDataSource = _FakeTokenLocalDataSource(); - sessionInvalidator = _FakeTokenSessionInvalidator(tokenLocalDataSource); - - adapter = _TokenRefreshAdapter(); - dio = _dioWithTokenInterceptor( - adapter, - tokenLocalDataSource: tokenLocalDataSource, - sessionInvalidator: sessionInvalidator, - ); - }); - - test('reuses cached access token for repeated protected requests', () async { - final storage = _CountingSecureStorage({ - 'accessToken': 'access-token', - 'refreshToken': 'refresh-token', - }); - final cachedTokenLocalDataSource = TokenLocalDataSourceImpl.withStorage( - storage, - ); - - adapter = _TokenRefreshAdapter(authorizeProtectedRequests: true); - dio = Dio( - BaseOptions( - baseUrl: 'https://example.com', - receiveDataWhenStatusError: true, - ), - )..httpClientAdapter = adapter; - dio.interceptors.add( - TokenInterceptor( - dio, - tokenLocalDataSource: cachedTokenLocalDataSource, - sessionInvalidator: _FakeTokenSessionInvalidator( - cachedTokenLocalDataSource, - ), - ), - ); - - final firstResponse = await dio.get('/protected/one'); - final secondResponse = await dio.get('/protected/two'); - - expect(firstResponse.statusCode, 200); - expect(secondResponse.statusCode, 200); - expect(adapter.refreshRequests, 0); - expect(adapter.protectedAuthorizationHeaders, [ - 'Bearer access-token', - 'Bearer access-token', - ]); - expect(storage.readsByKey, {'accessToken': 1, 'refreshToken': 1}); - }); - - test( - 'stores refreshed access and refresh tokens before retrying request', - () async { - final response = await dio.get('/protected'); - - expect(response.statusCode, 200); - expect( - tokenLocalDataSource.storedToken, - const TokenEntity( - accessToken: 'new-access-token', - refreshToken: 'new-refresh-token', - ), - ); - expect(tokenLocalDataSource.storeTokensCallCount, 1); - expect(adapter.refreshRequests, 1); - expect( - adapter.protectedAuthorizationHeaders, - contains('Bearer new-access-token'), - ); - }, - ); - - test('retries concurrent queued requests after a single refresh', () async { - final refreshCompleter = Completer(); - adapter = _TokenRefreshAdapter(refreshCompleter: refreshCompleter); - dio.httpClientAdapter = adapter; - - final firstRequest = dio.get('/protected/one'); - await _flushMicrotasks(); - final secondRequest = dio.get('/protected/two'); - await _flushMicrotasks(); - - expect(adapter.refreshRequests, 1); - refreshCompleter.complete(); - - final responses = await Future.wait([firstRequest, secondRequest]); - - expect(responses.map((response) => response.statusCode), everyElement(200)); - expect(adapter.refreshRequests, 1); - expect( - adapter.protectedAuthorizationHeaders.where( - (header) => header == 'Bearer new-access-token', - ), - hasLength(2), - ); - }); - - test('shares refresh coordination across interceptor instances', () async { - final refreshCompleter = Completer(); - adapter = _TokenRefreshAdapter(refreshCompleter: refreshCompleter); - final firstDio = _dioWithTokenInterceptor( - adapter, - tokenLocalDataSource: tokenLocalDataSource, - sessionInvalidator: sessionInvalidator, - ); - final secondDio = _dioWithTokenInterceptor( - adapter, - tokenLocalDataSource: tokenLocalDataSource, - sessionInvalidator: sessionInvalidator, - ); - - final firstRequest = firstDio.get('/protected/one'); - await _flushMicrotasks(); - final secondRequest = secondDio.get('/protected/two'); - await _flushMicrotasks(); - - expect(adapter.refreshRequests, 1); - refreshCompleter.complete(); - - final responses = await Future.wait([firstRequest, secondRequest]); - - expect(responses.map((response) => response.statusCode), everyElement(200)); - expect(adapter.refreshRequests, 1); - expect(tokenLocalDataSource.storeTokensCallCount, 1); - expect( - adapter.protectedAuthorizationHeaders.where( - (header) => header == 'Bearer new-access-token', - ), - hasLength(2), - ); - }); - - test('rejects original request when retry after refresh fails', () async { - adapter = _TokenRefreshAdapter(retryStatusCode: 500); - dio.httpClientAdapter = adapter; - - await expectLater( - dio.get('/protected'), - throwsA( - isA().having( - (error) => error.response?.statusCode, - 'statusCode', - 500, - ), - ), - ); - - expect(adapter.refreshRequests, 1); - expect(sessionInvalidator.signOutCalled, isFalse); - }); - - test('locally signs out when refresh token request returns 401', () async { - adapter = _TokenRefreshAdapter(refreshStatusCode: 401); - dio.httpClientAdapter = adapter; - - await expectLater( - dio.get('/protected'), - throwsA(isA()), - ); - - expect( - adapter.requestedPaths, - containsAll(['/protected', '/refresh-token']), - ); - expect(adapter.refreshRequests, 1); - expect(sessionInvalidator.signOutCalled, isTrue); - expect(tokenLocalDataSource.deleteTokenCalled, isTrue); - }); - - test( - 'continues authentication requests when local token lookup fails', - () async { - tokenLocalDataSource.throwOnGetToken = true; - - final response = await dio.post(Endpoint.signIn); - - expect(response.statusCode, 200); - expect(adapter.protectedAuthorizationHeaders, isEmpty); - }, - ); - - test( - 'rejects protected requests locally when local token lookup fails', - () async { - tokenLocalDataSource.throwOnGetToken = true; - - await expectLater( - dio.get('/protected'), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('Authentication token is unavailable'), - ), - ), - ); - - expect(adapter.requestedPaths, isEmpty); - expect(sessionInvalidator.signOutCalled, isFalse); - expect(tokenLocalDataSource.deleteTokenCalled, isFalse); - }, - ); - - test( - 'missing refresh headers rejects request and signs out locally', - () async { - adapter = _TokenRefreshAdapter(omitRefreshHeaders: true); - dio.httpClientAdapter = adapter; - - await expectLater( - dio.get('/protected'), - throwsA(isA()), - ); - - expect(adapter.refreshRequests, 1); - expect(sessionInvalidator.signOutCalled, isTrue); - expect(tokenLocalDataSource.deleteTokenCalled, isTrue); - }, - ); -} - -Dio _dioWithTokenInterceptor( - HttpClientAdapter adapter, { - required TokenLocalDataSource tokenLocalDataSource, - required TokenSessionInvalidator sessionInvalidator, -}) { - final dio = Dio( - BaseOptions( - baseUrl: 'https://example.com', - receiveDataWhenStatusError: true, - ), - )..httpClientAdapter = adapter; - dio.interceptors.add( - TokenInterceptor( - dio, - tokenLocalDataSource: tokenLocalDataSource, - sessionInvalidator: sessionInvalidator, - ), - ); - return dio; -} - -Future _flushMicrotasks() async { - for (var i = 0; i < 5; i++) { - await Future.delayed(Duration.zero); - } -} - -class _TokenRefreshAdapter implements HttpClientAdapter { - _TokenRefreshAdapter({ - this.refreshStatusCode = 200, - this.retryStatusCode = 200, - this.refreshCompleter, - this.omitRefreshHeaders = false, - this.authorizeProtectedRequests = false, - }); - - final int refreshStatusCode; - final int retryStatusCode; - final Completer? refreshCompleter; - final bool omitRefreshHeaders; - final bool authorizeProtectedRequests; - - final requestedPaths = []; - final protectedAuthorizationHeaders = []; - final refreshAuthorizationHeaders = []; - final _pathRequestCounts = {}; - - int refreshRequests = 0; - - @override - Future fetch( - RequestOptions options, - Stream? requestStream, - Future? cancelFuture, - ) async { - requestedPaths.add(options.path); - if (options.path == '/refresh-token') { - refreshRequests++; - refreshAuthorizationHeaders.add( - options.headers['Authorization-refresh']?.toString(), - ); - await refreshCompleter?.future; - return ResponseBody.fromString( - '{"message":"Refresh response"}', - refreshStatusCode, - headers: { - Headers.contentTypeHeader: [Headers.jsonContentType], - if (refreshStatusCode == 200 && !omitRefreshHeaders) ...{ - 'authorization': ['new-access-token'], - 'authorization-refresh': ['new-refresh-token'], - }, - }, - ); - } - - if (options.path.startsWith('/protected')) { - protectedAuthorizationHeaders.add( - options.headers['Authorization']?.toString(), - ); - final requestCount = (_pathRequestCounts[options.path] ?? 0) + 1; - _pathRequestCounts[options.path] = requestCount; - - if (!authorizeProtectedRequests && requestCount == 1) { - return _response(401, '{"message":"Unauthorized"}'); - } - - return _response(retryStatusCode, '{"message":"Retried response"}'); - } - - return _response(200, '{"message":"OK"}'); - } - - ResponseBody _response(int statusCode, String body) { - return ResponseBody.fromString( - body, - statusCode, - headers: { - Headers.contentTypeHeader: [Headers.jsonContentType], - }, - ); - } - - @override - void close({bool force = false}) {} -} - -class _CountingSecureStorage extends FlutterSecureStorage { - _CountingSecureStorage(this.values); - - final Map values; - final readsByKey = {}; - - @override - Future read({ - required String key, - AppleOptions? iOptions, - AndroidOptions? aOptions, - LinuxOptions? lOptions, - WebOptions? webOptions, - AppleOptions? mOptions, - WindowsOptions? wOptions, - }) async { - readsByKey[key] = (readsByKey[key] ?? 0) + 1; - return values[key]; - } -} - -class _FakeTokenLocalDataSource implements TokenLocalDataSource { - TokenEntity token = const TokenEntity( - accessToken: 'access-token', - refreshToken: 'refresh-token', - ); - TokenEntity? storedToken; - bool deleteTokenCalled = false; - int storeTokensCallCount = 0; - bool throwOnGetToken = false; - - @override - Future deleteToken() async { - deleteTokenCalled = true; - } - - @override - Future getToken() async { - if (throwOnGetToken) { - throw Exception('token unavailable'); - } - return token; - } - - @override - Future storeAuthToken(String token) async {} - - @override - Future storeTokens(TokenEntity token) async { - storeTokensCallCount++; - storedToken = token; - this.token = token; - } -} - -class _FakeTokenSessionInvalidator implements TokenSessionInvalidator { - _FakeTokenSessionInvalidator(this._tokenLocalDataSource); - - final TokenLocalDataSource _tokenLocalDataSource; - bool signOutCalled = false; - - @override - Future signOutLocally() async { - signOutCalled = true; - await _tokenLocalDataSource.deleteToken(); - } -} diff --git a/test/core/dio/transformers/logging_transformer_test.dart b/test/core/dio/transformers/logging_transformer_test.dart deleted file mode 100644 index 25a99bd3..00000000 --- a/test/core/dio/transformers/logging_transformer_test.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/core/dio/app_dio.dart'; -import 'package:on_time_front/core/dio/interceptors/logger_interceptor.dart'; -import 'package:on_time_front/core/dio/interceptors/token_interceptor.dart'; -import 'package:on_time_front/core/dio/interceptors/token_session_invalidator.dart'; -import 'package:on_time_front/core/dio/transformers/logging_transformer.dart'; -import 'package:on_time_front/data/data_sources/token_local_data_source.dart'; -import 'package:on_time_front/domain/entities/token_entity.dart'; - -void main() { - test( - 'logging transformer preserves serialized request and response body', - () async { - final inner = _FakeTransformer(); - final transformer = LoggingTransformer(inner: inner); - final options = RequestOptions(path: '/test', method: 'POST'); - final responseBody = ResponseBody.fromString('{"ok":true}', 200); - - expect(await transformer.transformRequest(options), '{"name":"meeting"}'); - expect(await transformer.transformResponse(options, responseBody), { - 'ok': true, - }); - }, - ); - - test('AppDio configures JSON defaults, logging, and auth interceptors', () { - final dio = AppDio( - _FakeTokenLocalDataSource(), - _FakeTokenSessionInvalidator(), - ); - - expect(dio.options.contentType, Headers.jsonContentType); - expect(dio.options.receiveDataWhenStatusError, isTrue); - expect(dio.options.followRedirects, isFalse); - expect(dio.transformer, isA()); - expect(dio.interceptors.whereType(), hasLength(1)); - expect(dio.interceptors.whereType(), hasLength(1)); - }); -} - -class _FakeTransformer implements Transformer { - @override - Future transformRequest(RequestOptions options) async { - return '{"name":"meeting"}'; - } - - @override - Future transformResponse( - RequestOptions options, - ResponseBody responseBody, - ) async { - return {'ok': true}; - } -} - -class _FakeTokenLocalDataSource implements TokenLocalDataSource { - @override - Future storeTokens(TokenEntity token) async {} - - @override - Future storeAuthToken(String token) async {} - - @override - Future getToken() async { - return const TokenEntity(accessToken: 'access', refreshToken: 'refresh'); - } - - @override - Future deleteToken() async {} -} - -class _FakeTokenSessionInvalidator implements TokenSessionInvalidator { - @override - Future signOutLocally() async {} -} diff --git a/test/core/services/fallback_alarm_notification_service_test.dart b/test/core/services/fallback_alarm_notification_service_test.dart index 6d286884..78a168e7 100644 --- a/test/core/services/fallback_alarm_notification_service_test.dart +++ b/test/core/services/fallback_alarm_notification_service_test.dart @@ -1,4 +1,3 @@ -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; import 'package:on_time_front/core/services/notification_service.dart'; @@ -6,7 +5,7 @@ import 'package:on_time_front/domain/entities/alarm_entities.dart'; void main() { test( - 'permission checks map Firebase authorization to alarm permission', + 'permission checks map local notification authorization to alarm permission', () async { final notificationService = _FakeNotificationService( checkStatus: AuthorizationStatus.provisional, diff --git a/test/core/services/notification_content_test.dart b/test/core/services/notification_content_test.dart index 24423fc0..422d79ed 100644 --- a/test/core/services/notification_content_test.dart +++ b/test/core/services/notification_content_test.dart @@ -5,51 +5,6 @@ import 'package:on_time_front/core/services/notification_content.dart'; import 'package:on_time_front/domain/entities/alarm_entities.dart'; void main() { - test('remote notification content prefers FCM notification text', () { - final content = remoteNotificationDisplayContent( - notificationTitle: 'Server title', - notificationBody: 'Server body', - data: const { - 'title': 'Data title', - 'body': 'Data body', - 'scheduleId': 'schedule-1', - }, - ); - - expect(content?.title, 'Server title'); - expect(content?.body, 'Server body'); - expect(jsonDecode(content!.payload), { - 'title': 'Data title', - 'body': 'Data body', - 'scheduleId': 'schedule-1', - }); - }); - - test( - 'remote notification content accepts backend title and body variants', - () { - expect( - remoteNotificationDisplayContent( - data: const {'Title': 'Upper title', 'Content': 'Upper content'}, - )?.title, - 'Upper title', - ); - expect( - remoteNotificationDisplayContent( - data: const {'title': 'Lower title', 'content': 'Lower content'}, - )?.body, - 'Lower content', - ); - expect( - remoteNotificationDisplayContent( - data: const {'Body': 'Body only'}, - )?.title, - '알림', - ); - expect(remoteNotificationDisplayContent(data: const {}), isNull); - }, - ); - test('local notification payloads are encoded only when present', () { expect(encodeLocalNotificationPayload(null), isNull); expect( diff --git a/test/core/services/notification_routing_test.dart b/test/core/services/notification_routing_test.dart index abd5400e..8cc3fb51 100644 --- a/test/core/services/notification_routing_test.dart +++ b/test/core/services/notification_routing_test.dart @@ -52,33 +52,6 @@ void main() { ); }); - group('isScheduleAlarmMessagePayload', () { - test('detects native alarm push messages from data or known titles', () { - expect( - isScheduleAlarmMessagePayload( - data: const {'type': 'schedule_alarm'}, - title: null, - ), - isTrue, - ); - expect( - isScheduleAlarmMessagePayload(data: const {}, title: '약속 알림'), - isTrue, - ); - expect( - isScheduleAlarmMessagePayload(data: const {}, title: 'Schedule alarm'), - isTrue, - ); - expect( - isScheduleAlarmMessagePayload( - data: const {'type': 'announcement'}, - title: 'General', - ), - isFalse, - ); - }); - }); - group('notificationRouteForPayloadString', () { test( 'routes schedule notification payload to the schedule start screen', @@ -152,7 +125,7 @@ void main() { }); group('notificationRouteForData', () { - test('routes background message data with the same notification rules', () { + test('routes decoded local payload data with the same rules', () { expect( notificationRouteForData(const { 'type': 'schedule_notification', diff --git a/test/core/services/notification_service_test.dart b/test/core/services/notification_service_test.dart deleted file mode 100644 index 13641e87..00000000 --- a/test/core/services/notification_service_test.dart +++ /dev/null @@ -1,915 +0,0 @@ -import 'dart:async'; - -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/core/services/notification_service.dart'; -import 'package:on_time_front/core/services/notification_tap_router.dart'; -import 'package:on_time_front/core/services/notification_token_registrar.dart'; -import 'package:on_time_front/domain/entities/alarm_entities.dart'; -import 'package:timezone/timezone.dart' as tz; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - test( - 'hasNotificationPermission accepts authorized and provisional states', - () async { - final messaging = _FakeFirebaseMessaging(AuthorizationStatus.authorized); - final localNotifications = _RecordingLocalNotifications(); - - final service = NotificationService.test( - messaging: messaging, - localNotifications: localNotifications, - isFlutterLocalNotificationsInitialized: true, - ); - - expect(await service.hasNotificationPermission(), isTrue); - - messaging.authorizationStatus = AuthorizationStatus.provisional; - expect(await service.hasNotificationPermission(), isTrue); - - messaging.authorizationStatus = AuthorizationStatus.denied; - expect(await service.hasNotificationPermission(), isFalse); - }, - ); - - test('requestPermission delegates to messaging on mobile targets', () async { - final messaging = _FakeFirebaseMessaging(AuthorizationStatus.notDetermined) - ..requestedAuthorizationStatus = AuthorizationStatus.authorized; - final service = NotificationService.test( - messaging: messaging, - localNotifications: _RecordingLocalNotifications(), - isFlutterLocalNotificationsInitialized: true, - ); - - expect(await service.requestPermission(), AuthorizationStatus.authorized); - expect(messaging.requestPermissionCount, 1); - }); - - test('iOS permission checks use local notification permission', () async { - final messaging = _FakeFirebaseMessaging(AuthorizationStatus.authorized); - final iosPlugin = _FakeIOSLocalNotificationsPlugin( - permissionsEnabled: false, - ); - final service = NotificationService.test( - messaging: messaging, - localNotifications: _RecordingLocalNotifications(iosPlugin: iosPlugin), - isFlutterLocalNotificationsInitialized: true, - isIOSOverride: true, - ); - - expect( - await service.checkNotificationPermission(), - AuthorizationStatus.denied, - ); - expect(iosPlugin.checkPermissionsCount, 1); - }); - - test('iOS permission requests ask local notification plugin too', () async { - final messaging = _FakeFirebaseMessaging(AuthorizationStatus.notDetermined) - ..requestedAuthorizationStatus = AuthorizationStatus.authorized; - final iosPlugin = _FakeIOSLocalNotificationsPlugin( - requestPermissionsResult: true, - ); - final service = NotificationService.test( - messaging: messaging, - localNotifications: _RecordingLocalNotifications(iosPlugin: iosPlugin), - isFlutterLocalNotificationsInitialized: true, - isIOSOverride: true, - ); - - expect(await service.requestPermission(), AuthorizationStatus.authorized); - expect(messaging.requestPermissionCount, 1); - expect(iosPlugin.requestPermissionsCount, 1); - expect(iosPlugin.lastRequestedAlert, isTrue); - expect(iosPlugin.lastRequestedBadge, isTrue); - expect(iosPlugin.lastRequestedSound, isTrue); - }); - - test( - 'initialize requests permission, sets up local notifications, and routes initial messages', - () async { - final messaging = - _FakeFirebaseMessaging(AuthorizationStatus.notDetermined) - ..requestedAuthorizationStatus = AuthorizationStatus.authorized - ..initialMessage = const RemoteMessage( - data: {'type': 'preparation_step', 'scheduleId': 'schedule-1'}, - ); - final localNotifications = _RecordingLocalNotifications(); - final tapRouter = _FakeNotificationTapRouter(); - const firebaseMessagingChannel = MethodChannel( - 'plugins.flutter.io/firebase_messaging', - ); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - firebaseMessagingChannel, - (_) async => null, - ); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(firebaseMessagingChannel, null); - }); - final service = NotificationService.test( - messaging: messaging, - localNotifications: localNotifications, - notificationTapRouter: tapRouter, - ); - - await service.initialize(); - - expect(messaging.requestPermissionCount, 1); - expect(messaging.getTokenCount, 1); - expect(messaging.getInitialMessageCount, 1); - expect(localNotifications.initializeCount, 1); - expect(tapRouter.remoteMessageData.single['scheduleId'], 'schedule-1'); - }, - ); - - test( - 'repeated initialize handles each notification entry point once', - () async { - final messaging = - _FakeFirebaseMessaging(AuthorizationStatus.notDetermined) - ..requestedAuthorizationStatus = AuthorizationStatus.authorized - ..initialMessage = const RemoteMessage( - data: {'type': 'preparation_step', 'scheduleId': 'initial'}, - ); - final localNotifications = _RecordingLocalNotifications(); - final tapRouter = _FakeNotificationTapRouter(); - final foregroundMessages = StreamController.broadcast(); - final openedAppMessages = StreamController.broadcast(); - const firebaseMessagingChannel = MethodChannel( - 'plugins.flutter.io/firebase_messaging', - ); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - firebaseMessagingChannel, - (_) async => null, - ); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(firebaseMessagingChannel, null); - foregroundMessages.close(); - openedAppMessages.close(); - }); - final service = NotificationService.test( - messaging: messaging, - localNotifications: localNotifications, - notificationTapRouter: tapRouter, - onMessage: foregroundMessages.stream, - onMessageOpenedApp: openedAppMessages.stream, - ); - - await service.initialize(); - await service.initialize(); - - foregroundMessages.add( - const RemoteMessage(data: {'title': 'Title', 'body': 'Body'}), - ); - openedAppMessages.add( - const RemoteMessage( - data: {'type': 'preparation_step', 'scheduleId': 'opened'}, - ), - ); - await pumpEventQueue(); - - expect(messaging.getInitialMessageCount, 1); - expect(localNotifications.shown, hasLength(1)); - expect(tapRouter.remoteMessageData.map((data) => data['scheduleId']), [ - 'initial', - 'opened', - ]); - }, - ); - - test('concurrent initialize shares one in-flight setup', () async { - final permissionBlocker = Completer(); - final messaging = _FakeFirebaseMessaging(AuthorizationStatus.notDetermined) - ..requestedAuthorizationStatus = AuthorizationStatus.authorized - ..requestPermissionBlocker = permissionBlocker - ..token = 'fcm-token'; - final localNotifications = _RecordingLocalNotifications(); - final tokenRegistrar = _FakeFcmTokenRegistrar(); - const firebaseMessagingChannel = MethodChannel( - 'plugins.flutter.io/firebase_messaging', - ); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(firebaseMessagingChannel, (_) async => null); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(firebaseMessagingChannel, null); - }); - final service = NotificationService.test( - messaging: messaging, - localNotifications: localNotifications, - fcmTokenRegistrar: tokenRegistrar, - ); - - final firstInitialize = service.initialize(); - await pumpEventQueue(); - final secondInitialize = service.initialize(); - - permissionBlocker.complete(); - await Future.wait([firstInitialize, secondInitialize]); - - expect(messaging.requestPermissionCount, 1); - expect(messaging.getInitialMessageCount, 1); - expect(localNotifications.initializeCount, 1); - expect(tokenRegistrar.registeredTokens, ['fcm-token']); - }); - - test( - 'openNotificationSettings returns false when platform launch fails', - () async { - final service = NotificationService.test( - messaging: _FakeFirebaseMessaging(AuthorizationStatus.denied), - localNotifications: _RecordingLocalNotifications(), - isFlutterLocalNotificationsInitialized: true, - ); - - expect(await service.openNotificationSettings(), isFalse); - }, - ); - - test('requestNotificationToken tolerates missing FCM token', () async { - final messaging = _FakeFirebaseMessaging(AuthorizationStatus.authorized); - final service = NotificationService.test( - messaging: messaging, - localNotifications: _RecordingLocalNotifications(), - isFlutterLocalNotificationsInitialized: true, - ); - - await service.requestNotificationToken(); - - expect(messaging.getTokenCount, 1); - expect(messaging.tokenRefreshListened, isTrue); - }); - - test('requestNotificationToken delegates FCM token registration', () async { - final messaging = _FakeFirebaseMessaging(AuthorizationStatus.authorized) - ..token = 'fcm-token'; - final tokenRegistrar = _FakeFcmTokenRegistrar(); - final service = NotificationService.test( - messaging: messaging, - localNotifications: _RecordingLocalNotifications(), - isFlutterLocalNotificationsInitialized: true, - fcmTokenRegistrar: tokenRegistrar, - ); - - await service.requestNotificationToken(); - - expect(tokenRegistrar.registeredTokens, ['fcm-token']); - }); - - test( - 'token refreshes register the refreshed token for this device', - () async { - final messaging = _FakeFirebaseMessaging(AuthorizationStatus.authorized) - ..token = 'initial-token'; - final tokenRegistrar = _FakeFcmTokenRegistrar(); - final service = NotificationService.test( - messaging: messaging, - localNotifications: _RecordingLocalNotifications(), - isFlutterLocalNotificationsInitialized: true, - fcmTokenRegistrar: tokenRegistrar, - ); - - await service.requestNotificationToken(); - messaging.emitTokenRefresh('refreshed-token'); - await pumpEventQueue(); - - expect(tokenRegistrar.registeredTokens, [ - 'initial-token', - 'refreshed-token', - ]); - }, - ); - - test( - 'repeated token requests keep one refresh registration callback', - () async { - final messaging = _FakeFirebaseMessaging(AuthorizationStatus.authorized) - ..token = 'initial-token'; - final tokenRegistrar = _FakeFcmTokenRegistrar(); - final service = NotificationService.test( - messaging: messaging, - localNotifications: _RecordingLocalNotifications(), - isFlutterLocalNotificationsInitialized: true, - fcmTokenRegistrar: tokenRegistrar, - ); - - await service.requestNotificationToken(); - await service.requestNotificationToken(); - messaging.emitTokenRefresh('refreshed-token'); - await pumpEventQueue(); - - expect(tokenRegistrar.registeredTokens, [ - 'initial-token', - 'initial-token', - 'refreshed-token', - ]); - }, - ); - - test( - 'setupFlutterNotifications initializes local notifications once', - () async { - final localNotifications = _RecordingLocalNotifications(); - final service = NotificationService.test( - messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), - localNotifications: localNotifications, - ); - - await service.setupFlutterNotifications(); - await service.setupFlutterNotifications(); - - expect(localNotifications.initializeCount, 1); - }, - ); - - test( - 'local notification taps are delegated to the notification tap router', - () async { - final localNotifications = _RecordingLocalNotifications(); - final tapRouter = _FakeNotificationTapRouter(); - final service = NotificationService.test( - messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), - localNotifications: localNotifications, - notificationTapRouter: tapRouter, - ); - - await service.setupFlutterNotifications(); - localNotifications.tapPayload( - '{"type":"preparation_step","scheduleId":"schedule-1"}', - ); - localNotifications.tapPayload('not-json'); - - expect(tapRouter.localPayloads, [ - '{"type":"preparation_step","scheduleId":"schedule-1"}', - 'not-json', - ]); - }, - ); - - test('showLocalNotification displays encoded non-alarm payloads', () async { - final localNotifications = _RecordingLocalNotifications(); - final service = NotificationService.test( - messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), - localNotifications: localNotifications, - isFlutterLocalNotificationsInitialized: true, - ); - - await service.showLocalNotification( - title: 'Reminder', - body: 'Leave soon', - payload: const {'type': 'info', 'scheduleId': 'schedule-1'}, - ); - - expect(localNotifications.shown, hasLength(1)); - expect(localNotifications.shown.single.title, 'Reminder'); - expect(localNotifications.shown.single.body, 'Leave soon'); - expect(localNotifications.shown.single.payload, contains('schedule-1')); - }); - - test('showLocalNotification suppresses schedule alarm payloads', () async { - final localNotifications = _RecordingLocalNotifications(); - final service = NotificationService.test( - messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), - localNotifications: localNotifications, - isFlutterLocalNotificationsInitialized: true, - ); - - await service.showLocalNotification( - title: 'Alarm', - body: 'Start preparing', - payload: const {'type': 'schedule_alarm'}, - ); - - expect(localNotifications.shown, isEmpty); - }); - - test('showLocalNotification ignores setup and display failures', () async { - final setupFailureNotifications = _RecordingLocalNotifications() - ..throwOnInitialize = true; - final setupFailureService = NotificationService.test( - messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), - localNotifications: setupFailureNotifications, - ); - - await setupFailureService.showLocalNotification( - title: 'Reminder', - body: 'Leave soon', - ); - - expect(setupFailureNotifications.shown, isEmpty); - - final displayFailureNotifications = _RecordingLocalNotifications() - ..throwOnShow = true; - final displayFailureService = NotificationService.test( - messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), - localNotifications: displayFailureNotifications, - isFlutterLocalNotificationsInitialized: true, - ); - - await displayFailureService.showLocalNotification( - title: 'Reminder', - body: 'Leave soon', - ); - - expect(displayFailureNotifications.showAttempts, 1); - expect(displayFailureNotifications.shown, isEmpty); - }); - - test( - 'preparation step notifications include schedule and step payload', - () async { - final localNotifications = _RecordingLocalNotifications(); - final service = NotificationService.test( - messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), - localNotifications: localNotifications, - isFlutterLocalNotificationsInitialized: true, - ); - - await service.showPreparationStepNotification( - scheduleName: 'Morning meeting', - preparationName: 'Pack', - scheduleId: 'schedule-1', - stepId: 'step-1', - ); - - expect(localNotifications.shown, hasLength(1)); - expect(localNotifications.shown.single.title, contains('Pack')); - expect(localNotifications.shown.single.payload, contains('schedule-1')); - expect(localNotifications.shown.single.payload, contains('step-1')); - }, - ); - - test('preparation step notifications are skipped in foreground', () async { - final localNotifications = _RecordingLocalNotifications(); - final service = NotificationService.test( - messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), - localNotifications: localNotifications, - isFlutterLocalNotificationsInitialized: true, - ); - - TestWidgetsFlutterBinding.instance.handleAppLifecycleStateChanged( - AppLifecycleState.resumed, - ); - await service.showPreparationStepNotification( - scheduleName: 'Morning meeting', - preparationName: 'Pack', - scheduleId: 'schedule-1', - stepId: 'step-1', - ); - - expect(localNotifications.shown, isEmpty); - }); - - test( - 'remote notifications prefer displayable content and skip alarm pushes', - () async { - final localNotifications = _RecordingLocalNotifications(); - final service = NotificationService.test( - messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), - localNotifications: localNotifications, - isFlutterLocalNotificationsInitialized: true, - ); - - await service.showNotification( - const RemoteMessage( - data: { - 'title': 'Backend title', - 'body': 'Backend body', - 'route': '/calendar', - }, - ), - ); - await service.showNotification( - const RemoteMessage(data: {'type': 'schedule_alarm'}), - ); - await service.showNotification(const RemoteMessage(data: {})); - - expect(localNotifications.shown, hasLength(1)); - expect(localNotifications.shown.single.title, 'Backend title'); - expect(localNotifications.shown.single.body, 'Backend body'); - }, - ); - - test('remote notifications ignore setup and display failures', () async { - final setupFailureNotifications = _RecordingLocalNotifications() - ..throwOnInitialize = true; - final setupFailureService = NotificationService.test( - messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), - localNotifications: setupFailureNotifications, - ); - - await setupFailureService.showNotification( - const RemoteMessage(data: {'title': 'Title', 'body': 'Body'}), - ); - - expect(setupFailureNotifications.shown, isEmpty); - - final displayFailureNotifications = _RecordingLocalNotifications() - ..throwOnShow = true; - final displayFailureService = NotificationService.test( - messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), - localNotifications: displayFailureNotifications, - isFlutterLocalNotificationsInitialized: true, - ); - - await displayFailureService.showNotification( - const RemoteMessage(data: {'title': 'Title', 'body': 'Body'}), - ); - - expect(displayFailureNotifications.showAttempts, 1); - expect(displayFailureNotifications.shown, isEmpty); - }); - - test( - 'schedule notification scheduling requires notification permission', - () async { - final messaging = _FakeFirebaseMessaging(AuthorizationStatus.denied); - final service = NotificationService.test( - messaging: messaging, - localNotifications: _RecordingLocalNotifications(), - isFlutterLocalNotificationsInitialized: true, - ); - - await expectLater( - service.scheduleFallbackAlarm(_record()), - throwsA( - isA().having( - (error) => error.permissionIssue, - 'permissionIssue', - AlarmPermissionIssue.notificationPermissionDenied, - ), - ), - ); - }, - ); - - test( - 'schedule notifications schedule and cancel by stable notification id', - () async { - final messaging = _FakeFirebaseMessaging(AuthorizationStatus.authorized); - final localNotifications = _RecordingLocalNotifications(); - final service = NotificationService.test( - messaging: messaging, - localNotifications: localNotifications, - localeProvider: () => 'en', - isFlutterLocalNotificationsInitialized: true, - ); - final record = _record(fallbackNotificationId: null); - - await service.scheduleFallbackAlarm(record); - await service.cancelFallbackNotification( - stableAlarmId(record.scheduleId), - ); - - expect(localNotifications.scheduled, hasLength(1)); - expect( - localNotifications.scheduled.single.id, - stableAlarmId('schedule-1'), - ); - expect(localNotifications.scheduled.single.title, 'Morning meeting'); - expect( - localNotifications.scheduled.single.body, - contains('time to get ready'), - ); - expect( - localNotifications - .scheduled - .single - .notificationDetails - .android - ?.channelName, - 'Schedule notifications', - ); - expect( - localNotifications - .scheduled - .single - .notificationDetails - .android - ?.category, - AndroidNotificationCategory.reminder, - ); - expect( - localNotifications - .scheduled - .single - .notificationDetails - .iOS - ?.interruptionLevel, - InterruptionLevel.timeSensitive, - ); - expect(localNotifications.cancelledIds, [stableAlarmId('schedule-1')]); - }, - ); -} - -NotificationSettings _settings(AuthorizationStatus status) { - return NotificationSettings( - alert: AppleNotificationSetting.enabled, - announcement: AppleNotificationSetting.disabled, - authorizationStatus: status, - badge: AppleNotificationSetting.enabled, - carPlay: AppleNotificationSetting.disabled, - lockScreen: AppleNotificationSetting.enabled, - notificationCenter: AppleNotificationSetting.enabled, - showPreviews: AppleShowPreviewSetting.always, - timeSensitive: AppleNotificationSetting.disabled, - criticalAlert: AppleNotificationSetting.disabled, - sound: AppleNotificationSetting.enabled, - providesAppNotificationSettings: AppleNotificationSetting.disabled, - ); -} - -ScheduledAlarmRecord _record({int? fallbackNotificationId = 42}) { - return ScheduledAlarmRecord( - scheduleId: 'schedule-1', - alarmTime: DateTime.utc(2026, 5, 15, 8), - preparationStartTime: DateTime.utc(2026, 5, 15, 8, 5), - scheduleFingerprint: 'fingerprint', - provider: AlarmProvider.localNotification, - scheduleTitle: 'Morning meeting', - payload: const { - 'type': 'schedule_notification', - 'scheduleId': 'schedule-1', - 'promptVariant': 'notification', - }, - fallbackNotificationId: fallbackNotificationId, - ); -} - -class _FakeFirebaseMessaging implements FirebaseMessaging { - _FakeFirebaseMessaging(this.authorizationStatus); - - final _tokenRefreshController = StreamController.broadcast(); - AuthorizationStatus authorizationStatus; - AuthorizationStatus requestedAuthorizationStatus = - AuthorizationStatus.authorized; - Completer? requestPermissionBlocker; - int requestPermissionCount = 0; - int getTokenCount = 0; - int getInitialMessageCount = 0; - bool tokenRefreshListened = false; - String? token; - RemoteMessage? initialMessage; - - @override - Future getNotificationSettings() async { - return _settings(authorizationStatus); - } - - @override - Future requestPermission({ - bool alert = true, - bool announcement = false, - bool badge = true, - bool carPlay = false, - bool criticalAlert = false, - bool provisional = false, - bool sound = true, - bool providesAppNotificationSettings = false, - }) async { - requestPermissionCount += 1; - authorizationStatus = requestedAuthorizationStatus; - await requestPermissionBlocker?.future; - return _settings(authorizationStatus); - } - - @override - Future getToken({ - String? serviceWorkerScriptPath, - String? vapidKey, - }) async { - getTokenCount += 1; - return token; - } - - @override - Future getInitialMessage() async { - getInitialMessageCount += 1; - return initialMessage; - } - - @override - Stream get onTokenRefresh { - tokenRefreshListened = true; - return _tokenRefreshController.stream; - } - - void emitTokenRefresh(String token) { - _tokenRefreshController.add(token); - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -class _ShownNotification { - const _ShownNotification(this.title, this.body, this.payload); - - final String? title; - final String? body; - final String? payload; -} - -class _ScheduledNotification { - const _ScheduledNotification( - this.id, - this.title, - this.body, - this.scheduledDate, - this.notificationDetails, - ); - - final int id; - final String? title; - final String? body; - final tz.TZDateTime scheduledDate; - final NotificationDetails notificationDetails; -} - -class _RecordingLocalNotifications implements FlutterLocalNotificationsPlugin { - _RecordingLocalNotifications({this.iosPlugin}); - - final _FakeIOSLocalNotificationsPlugin? iosPlugin; - final shown = <_ShownNotification>[]; - final scheduled = <_ScheduledNotification>[]; - final cancelledIds = []; - int initializeCount = 0; - int showAttempts = 0; - bool throwOnInitialize = false; - bool throwOnShow = false; - DidReceiveNotificationResponseCallback? notificationResponseCallback; - - @override - T? resolvePlatformSpecificImplementation< - T extends FlutterLocalNotificationsPlatform - >() { - if (T == IOSFlutterLocalNotificationsPlugin) { - return iosPlugin as T?; - } - return null; - } - - @override - Future initialize({ - required InitializationSettings settings, - DidReceiveNotificationResponseCallback? onDidReceiveNotificationResponse, - DidReceiveBackgroundNotificationResponseCallback? - onDidReceiveBackgroundNotificationResponse, - }) async { - if (throwOnInitialize) { - throw Exception('initialize failed'); - } - notificationResponseCallback = onDidReceiveNotificationResponse; - initializeCount += 1; - return true; - } - - void tapPayload(String? payload) { - notificationResponseCallback?.call( - NotificationResponse( - notificationResponseType: NotificationResponseType.selectedNotification, - payload: payload, - ), - ); - } - - @override - Future show({ - required int id, - String? title, - String? body, - NotificationDetails? notificationDetails, - String? payload, - }) async { - showAttempts += 1; - if (throwOnShow) { - throw Exception('show failed'); - } - shown.add(_ShownNotification(title, body, payload)); - } - - @override - Future zonedSchedule({ - required int id, - required tz.TZDateTime scheduledDate, - required NotificationDetails notificationDetails, - required AndroidScheduleMode androidScheduleMode, - String? title, - String? body, - String? payload, - DateTimeComponents? matchDateTimeComponents, - }) async { - scheduled.add( - _ScheduledNotification( - id, - title, - body, - scheduledDate, - notificationDetails, - ), - ); - } - - @override - Future cancel({required int id, String? tag}) async { - cancelledIds.add(id); - } - - @override - Future> pendingNotificationRequests() async { - return [ - for (final notification in scheduled) - PendingNotificationRequest( - notification.id, - notification.title, - notification.body, - null, - ), - ]; - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -class _FakeIOSLocalNotificationsPlugin - extends IOSFlutterLocalNotificationsPlugin { - _FakeIOSLocalNotificationsPlugin({ - this.permissionsEnabled = true, - this.requestPermissionsResult, - }); - - bool permissionsEnabled; - bool? requestPermissionsResult; - int checkPermissionsCount = 0; - int requestPermissionsCount = 0; - bool? lastRequestedAlert; - bool? lastRequestedBadge; - bool? lastRequestedSound; - - @override - Future checkPermissions() async { - checkPermissionsCount += 1; - return NotificationsEnabledOptions( - isEnabled: permissionsEnabled, - isSoundEnabled: permissionsEnabled, - isAlertEnabled: permissionsEnabled, - isBadgeEnabled: permissionsEnabled, - isProvisionalEnabled: false, - isCriticalEnabled: false, - isProvidesAppNotificationSettingsEnabled: false, - ); - } - - @override - Future requestPermissions({ - bool sound = false, - bool alert = false, - bool badge = false, - bool provisional = false, - bool critical = false, - bool carPlay = false, - bool providesAppNotificationSettings = false, - }) async { - requestPermissionsCount += 1; - lastRequestedAlert = alert; - lastRequestedBadge = badge; - lastRequestedSound = sound; - final result = requestPermissionsResult ?? permissionsEnabled; - permissionsEnabled = result; - return result; - } -} - -class _FakeFcmTokenRegistrar implements FcmTokenRegistrar { - final registeredTokens = []; - - @override - Future registerToken(String firebaseToken) async { - registeredTokens.add(firebaseToken); - } -} - -class _FakeNotificationTapRouter implements NotificationTapRouter { - final localPayloads = []; - final remoteMessageData = >[]; - - @override - void routeLocalNotificationTap(String? payload) { - localPayloads.add(payload); - } - - @override - void routeRemoteNotificationData(Map data) { - remoteMessageData.add(data); - } -} diff --git a/test/core/services/notification_tap_router_test.dart b/test/core/services/notification_tap_router_test.dart index 5d051cad..0471722d 100644 --- a/test/core/services/notification_tap_router_test.dart +++ b/test/core/services/notification_tap_router_test.dart @@ -14,22 +14,6 @@ void main() { expect(navigationService.pushedRoutes, ['/alarmScreen']); }); - - test('routes remote schedule alarm data with launch payload', () { - final navigationService = _FakeNavigationService(); - final router = NavigationNotificationTapRouter(navigationService); - - router.routeRemoteNotificationData({ - 'type': 'schedule_alarm', - 'scheduleId': 'schedule-1', - }); - - expect(navigationService.pushedRoutes, ['/scheduleStart']); - expect(navigationService.pushedExtras.single, { - 'type': 'schedule_alarm', - 'scheduleId': 'schedule-1', - }); - }); } class _FakeNavigationService implements NavigationService { diff --git a/test/core/services/product_analytics_service_test.dart b/test/core/services/product_analytics_service_test.dart deleted file mode 100644 index d0f0dbde..00000000 --- a/test/core/services/product_analytics_service_test.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/core/services/app_metadata_service.dart'; -import 'package:on_time_front/core/services/product_analytics_service.dart'; -import 'package:on_time_front/domain/entities/analytics_preference.dart'; -import 'package:on_time_front/domain/entities/product_usage_event.dart'; -import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; - -void main() { - test('product usage events include the runtime app version', () async { - final client = _FakeAnalyticsProviderClient(); - final service = _buildService(client); - - await service.applyPreference( - const AnalyticsPreference(enabled: true, isConfirmed: true), - ); - await service.track(_scheduleCreatedEvent()); - - expect(client.loggedEvents.single.parameters['app_version'], '9.8.7'); - }); - - test( - 'unconfirmed analytics preference does not log product usage events', - () async { - final client = _FakeAnalyticsProviderClient(); - final service = _buildService(client); - - await service.applyPreference( - const AnalyticsPreference(enabled: true, isConfirmed: false), - ); - await service.track(_scheduleCreatedEvent()); - - expect(client.collectionEnabledValues, [false]); - expect(client.loggedEvents, isEmpty); - }, - ); - - test( - 'disabled analytics preference does not log product usage events', - () async { - final client = _FakeAnalyticsProviderClient(); - final service = _buildService(client); - - await service.applyPreference( - const AnalyticsPreference(enabled: false, isConfirmed: true), - ); - await service.track(_scheduleCreatedEvent()); - - expect(client.collectionEnabledValues, [false]); - expect(client.loggedEvents, isEmpty); - }, - ); -} - -ProductAnalyticsService _buildService(_FakeAnalyticsProviderClient client) { - return ProductAnalyticsService( - client: client, - appMetadataProvider: const _FakeAppMetadataProvider( - AppMetadata(version: '9.8.7', buildNumber: '654'), - ), - collectionAllowedInBuild: true, - ); -} - -ProductUsageEvent _scheduleCreatedEvent() { - return ProductUsageEvent.scheduleCreated( - preparationMode: SchedulePreparationMode.defaultPreparation, - preparationStepCount: 1, - minutesUntilSchedule: 60, - ); -} - -class _FakeAnalyticsProviderClient implements AnalyticsProviderClient { - final collectionEnabledValues = []; - final loggedEvents = <({String name, Map parameters})>[]; - - @override - Future setAnalyticsCollectionEnabled(bool enabled) async { - collectionEnabledValues.add(enabled); - } - - @override - Future logEvent({ - required String name, - required Map parameters, - }) async { - loggedEvents.add((name: name, parameters: parameters)); - } - - @override - Future setUserId(String? userId) async {} -} - -class _FakeAppMetadataProvider implements AppMetadataProvider { - const _FakeAppMetadataProvider(this.metadata); - - final AppMetadata metadata; - - @override - Future getMetadata() async => metadata; -} diff --git a/test/core/time/civil_time_resolver_test.dart b/test/core/time/civil_time_resolver_test.dart new file mode 100644 index 00000000..29b5c252 --- /dev/null +++ b/test/core/time/civil_time_resolver_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/time/civil_time_resolver.dart'; + +void main() { + test('normal civil time resolves to one absolute occurrence', () { + final occurrences = CivilTimeResolver.resolve( + DateTime(2026, 2, 1, 12, 30), + 'America/New_York', + ); + + expect(occurrences, hasLength(1)); + expect(occurrences.single.offsetSeconds, -5 * 60 * 60); + expect(occurrences.single.instantUtc, DateTime.utc(2026, 2, 1, 17, 30)); + }); + + test('spring-forward gap has no valid occurrence', () { + expect( + CivilTimeResolver.resolve( + DateTime(2026, 3, 8, 2, 30), + 'America/New_York', + ), + isEmpty, + ); + }); + + test('fall-back overlap exposes first and second occurrence', () { + final occurrences = CivilTimeResolver.resolve( + DateTime(2026, 11, 1, 1, 30), + 'America/New_York', + ); + + expect(occurrences.map((value) => value.offsetSeconds), [ + -4 * 60 * 60, + -5 * 60 * 60, + ]); + expect(occurrences.map((value) => value.instantUtc), [ + DateTime.utc(2026, 11, 1, 5, 30), + DateTime.utc(2026, 11, 1, 6, 30), + ]); + }); +} diff --git a/test/core/validation/backend_constraints_test.dart b/test/core/validation/backend_constraints_test.dart deleted file mode 100644 index 0ffe5fd5..00000000 --- a/test/core/validation/backend_constraints_test.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/core/validation/backend_constraints.dart'; - -void main() { - group('PasswordPolicy', () { - test('accepts 8-64 chars with letter number and special character', () { - expect(PasswordPolicy.validate('Password1!'), isNull); - expect(PasswordPolicy.isValid('Password1!'), isTrue); - expect(PasswordPolicy.validate('A1!aaaaa'), isNull); - expect(PasswordPolicy.validate('${'A' * 62}1!'), isNull); - }); - - test('rejects passwords outside backend policy', () { - expect(PasswordPolicy.validate('A1!aaaa'), PasswordPolicyError.tooShort); - expect( - PasswordPolicy.validate('${'A' * 63}1!'), - PasswordPolicyError.tooLong, - ); - expect( - PasswordPolicy.validate('12345678!'), - PasswordPolicyError.missingLetter, - ); - expect( - PasswordPolicy.validate('Password!'), - PasswordPolicyError.missingNumber, - ); - expect( - PasswordPolicy.validate('Password1'), - PasswordPolicyError.missingSpecialCharacter, - ); - }); - }); - - test('device ID pattern matches backend contract', () { - expect( - BackendConstraints.deviceIdPattern.hasMatch( - '550e8400-e29b-41d4-a716-446655440000', - ), - isTrue, - ); - expect(BackendConstraints.deviceIdPattern.hasMatch('device-1'), isFalse); - expect( - BackendConstraints.deviceIdPattern.hasMatch('invalid device id'), - isFalse, - ); - }); - - test('trimToMaxLength trims whitespace before enforcing backend limit', () { - expect(BackendConstraints.trimToMaxLength(' hello ', 10), 'hello'); - expect(BackendConstraints.trimToMaxLength(' hello world ', 5), 'hello'); - }); -} diff --git a/test/data/daos/preparation_schedule_dao_test.dart b/test/data/daos/preparation_schedule_dao_test.dart index 374c251e..0e34abbc 100644 --- a/test/data/daos/preparation_schedule_dao_test.dart +++ b/test/data/daos/preparation_schedule_dao_test.dart @@ -49,11 +49,8 @@ void main() { .insert( UsersCompanion( id: drift.Value(userId), - email: drift.Value('testuser@example.com'), - name: drift.Value('Test User'), - spareTime: drift.Value(Duration(minutes: 30).inSeconds), + spareTime: const drift.Value(30), note: drift.Value('Test Note'), - score: drift.Value(100), ), ); diff --git a/test/data/daos/preparation_user_dao_test.dart b/test/data/daos/preparation_user_dao_test.dart index d95b613b..cfc6d832 100644 --- a/test/data/daos/preparation_user_dao_test.dart +++ b/test/data/daos/preparation_user_dao_test.dart @@ -45,11 +45,8 @@ void main() { .insert( UsersCompanion( id: drift.Value(userId), - email: drift.Value('testuser@example.com'), - name: drift.Value('Test User'), - spareTime: drift.Value(Duration(minutes: 30).inSeconds), + spareTime: const drift.Value(30), note: drift.Value('Test Note'), - score: drift.Value(100), ), ); diff --git a/test/data/daos/schedule_dao_test.dart b/test/data/daos/schedule_dao_test.dart index 3e0c1179..b082fd90 100644 --- a/test/data/daos/schedule_dao_test.dart +++ b/test/data/daos/schedule_dao_test.dart @@ -25,6 +25,7 @@ void main() async { id: scheduleEntityId, placeId: placeModel.id, scheduleName: 'Test Schedule', + timeZoneId: 'Asia/Seoul', scheduleTime: scheduleTime, moveTime: Duration(minutes: 10), isChanged: false, @@ -32,6 +33,10 @@ void main() async { scheduleSpareTime: Duration(minutes: 5), scheduleNote: 'Test Note', latenessTime: 0, + doneStatus: 'notEnded', + preparationTemplateDeleted: false, + preparationFrozen: false, + scoreContributionRecorded: false, ); final scheduleWithPlaceModel = ScheduleWithPlace( diff --git a/test/data/daos/user_dao_test.dart b/test/data/daos/user_dao_test.dart index e56861ef..470d8c34 100644 --- a/test/data/daos/user_dao_test.dart +++ b/test/data/daos/user_dao_test.dart @@ -32,11 +32,10 @@ void main() { test('getAllUsers returns all persisted users as domain entities', () async { const secondUser = UserEntity( id: 'user-2', - email: 'second@example.com', - name: 'Second User', spareTime: Duration(minutes: 20), note: 'second note', - score: 3.5, + eligibleOutcomeCount: 2, + onTimeOutcomeCount: 1, ); await dao.createUser(_user); @@ -51,9 +50,8 @@ void main() { const _user = UserEntity( id: 'user-1', - email: 'user@example.com', - name: 'Test User', spareTime: Duration(minutes: 15), note: 'note', - score: 4.5, + eligibleOutcomeCount: 4, + onTimeOutcomeCount: 3, ); diff --git a/test/data/data_sources/alarm_remote_data_source_test.dart b/test/data/data_sources/alarm_remote_data_source_test.dart deleted file mode 100644 index 53293260..00000000 --- a/test/data/data_sources/alarm_remote_data_source_test.dart +++ /dev/null @@ -1,445 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:on_time_front/core/constants/endpoint.dart'; -import 'package:on_time_front/data/data_sources/alarm_remote_data_source.dart'; -import 'package:on_time_front/domain/entities/alarm_entities.dart'; - -import '../../helpers/mock.mocks.dart'; - -void main() { - late Dio dio; - late AlarmRemoteDataSourceImpl remoteDataSource; - - setUp(() { - dio = MockAppDio(); - remoteDataSource = AlarmRemoteDataSourceImpl(dio); - }); - - test('getAlarmSettings maps backend settings response', () async { - when(dio.get(Endpoint.alarmSettings)).thenAnswer( - (_) async => Response( - statusCode: 200, - data: { - 'data': { - 'alarmsEnabled': true, - 'defaultAlarmOffsetMinutes': 8, - 'updatedAt': '2026-05-05T09:00:00.000', - }, - }, - requestOptions: RequestOptions(path: Endpoint.alarmSettings), - ), - ); - - final settings = await remoteDataSource.getAlarmSettings(); - - expect(settings.alarmsEnabled, isTrue); - expect(settings.defaultAlarmOffsetMinutes, 8); - expect(settings.alarmOffset, const Duration(minutes: 8)); - }); - - test( - 'updateAlarmSettings patches the enabled flag and returns settings', - () async { - when( - dio.patch(Endpoint.alarmSettings, data: anyNamed('data')), - ).thenAnswer( - (_) async => Response( - statusCode: 200, - data: { - 'data': {'alarmsEnabled': false, 'defaultAlarmOffsetMinutes': 5}, - }, - requestOptions: RequestOptions(path: Endpoint.alarmSettings), - ), - ); - - final settings = await remoteDataSource.updateAlarmSettings( - alarmsEnabled: false, - ); - - final data = - verify( - dio.patch( - Endpoint.alarmSettings, - data: captureAnyNamed('data'), - ), - ).captured.single - as Map; - expect(data, {'alarmsEnabled': false}); - expect(settings.alarmsEnabled, isFalse); - }, - ); - - test('registerCurrentDevice posts device capability contract', () async { - when( - dio.put(Endpoint.currentDevice, data: anyNamed('data')), - ).thenAnswer( - (_) async => Response( - statusCode: 200, - requestOptions: RequestOptions(path: Endpoint.currentDevice), - ), - ); - - await remoteDataSource.registerCurrentDevice( - const AlarmDeviceInfo( - deviceId: 'device-1', - platform: 'android', - appVersion: '1.0.0', - osVersion: 'android-35', - supportsNativeAlarm: true, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, - fallbackProvider: AlarmProvider.localNotification, - ), - ); - - final data = - verify( - dio.put( - Endpoint.currentDevice, - data: captureAnyNamed('data'), - ), - ).captured.single - as Map; - expect(data['deviceId'], 'device-1'); - expect(data['nativeAlarmProvider'], 'androidAlarmManager'); - expect(data['fallbackProvider'], 'localNotification'); - }); - - test('unregisterCurrentDevice deletes the current device by id', () async { - when( - dio.delete(Endpoint.currentDevice, data: anyNamed('data')), - ).thenAnswer( - (_) async => Response( - statusCode: 200, - requestOptions: RequestOptions(path: Endpoint.currentDevice), - ), - ); - - await remoteDataSource.unregisterCurrentDevice('device-1'); - - final data = - verify( - dio.delete( - Endpoint.currentDevice, - data: captureAnyNamed('data'), - ), - ).captured.single - as Map; - expect(data, {'deviceId': 'device-1'}); - }); - - test('getAlarmWindow queries ISO range and maps schedules', () async { - final start = DateTime.utc(2026, 5, 5, 9); - final end = start.add(const Duration(days: 7)); - when( - dio.get( - Endpoint.alarmWindow, - queryParameters: anyNamed('queryParameters'), - ), - ).thenAnswer( - (_) async => Response( - statusCode: 200, - data: { - 'data': [ - { - 'scheduleId': 'schedule-1', - 'scheduleName': 'Morning meeting', - 'place': {'placeId': 'place-1', 'placeName': 'Office'}, - 'scheduleTime': '2026-05-06T10:00:00.000', - 'moveTime': 20, - 'scheduleSpareTime': 10, - 'doneStatus': 'NOT_ENDED', - 'preparations': [ - { - 'preparationId': 'prep-1', - 'preparationName': 'Pack', - 'preparationTime': 5, - 'nextPreparationId': null, - }, - ], - }, - ], - }, - requestOptions: RequestOptions(path: Endpoint.alarmWindow), - ), - ); - - final schedules = await remoteDataSource.getAlarmWindow(start, end); - - final query = - verify( - dio.get( - Endpoint.alarmWindow, - queryParameters: captureAnyNamed('queryParameters'), - ), - ).captured.single - as Map; - expect(query, { - 'startDate': start.toIso8601String(), - 'endDate': end.toIso8601String(), - }); - expect(schedules.single.id, 'schedule-1'); - expect( - schedules.single.preparation.preparationStepList.single.id, - 'prep-1', - ); - }); - - test( - 'non-200 alarm endpoints throw instead of returning partial data', - () async { - when(dio.get(Endpoint.alarmSettings)).thenAnswer( - (_) async => Response( - statusCode: 500, - requestOptions: RequestOptions(path: Endpoint.alarmSettings), - ), - ); - - await expectLater(remoteDataSource.getAlarmSettings(), throwsException); - }, - ); - - test('non-200 alarm mutations and window queries surface failures', () async { - when( - dio.patch(Endpoint.alarmSettings, data: anyNamed('data')), - ).thenAnswer( - (_) async => Response( - statusCode: 500, - requestOptions: RequestOptions(path: Endpoint.alarmSettings), - ), - ); - await expectLater( - remoteDataSource.updateAlarmSettings(alarmsEnabled: true), - throwsException, - ); - - when( - dio.put(Endpoint.currentDevice, data: anyNamed('data')), - ).thenAnswer( - (_) async => Response( - statusCode: 500, - requestOptions: RequestOptions(path: Endpoint.currentDevice), - ), - ); - await expectLater( - remoteDataSource.registerCurrentDevice( - const AlarmDeviceInfo( - deviceId: 'device-1', - platform: 'android', - appVersion: '1.0.0', - osVersion: 'android-35', - supportsNativeAlarm: true, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, - fallbackProvider: AlarmProvider.localNotification, - ), - ), - throwsException, - ); - - when( - dio.delete(Endpoint.currentDevice, data: anyNamed('data')), - ).thenAnswer( - (_) async => Response( - statusCode: 500, - requestOptions: RequestOptions(path: Endpoint.currentDevice), - ), - ); - await expectLater( - remoteDataSource.unregisterCurrentDevice('device-1'), - throwsException, - ); - - when( - dio.get( - Endpoint.alarmWindow, - queryParameters: anyNamed('queryParameters'), - ), - ).thenAnswer( - (_) async => Response( - statusCode: 500, - requestOptions: RequestOptions(path: Endpoint.alarmWindow), - ), - ); - final start = DateTime.utc(2026, 5, 5, 9); - await expectLater( - remoteDataSource.getAlarmWindow( - start, - start.add(const Duration(days: 7)), - ), - throwsException, - ); - }); - - group('postAlarmStatus', () { - test( - 'posts lower-camel backend contract without retry on success', - () async { - when( - dio.post( - Endpoint.alarmStatus, - data: anyNamed('data'), - options: anyNamed('options'), - ), - ).thenAnswer( - (_) async => Response( - statusCode: 200, - requestOptions: RequestOptions(path: Endpoint.alarmStatus), - ), - ); - - await remoteDataSource.postAlarmStatus(_statusReport()); - - final verification = verify( - dio.post( - Endpoint.alarmStatus, - data: captureAnyNamed('data'), - options: captureAnyNamed('options'), - ), - )..called(1); - final data = verification.captured[0] as Map; - final options = verification.captured[1] as Options; - - expect(options.validateStatus!(400), isTrue); - expect(data.containsKey('permissionIssue'), isFalse); - expect(data['reconciledAt'], '2026-05-05T09:00:00.000Z'); - expect(data['status'], 'armed'); - expect(data['nativeAlarmProvider'], 'iosAlarmKit'); - expect(data['fallbackProvider'], 'localNotification'); - }, - ); - - test( - 'falls back to backend enum format after generic bad request', - () async { - var callCount = 0; - when( - dio.post( - Endpoint.alarmStatus, - data: anyNamed('data'), - options: anyNamed('options'), - ), - ).thenAnswer((_) async { - callCount += 1; - return Response( - statusCode: callCount == 1 ? 400 : 200, - data: callCount == 1 - ? { - 'status': 'error', - 'code': 400, - 'message': 'bad request', - 'data': null, - } - : null, - requestOptions: RequestOptions(path: Endpoint.alarmStatus), - ); - }); - - await remoteDataSource.postAlarmStatus(_statusReport()); - - final verification = verify( - dio.post( - Endpoint.alarmStatus, - data: captureAnyNamed('data'), - options: captureAnyNamed('options'), - ), - )..called(2); - final firstData = verification.captured[0] as Map; - final firstOptions = verification.captured[1] as Options; - final secondData = verification.captured[2] as Map; - final secondOptions = verification.captured[3] as Options; - - expect(firstOptions.validateStatus!(400), isTrue); - expect(secondOptions.validateStatus!(400), isTrue); - expect(firstData.containsKey('permissionIssue'), isFalse); - expect(firstData['status'], 'armed'); - expect(firstData['nativeAlarmProvider'], 'iosAlarmKit'); - expect(secondData.containsKey('permissionIssue'), isFalse); - expect(secondData['status'], 'ARMED'); - expect(secondData['nativeAlarmProvider'], 'IOS_ALARM_KIT'); - expect(secondData['fallbackProvider'], 'LOCAL_NOTIFICATION'); - }, - ); - - test('does not retry semantic validation errors', () async { - when( - dio.post( - Endpoint.alarmStatus, - data: anyNamed('data'), - options: anyNamed('options'), - ), - ).thenAnswer( - (_) async => Response( - statusCode: 400, - data: { - 'status': 'error', - 'code': 1002, - 'message': 'invalid input', - 'data': null, - }, - requestOptions: RequestOptions(path: Endpoint.alarmStatus), - ), - ); - - expect( - () => remoteDataSource.postAlarmStatus(_statusReport()), - throwsException, - ); - - verify( - dio.post( - Endpoint.alarmStatus, - data: anyNamed('data'), - options: anyNamed('options'), - ), - ).called(1); - }); - - test('throws device session exception for inactive session', () async { - when( - dio.post( - Endpoint.alarmStatus, - data: anyNamed('data'), - options: anyNamed('options'), - ), - ).thenAnswer( - (_) async => Response( - statusCode: 409, - data: {'code': 'DEVICE_SESSION_NOT_ACTIVE'}, - requestOptions: RequestOptions(path: Endpoint.alarmStatus), - ), - ); - - expect( - () => remoteDataSource.postAlarmStatus(_statusReport()), - throwsA(isA()), - ); - - verify( - dio.post( - Endpoint.alarmStatus, - data: anyNamed('data'), - options: anyNamed('options'), - ), - ).called(1); - }); - }); -} - -AlarmStatusReport _statusReport() { - final now = DateTime.utc(2026, 5, 5, 9); - return AlarmStatusReport( - deviceId: 'device-1', - reconciledAt: now, - scheduleWindowStart: now, - scheduleWindowEnd: now.add(const Duration(days: 8)), - alarmCoverageStart: now, - alarmCoverageEnd: now.add(const Duration(days: 7)), - status: AlarmReconciliationStatus.armed, - nativeAlarmProvider: AlarmProvider.iosAlarmKit, - fallbackProvider: AlarmProvider.localNotification, - armedScheduleCount: 1, - armedScheduleIds: const ['schedule-1'], - skippedScheduleCount: 0, - failures: const [], - ); -} diff --git a/test/data/data_sources/analytics_preference_remote_data_source_test.dart b/test/data/data_sources/analytics_preference_remote_data_source_test.dart deleted file mode 100644 index d31b50b3..00000000 --- a/test/data/data_sources/analytics_preference_remote_data_source_test.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:on_time_front/core/constants/endpoint.dart'; -import 'package:on_time_front/data/data_sources/analytics_preference_remote_data_source.dart'; - -import '../../helpers/mock.mocks.dart'; - -void main() { - late Dio dio; - late AnalyticsPreferenceRemoteDataSourceImpl dataSource; - - setUp(() { - dio = MockAppDio(); - dataSource = AnalyticsPreferenceRemoteDataSourceImpl(dio); - }); - - test('loads analytics preference from the account endpoint', () async { - when(dio.get(Endpoint.analyticsPreference)).thenAnswer( - (_) async => Response( - statusCode: 200, - data: { - 'data': { - 'enabled': true, - 'updatedAt': '2026-05-26T12:00:00Z', - }, - }, - requestOptions: RequestOptions(path: Endpoint.analyticsPreference), - ), - ); - - final preference = await dataSource.getAnalyticsPreference(); - - expect(preference.enabled, isTrue); - expect(preference.updatedAt, DateTime.parse('2026-05-26T12:00:00Z')); - }); - - test('updates analytics preference with the enabled flag only', () async { - when( - dio.put( - Endpoint.analyticsPreference, - data: anyNamed('data'), - ), - ).thenAnswer( - (_) async => Response( - statusCode: 200, - data: { - 'data': { - 'enabled': false, - 'updatedAt': '2026-05-26T12:00:05Z', - }, - }, - requestOptions: RequestOptions(path: Endpoint.analyticsPreference), - ), - ); - - final preference = await dataSource.updateAnalyticsPreference( - enabled: false, - ); - - final data = - verify( - dio.put( - Endpoint.analyticsPreference, - data: captureAnyNamed('data'), - ), - ).captured.single - as Map; - expect(data, {'enabled': false}); - expect(preference.enabled, isFalse); - }); -} diff --git a/test/data/data_sources/authentication_remote_data_source_test.dart b/test/data/data_sources/authentication_remote_data_source_test.dart deleted file mode 100644 index bec64cc1..00000000 --- a/test/data/data_sources/authentication_remote_data_source_test.dart +++ /dev/null @@ -1,387 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:on_time_front/core/constants/endpoint.dart'; -import 'package:on_time_front/data/data_sources/authentication_remote_data_source.dart'; -import 'package:on_time_front/data/models/sign_in_with_apple_request_model.dart'; -import 'package:on_time_front/data/models/sign_in_with_google_request_model.dart'; -import 'package:on_time_front/domain/entities/user_entity.dart'; - -import '../../helpers/mock.mocks.dart'; - -void main() { - late Dio dio; - late AuthenticationRemoteDataSourceImpl dataSource; - - setUp(() { - dio = MockAppDio(); - dataSource = AuthenticationRemoteDataSourceImpl(dio); - }); - - group('deleteUser', () { - test('sends optional feedback in the DELETE request body', () async { - when(dio.delete(Endpoint.deleteUser, data: anyNamed('data'))).thenAnswer( - (_) async => Response( - statusCode: 200, - requestOptions: RequestOptions(path: Endpoint.deleteUser), - ), - ); - - await dataSource.deleteUser(feedbackMessage: ' Not useful anymore. '); - - final capturedData = - verify( - dio.delete(Endpoint.deleteUser, data: captureAnyNamed('data')), - ).captured.single - as Map; - expect(capturedData['feedbackId'], isA()); - expect(capturedData['message'], 'Not useful anymore.'); - }); - - test('sends an empty body when feedback is blank', () async { - when( - dio.delete(Endpoint.deleteGoogleMe, data: anyNamed('data')), - ).thenAnswer( - (_) async => Response( - statusCode: 200, - requestOptions: RequestOptions(path: Endpoint.deleteGoogleMe), - ), - ); - - await dataSource.deleteGoogleMe(feedbackMessage: ' '); - - verify( - dio.delete(Endpoint.deleteGoogleMe, data: {}), - ).called(1); - }); - }); - - group('auth contract', () { - test( - 'signIn posts credentials and returns user with response tokens', - () async { - when( - dio.post(Endpoint.signIn, data: anyNamed('data')), - ).thenAnswer((_) async => _authResponse(Endpoint.signIn)); - - final (user, token) = await dataSource.signIn( - 'user@example.com', - 'Password1!', - ); - - final capturedData = - verify( - dio.post(Endpoint.signIn, data: captureAnyNamed('data')), - ).captured.single - as Map; - expect(capturedData, { - 'email': 'user@example.com', - 'password': 'Password1!', - }); - expect(user, _user(isOnboardingCompleted: true)); - expect(token.accessToken, 'access-token'); - expect(token.refreshToken, 'refresh-token'); - }, - ); - - test( - 'signUp posts registration data and maps guest onboarding status', - () async { - when(dio.post(Endpoint.signUp, data: anyNamed('data'))).thenAnswer( - (_) async => _authResponse(Endpoint.signUp, role: 'GUEST'), - ); - - final (user, _) = await dataSource.signUp( - 'new@example.com', - 'Password1!', - 'New User', - ); - - final capturedData = - verify( - dio.post(Endpoint.signUp, data: captureAnyNamed('data')), - ).captured.single - as Map; - expect(capturedData, { - 'email': 'new@example.com', - 'password': 'Password1!', - 'name': 'New User', - }); - expect(user, _user(isOnboardingCompleted: false)); - }, - ); - - test('signInWithGoogle posts provider token payload', () async { - when( - dio.post(Endpoint.signInWithGoogle, data: anyNamed('data')), - ).thenAnswer((_) async => _authResponse(Endpoint.signInWithGoogle)); - - await dataSource.signInWithGoogle( - SignInWithGoogleRequestModel( - idToken: 'google-id-token', - refreshToken: 'google-refresh-token', - ), - ); - - final capturedData = - verify( - dio.post( - Endpoint.signInWithGoogle, - data: captureAnyNamed('data'), - ), - ).captured.single - as Map; - expect(capturedData, { - 'idToken': 'google-id-token', - 'refreshToken': 'google-refresh-token', - }); - }); - - test('signInWithApple omits null email from provider payload', () async { - when( - dio.post(Endpoint.signInWithApple, data: anyNamed('data')), - ).thenAnswer((_) async => _authResponse(Endpoint.signInWithApple)); - - await dataSource.signInWithApple( - SignInWithAppleRequestModel( - idToken: 'apple-id-token', - authCode: 'auth-code', - fullName: 'Apple User', - ), - ); - - final capturedData = - verify( - dio.post( - Endpoint.signInWithApple, - data: captureAnyNamed('data'), - ), - ).captured.single - as Map; - expect(capturedData, { - 'idToken': 'apple-id-token', - 'authCode': 'auth-code', - 'fullName': 'Apple User', - }); - }); - - test('getUser maps backend profile defaults', () async { - when(dio.get(Endpoint.getUser)).thenAnswer( - (_) async => Response( - statusCode: 200, - data: { - 'data': { - 'userId': 2, - 'email': 'profile@example.com', - 'name': 'Profile', - 'spareTime': null, - 'note': null, - 'punctualityScore': null, - 'role': 'GUEST', - }, - }, - requestOptions: RequestOptions(path: Endpoint.getUser), - ), - ); - - final user = await dataSource.getUser(); - - expect( - user, - const UserEntity( - id: '2', - email: 'profile@example.com', - name: 'Profile', - spareTime: Duration.zero, - note: '', - score: -1, - isOnboardingCompleted: false, - ), - ); - }); - - test('getUserSocialType reads social type from profile payload', () async { - when(dio.get(Endpoint.getUser)).thenAnswer( - (_) async => Response( - statusCode: 200, - data: { - 'data': {'socialType': 'GOOGLE'}, - }, - requestOptions: RequestOptions(path: Endpoint.getUser), - ), - ); - - expect(await dataSource.getUserSocialType(), 'GOOGLE'); - }); - - test('postFeedback trims backend long-text payload', () async { - when(dio.post(Endpoint.feedback, data: anyNamed('data'))).thenAnswer( - (_) async => Response( - statusCode: 200, - requestOptions: RequestOptions(path: Endpoint.feedback), - ), - ); - - await dataSource.postFeedback(' useful feedback '); - - final capturedData = - verify( - dio.post(Endpoint.feedback, data: captureAnyNamed('data')), - ).captured.single - as Map; - expect(capturedData['feedbackId'], isA()); - expect(capturedData['message'], 'useful feedback'); - }); - - test( - 'non-200 signIn response throws instead of returning partial data', - () async { - when(dio.post(Endpoint.signIn, data: anyNamed('data'))).thenAnswer( - (_) async => Response( - statusCode: 500, - requestOptions: RequestOptions(path: Endpoint.signIn), - ), - ); - - await expectLater( - dataSource.signIn('user@example.com', 'Password1!'), - throwsException, - ); - }, - ); - - test( - 'non-200 auth and profile responses surface contract failures', - () async { - when(dio.post(Endpoint.signUp, data: anyNamed('data'))).thenAnswer( - (_) async => Response( - statusCode: 409, - requestOptions: RequestOptions(path: Endpoint.signUp), - ), - ); - await expectLater( - dataSource.signUp('new@example.com', 'Password1!', 'New User'), - throwsException, - ); - - when( - dio.post(Endpoint.signInWithGoogle, data: anyNamed('data')), - ).thenAnswer( - (_) async => Response( - statusCode: 400, - requestOptions: RequestOptions(path: Endpoint.signInWithGoogle), - ), - ); - await expectLater( - dataSource.signInWithGoogle( - SignInWithGoogleRequestModel( - idToken: 'google-id-token', - refreshToken: 'google-refresh-token', - ), - ), - throwsException, - ); - - when( - dio.post(Endpoint.signInWithApple, data: anyNamed('data')), - ).thenAnswer( - (_) async => Response( - statusCode: 400, - requestOptions: RequestOptions(path: Endpoint.signInWithApple), - ), - ); - await expectLater( - dataSource.signInWithApple( - SignInWithAppleRequestModel( - idToken: 'apple-id-token', - authCode: 'auth-code', - fullName: 'Apple User', - ), - ), - throwsException, - ); - - when(dio.get(Endpoint.getUser)).thenAnswer( - (_) async => Response( - statusCode: 404, - requestOptions: RequestOptions(path: Endpoint.getUser), - ), - ); - await expectLater(dataSource.getUser(), throwsException); - await expectLater(dataSource.getUserSocialType(), throwsException); - }, - ); - - test('non-200 delete and feedback responses surface failures', () async { - when( - dio.delete(Endpoint.deleteGoogleMe, data: anyNamed('data')), - ).thenAnswer( - (_) async => Response( - statusCode: 500, - requestOptions: RequestOptions(path: Endpoint.deleteGoogleMe), - ), - ); - await expectLater(dataSource.deleteGoogleMe(), throwsException); - - when( - dio.delete(Endpoint.deleteAppleMe, data: anyNamed('data')), - ).thenAnswer( - (_) async => Response( - statusCode: 500, - requestOptions: RequestOptions(path: Endpoint.deleteAppleMe), - ), - ); - await expectLater(dataSource.deleteAppleMe(), throwsException); - - when(dio.delete(Endpoint.deleteUser, data: anyNamed('data'))).thenAnswer( - (_) async => Response( - statusCode: 500, - requestOptions: RequestOptions(path: Endpoint.deleteUser), - ), - ); - await expectLater(dataSource.deleteUser(), throwsException); - - when(dio.post(Endpoint.feedback, data: anyNamed('data'))).thenAnswer( - (_) async => Response( - statusCode: 500, - requestOptions: RequestOptions(path: Endpoint.feedback), - ), - ); - await expectLater(dataSource.postFeedback('not useful'), throwsException); - }); - }); -} - -Response _authResponse(String path, {String role = 'USER'}) { - return Response( - statusCode: 200, - data: { - 'data': { - 'userId': 1, - 'email': 'user@example.com', - 'name': 'User', - 'spareTime': 10, - 'note': 'note', - 'punctualityScore': 4.5, - 'role': role, - }, - }, - headers: Headers.fromMap({ - 'authorization': ['access-token'], - 'authorization-refresh': ['refresh-token'], - }), - requestOptions: RequestOptions(path: path), - ); -} - -UserEntity _user({required bool isOnboardingCompleted}) { - return UserEntity( - id: '1', - email: 'user@example.com', - name: 'User', - spareTime: const Duration(minutes: 10), - note: 'note', - score: 4.5, - isOnboardingCompleted: isOnboardingCompleted, - ); -} diff --git a/test/data/data_sources/notification_remote_data_source_test.dart b/test/data/data_sources/notification_remote_data_source_test.dart deleted file mode 100644 index 432e2a66..00000000 --- a/test/data/data_sources/notification_remote_data_source_test.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:on_time_front/core/constants/endpoint.dart'; -import 'package:on_time_front/data/data_sources/notification_remote_data_source.dart'; -import 'package:on_time_front/data/models/fcm_token_register_request_model.dart'; - -import '../../helpers/mock.mocks.dart'; - -void main() { - late Dio dio; - late NotificationRemoteDataSourceImpl dataSource; - - setUp(() { - dio = MockAppDio(); - dataSource = NotificationRemoteDataSourceImpl(dio); - }); - - test('fcmTokenRegister posts the device token payload', () async { - when( - dio.post(Endpoint.fcmTokenRegister, data: anyNamed('data')), - ).thenAnswer( - (_) async => Response( - statusCode: 200, - requestOptions: RequestOptions(path: Endpoint.fcmTokenRegister), - ), - ); - - await dataSource.fcmTokenRegister( - FcmTokenRegisterRequestModel( - firebaseToken: 'fcm-token', - deviceId: 'device-1', - ), - ); - - final data = - verify( - dio.post( - Endpoint.fcmTokenRegister, - data: captureAnyNamed('data'), - ), - ).captured.single - as Map; - expect(data, {'firebaseToken': 'fcm-token', 'deviceId': 'device-1'}); - }); - - test('fcmTokenRegister rejects non-success backend status', () async { - when( - dio.post(Endpoint.fcmTokenRegister, data: anyNamed('data')), - ).thenAnswer( - (_) async => Response( - statusCode: 500, - requestOptions: RequestOptions(path: Endpoint.fcmTokenRegister), - ), - ); - - await expectLater( - dataSource.fcmTokenRegister( - FcmTokenRegisterRequestModel(firebaseToken: 'fcm-token'), - ), - throwsException, - ); - }); -} diff --git a/test/data/data_sources/preparation_local_data_source_test.dart b/test/data/data_sources/preparation_local_data_source_test.dart index 4136bbc0..57f8797d 100644 --- a/test/data/data_sources/preparation_local_data_source_test.dart +++ b/test/data/data_sources/preparation_local_data_source_test.dart @@ -20,11 +20,8 @@ void main() { .insert( UsersCompanion( id: const drift.Value('userId'), - email: const drift.Value('user@example.com'), - name: const drift.Value('User'), - spareTime: drift.Value(const Duration(minutes: 10).inSeconds), + spareTime: const drift.Value(10), note: const drift.Value('note'), - score: const drift.Value(4.5), ), ); await database @@ -58,14 +55,20 @@ void main() { }); test('creates and updates the default user preparation', () async { - await dataSource.createDefaultPreparation(_preparation(userBased: true)); + await dataSource.createDefaultPreparation( + _preparation(userBased: true), + userId: 'userId', + ); final updated = const PreparationStepEntity( id: 'step-1', preparationName: 'Updated shower', preparationTime: Duration(minutes: 12), ); - await dataSource.updatePreparation(updated); + await dataSource.replaceDefaultPreparation( + PreparationEntity(preparationStepList: [updated]), + userId: 'userId', + ); final stored = await database.preparationUserDao .getPreparationUsersByUserId('userId'); @@ -98,7 +101,15 @@ void main() { preparationTime: Duration(minutes: 20), nextPreparationId: 'step-2', ); - await dataSource.updatePreparation(updated); + await dataSource.replaceSchedulePreparation( + PreparationEntity( + preparationStepList: [ + updated, + bySchedule.preparationStepList.last, + ], + ), + scheduleId: 'scheduleId', + ); expect( (await dataSource.getPreparationStepById('step-1')).preparationName, 'Updated schedule prep', diff --git a/test/data/data_sources/preparation_remote_data_source_test.dart b/test/data/data_sources/preparation_remote_data_source_test.dart deleted file mode 100644 index 5ebb4a8e..00000000 --- a/test/data/data_sources/preparation_remote_data_source_test.dart +++ /dev/null @@ -1,190 +0,0 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/data/data_sources/preparation_remote_data_source.dart'; -import 'package:on_time_front/data/models/create_defualt_preparation_request_model.dart'; -import 'package:on_time_front/domain/entities/preparation_entity.dart'; -import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; - -void main() { - late _PreparationAdapter adapter; - late PreparationRemoteDataSourceImpl dataSource; - - setUp(() { - adapter = _PreparationAdapter(); - final dio = Dio( - BaseOptions(baseUrl: 'https://example.com', validateStatus: (_) => true), - )..httpClientAdapter = adapter; - dataSource = PreparationRemoteDataSourceImpl(dio); - }); - - test( - 'create and update calls serialize preparation steps for backend', - () async { - final preparation = _preparation(); - - await dataSource.createCustomPreparation(preparation, 'schedule-1'); - await dataSource.updatePreparationByScheduleId(preparation, 'schedule-1'); - await dataSource.updateDefaultPreparation(preparation); - - expect(adapter.requests.map((request) => request.method), [ - 'POST', - 'PUT', - 'PUT', - ]); - expect(adapter.requests[0].body, isA>()); - expect( - (adapter.requests[0].body as List).first['preparationName'], - 'Shower', - ); - expect( - (adapter.requests[1].body as List).first['preparationId'], - 'step-1', - ); - expect( - (adapter.requests[2].body as List).first['preparationId'], - 'step-1', - ); - }, - ); - - test( - 'default create and spare time update send their request bodies', - () async { - await dataSource.createDefaultPreparation( - CreateDefaultPreparationRequestModel.fromEntity( - preparationEntity: _preparation(), - spareTime: const Duration(minutes: 5), - note: 'note', - ), - ); - await dataSource.updateSpareTime(const Duration(minutes: 15)); - - expect(adapter.requests.first.method, 'PUT'); - expect( - (adapter.requests.first.body - as Map)['preparationList'], - isA(), - ); - expect( - (adapter.requests.last.body as Map)['newSpareTime'], - 15, - ); - }, - ); - - test( - 'get preparation calls map ordered backend steps into entities', - () async { - final bySchedule = await dataSource.getPreparationByScheduleId( - 'schedule-1', - ); - final defaultPreparation = await dataSource.getDefualtPreparation(); - - expect(bySchedule.preparationStepList.map((step) => step.id), [ - 'step-1', - 'step-2', - ]); - expect(defaultPreparation.preparationStepList.map((step) => step.id), [ - 'step-1', - 'step-2', - ]); - expect(bySchedule.totalDuration, const Duration(minutes: 15)); - }, - ); - - test('non-200 responses surface failures', () async { - adapter.statusCode = 500; - - await expectLater( - dataSource.createCustomPreparation(_preparation(), 'schedule-1'), - throwsException, - ); - await expectLater(dataSource.getDefualtPreparation(), throwsException); - }); -} - -PreparationEntity _preparation() { - return const PreparationEntity( - preparationStepList: [ - PreparationStepEntity( - id: 'step-1', - preparationName: 'Shower', - preparationTime: Duration(minutes: 10), - nextPreparationId: 'step-2', - ), - PreparationStepEntity( - id: 'step-2', - preparationName: 'Pack', - preparationTime: Duration(minutes: 5), - ), - ], - ); -} - -class _PreparationRequest { - const _PreparationRequest({ - required this.method, - required this.path, - required this.body, - }); - - final String method; - final String path; - final Object? body; -} - -class _PreparationAdapter implements HttpClientAdapter { - int statusCode = 200; - final requests = <_PreparationRequest>[]; - - @override - Future fetch( - RequestOptions options, - Stream? requestStream, - Future? cancelFuture, - ) async { - requests.add( - _PreparationRequest( - method: options.method, - path: options.path, - body: options.data, - ), - ); - - if (options.method == 'GET') { - return _json({ - 'data': [ - { - 'preparationId': 'step-1', - 'preparationName': 'Shower', - 'preparationTime': 10, - 'nextPreparationId': 'step-2', - }, - { - 'preparationId': 'step-2', - 'preparationName': 'Pack', - 'preparationTime': 5, - 'nextPreparationId': null, - }, - ], - }); - } - return _json({'data': null}); - } - - ResponseBody _json(Object body) { - return ResponseBody.fromString( - jsonEncode(body), - statusCode, - headers: { - Headers.contentTypeHeader: [Headers.jsonContentType], - }, - ); - } - - @override - void close({bool force = false}) {} -} diff --git a/test/data/data_sources/preparation_template_remote_data_source_test.dart b/test/data/data_sources/preparation_template_remote_data_source_test.dart deleted file mode 100644 index 8717005b..00000000 --- a/test/data/data_sources/preparation_template_remote_data_source_test.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:on_time_front/core/constants/endpoint.dart'; -import 'package:on_time_front/data/data_sources/preparation_template_remote_data_source.dart'; -import 'package:on_time_front/data/models/preparation_template_model.dart'; -import 'package:on_time_front/domain/entities/preparation_entity.dart'; -import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; - -import '../../helpers/mock.mocks.dart'; - -void main() { - late Dio dio; - late PreparationTemplateRemoteDataSourceImpl dataSource; - - setUp(() { - dio = MockAppDio(); - dataSource = PreparationTemplateRemoteDataSourceImpl(dio); - }); - - test('gets active preparation templates', () async { - when(dio.get(Endpoint.preparationTemplates)).thenAnswer( - (_) async => Response( - statusCode: 200, - requestOptions: RequestOptions(path: Endpoint.preparationTemplates), - data: { - 'status': 'success', - 'data': [ - { - 'templateId': 'template-1', - 'templateName': 'Work', - 'createdAt': '2026-05-14T02:10:00Z', - 'updatedAt': '2026-05-14T02:10:00Z', - 'deletedAt': null, - 'preparations': [], - }, - ], - }, - ), - ); - - final templates = await dataSource.getPreparationTemplates(); - - expect(templates.single.id, 'template-1'); - expect(templates.single.name, 'Work'); - }); - - test('creates preparation template with ordered steps', () async { - final request = UpsertPreparationTemplateRequestModel.fromValues( - templateId: 'template-1', - templateName: 'Work', - preparation: const PreparationEntity( - preparationStepList: [ - PreparationStepEntity( - id: 'prep-1', - preparationName: 'Pack laptop', - preparationTime: Duration(minutes: 5), - ), - ], - ), - ).toJson(); - - when(dio.post(Endpoint.preparationTemplates, data: request)).thenAnswer( - (_) async => Response( - statusCode: 200, - requestOptions: RequestOptions(path: Endpoint.preparationTemplates), - ), - ); - - await dataSource.createPreparationTemplate( - templateId: 'template-1', - templateName: 'Work', - preparation: const PreparationEntity( - preparationStepList: [ - PreparationStepEntity( - id: 'prep-1', - preparationName: 'Pack laptop', - preparationTime: Duration(minutes: 5), - ), - ], - ), - ); - - verify(dio.post(Endpoint.preparationTemplates, data: request)).called(1); - }); - - test('deletes preparation template by id', () async { - when(dio.delete(Endpoint.preparationTemplateById('template-1'))).thenAnswer( - (_) async => Response( - statusCode: 200, - requestOptions: RequestOptions( - path: Endpoint.preparationTemplateById('template-1'), - ), - ), - ); - - await dataSource.deletePreparationTemplate('template-1'); - - verify( - dio.delete(Endpoint.preparationTemplateById('template-1')), - ).called(1); - }); -} diff --git a/test/data/data_sources/schedule_data_source_contract_test.dart b/test/data/data_sources/schedule_data_source_contract_test.dart index 321cea19..2af4abd5 100644 --- a/test/data/data_sources/schedule_data_source_contract_test.dart +++ b/test/data/data_sources/schedule_data_source_contract_test.dart @@ -3,17 +3,6 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; void main() { - test( - 'schedule remote data source contract does not expose domain entities', - () { - final violations = _scheduleEntityViolations( - 'lib/data/data_sources/schedule_remote_data_source.dart', - ); - - expect(violations, isEmpty); - }, - ); - test( 'schedule local data source contract does not expose domain entities', () { diff --git a/test/data/data_sources/schedule_local_data_source_test.dart b/test/data/data_sources/schedule_local_data_source_test.dart index 923a5fd2..cc83d2f1 100644 --- a/test/data/data_sources/schedule_local_data_source_test.dart +++ b/test/data/data_sources/schedule_local_data_source_test.dart @@ -201,6 +201,7 @@ Schedule _scheduleRow({ id: id, placeId: placeId, scheduleName: scheduleName, + timeZoneId: 'Asia/Seoul', scheduleTime: scheduleTime, moveTime: moveTime, isChanged: isChanged, @@ -208,5 +209,9 @@ Schedule _scheduleRow({ scheduleSpareTime: scheduleSpareTime, scheduleNote: scheduleNote, latenessTime: latenessTime, + doneStatus: 'notEnded', + preparationTemplateDeleted: false, + preparationFrozen: false, + scoreContributionRecorded: false, ); } diff --git a/test/data/data_sources/schedule_remote_data_source_test.dart b/test/data/data_sources/schedule_remote_data_source_test.dart deleted file mode 100644 index c8d4c1f6..00000000 --- a/test/data/data_sources/schedule_remote_data_source_test.dart +++ /dev/null @@ -1,197 +0,0 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/data/data_sources/schedule_remote_data_source.dart'; -import 'package:on_time_front/data/models/create_schedule_request_model.dart'; -import 'package:on_time_front/data/models/update_schedule_request_model.dart'; - -void main() { - late _ScheduleAdapter adapter; - late ScheduleRemoteDataSourceImpl dataSource; - - setUp(() { - adapter = _ScheduleAdapter(); - final dio = Dio( - BaseOptions(baseUrl: 'https://example.com', validateStatus: (_) => true), - )..httpClientAdapter = adapter; - dataSource = ScheduleRemoteDataSourceImpl(dio); - }); - - test( - 'create, update, delete, start, and finish send schedule API contracts', - () async { - final createRequest = _createRequest('schedule-1'); - final updateRequest = _updateRequest('schedule-1'); - - await dataSource.createSchedule(createRequest); - await dataSource.updateSchedule(updateRequest); - await dataSource.deleteSchedule('schedule-1'); - await dataSource.startSchedule('schedule-1'); - await dataSource.finishSchedule('schedule-1', 7); - - expect(adapter.requests.map((request) => request.method), [ - 'POST', - 'PUT', - 'DELETE', - 'POST', - 'PUT', - ]); - expect(adapter.requests[0].body['scheduleName'], 'Meeting schedule-1'); - expect(adapter.requests[1].body['scheduleName'], 'Meeting schedule-1'); - expect(adapter.requests[3].path, '/schedules/schedule-1/start'); - expect(adapter.requests[4].body, { - 'scheduleId': 'schedule-1', - 'latenessTime': 7, - }); - }, - ); - - test('getScheduleById returns the backend response model', () async { - final schedule = await dataSource.getScheduleById('schedule-1'); - - expect(schedule.scheduleId, 'schedule-1'); - expect(schedule.place.placeId, 'place-1'); - expect(schedule.place.placeName, 'Office'); - expect(schedule.scheduleName, 'Morning meeting'); - expect(schedule.moveTime, 20); - expect(schedule.scheduleSpareTime, 5); - expect(schedule.doneStatus, 'NORMAL'); - }); - - test( - 'getSchedulesByDate passes date query parameters and maps list response', - () async { - final start = DateTime(2026, 5, 15); - final end = DateTime(2026, 5, 16); - - final schedules = await dataSource.getSchedulesByDate(start, end); - - expect(schedules.map((schedule) => schedule.scheduleId), [ - 'schedule-1', - 'schedule-2', - ]); - expect( - adapter.requests.single.query['startDate'], - start.toIso8601String(), - ); - expect(adapter.requests.single.query['endDate'], end.toIso8601String()); - }, - ); - - test('non-200 responses surface failures for callers', () async { - adapter.statusCode = 500; - - await expectLater( - dataSource.createSchedule(_createRequest('schedule-1')), - throwsException, - ); - await expectLater( - dataSource.getScheduleById('schedule-1'), - throwsException, - ); - }); -} - -CreateScheduleRequestModel _createRequest(String id) { - return CreateScheduleRequestModel( - scheduleId: id, - placeId: 'place-1', - placeName: 'Office', - scheduleName: 'Meeting $id', - scheduleTime: DateTime(2026, 5, 15, 9), - moveTime: 20, - isChange: false, - isStarted: false, - scheduleSpareTime: 5, - scheduleNote: 'note', - ); -} - -UpdateScheduleRequestModel _updateRequest(String id) { - return UpdateScheduleRequestModel( - scheduleId: id, - placeId: 'place-1', - placeName: 'Office', - scheduleName: 'Meeting $id', - scheduleTime: DateTime(2026, 5, 15, 9), - moveTime: 20, - scheduleSpareTime: 5, - scheduleNote: 'note', - ); -} - -class _ScheduleRequest { - const _ScheduleRequest({ - required this.method, - required this.path, - required this.query, - required this.body, - }); - - final String method; - final String path; - final Map query; - final Map body; -} - -class _ScheduleAdapter implements HttpClientAdapter { - int statusCode = 200; - final requests = <_ScheduleRequest>[]; - - @override - Future fetch( - RequestOptions options, - Stream? requestStream, - Future? cancelFuture, - ) async { - requests.add( - _ScheduleRequest( - method: options.method, - path: options.path, - query: Map.from(options.queryParameters), - body: options.data is Map - ? Map.from(options.data as Map) - : const {}, - ), - ); - - if (options.method == 'GET' && options.path.contains('schedule-1')) { - return _json({'data': _scheduleJson('schedule-1')}); - } - if (options.method == 'GET') { - return _json({ - 'data': [_scheduleJson('schedule-1'), _scheduleJson('schedule-2')], - }); - } - return _json({'data': null}); - } - - ResponseBody _json(Object body) { - return ResponseBody.fromString( - jsonEncode(body), - statusCode, - headers: { - Headers.contentTypeHeader: [Headers.jsonContentType], - }, - ); - } - - Map _scheduleJson(String id) { - return { - 'scheduleId': id, - 'place': {'placeId': 'place-1', 'placeName': 'Office'}, - 'scheduleName': id == 'schedule-1' ? 'Morning meeting' : 'Lunch', - 'scheduleTime': DateTime(2026, 5, 15, 9).toIso8601String(), - 'moveTime': 20, - 'scheduleSpareTime': 5, - 'scheduleNote': 'note', - 'latenessTime': 0, - 'doneStatus': 'NORMAL', - }; - } - - @override - void close({bool force = false}) {} -} diff --git a/test/data/data_sources/token_local_data_source_test.dart b/test/data/data_sources/token_local_data_source_test.dart deleted file mode 100644 index 0e66b1b2..00000000 --- a/test/data/data_sources/token_local_data_source_test.dart +++ /dev/null @@ -1,230 +0,0 @@ -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/data/data_sources/token_local_data_source.dart'; -import 'package:on_time_front/domain/entities/token_entity.dart'; - -void main() { - late TokenLocalDataSourceImpl dataSource; - - setUp(() { - FlutterSecureStorage.setMockInitialValues({}); - dataSource = TokenLocalDataSourceImpl(); - }); - - test('stores and reads access and refresh tokens together', () async { - const token = TokenEntity( - accessToken: 'access-token', - refreshToken: 'refresh-token', - ); - - await dataSource.storeTokens(token); - - expect(await dataSource.getToken(), token); - }); - - test( - 'does not reread secure storage after the token cache is warm', - () async { - const token = TokenEntity( - accessToken: 'access-token', - refreshToken: 'refresh-token', - ); - final storage = _CountingSecureStorage({ - 'accessToken': token.accessToken, - 'refreshToken': token.refreshToken, - }); - dataSource = TokenLocalDataSourceImpl.withStorage(storage); - - expect(await dataSource.getToken(), token); - expect(await dataSource.getToken(), token); - - expect(storage.readsByKey, {'accessToken': 1, 'refreshToken': 1}); - }, - ); - - test('auth token write only updates the access token slot', () async { - await dataSource.storeTokens( - const TokenEntity( - accessToken: 'old-access', - refreshToken: 'refresh-token', - ), - ); - - await dataSource.storeAuthToken('new-access'); - - expect( - await dataSource.getToken(), - const TokenEntity( - accessToken: 'new-access', - refreshToken: 'refresh-token', - ), - ); - }); - - test( - 'auth token write warms the cache when refresh token is persisted', - () async { - final storage = _CountingSecureStorage({'refreshToken': 'refresh-token'}); - dataSource = TokenLocalDataSourceImpl.withStorage(storage); - - await dataSource.storeAuthToken('new-access'); - storage.readsByKey.clear(); - - expect( - await dataSource.getToken(), - const TokenEntity( - accessToken: 'new-access', - refreshToken: 'refresh-token', - ), - ); - expect(storage.readsByKey, isEmpty); - }, - ); - - test( - 'legacy token load migrates to current storage and warms cache', - () async { - const legacyToken = TokenEntity( - accessToken: 'legacy-access', - refreshToken: 'legacy-refresh', - ); - final storage = _LegacyTokenSecureStorage( - currentValues: {}, - legacyValues: { - 'accessToken': legacyToken.accessToken, - 'refreshToken': legacyToken.refreshToken, - }, - ); - dataSource = TokenLocalDataSourceImpl.withStorage(storage); - - expect(await dataSource.getToken(), legacyToken); - storage.readsByKey.clear(); - - expect(await dataSource.getToken(), legacyToken); - expect(storage.currentValues, { - 'accessToken': legacyToken.accessToken, - 'refreshToken': legacyToken.refreshToken, - }); - expect(storage.readsByKey, isEmpty); - }, - ); - - test( - 'delete removes both token values and missing tokens fail clearly', - () async { - await dataSource.storeTokens( - const TokenEntity( - accessToken: 'access-token', - refreshToken: 'refresh-token', - ), - ); - - await dataSource.deleteToken(); - - await expectLater( - dataSource.getToken(), - throwsA( - isA().having( - (error) => error.toString(), - 'message', - contains('Token not found'), - ), - ), - ); - }, - ); -} - -class _CountingSecureStorage extends FlutterSecureStorage { - _CountingSecureStorage(this.values); - - final Map values; - final readsByKey = {}; - - @override - Future read({ - required String key, - AppleOptions? iOptions, - AndroidOptions? aOptions, - LinuxOptions? lOptions, - WebOptions? webOptions, - AppleOptions? mOptions, - WindowsOptions? wOptions, - }) async { - readsByKey[key] = (readsByKey[key] ?? 0) + 1; - return values[key]; - } - - @override - Future write({ - required String key, - required String? value, - AppleOptions? iOptions, - AndroidOptions? aOptions, - LinuxOptions? lOptions, - WebOptions? webOptions, - AppleOptions? mOptions, - WindowsOptions? wOptions, - }) async { - values[key] = value; - } - - @override - Future delete({ - required String key, - AppleOptions? iOptions, - AndroidOptions? aOptions, - LinuxOptions? lOptions, - WebOptions? webOptions, - AppleOptions? mOptions, - WindowsOptions? wOptions, - }) async { - values.remove(key); - } -} - -class _LegacyTokenSecureStorage extends FlutterSecureStorage { - _LegacyTokenSecureStorage({ - required this.currentValues, - required this.legacyValues, - }); - - final Map currentValues; - final Map legacyValues; - final readsByKey = {}; - - @override - Future read({ - required String key, - AppleOptions? iOptions, - AndroidOptions? aOptions, - LinuxOptions? lOptions, - WebOptions? webOptions, - AppleOptions? mOptions, - WindowsOptions? wOptions, - }) async { - readsByKey[key] = (readsByKey[key] ?? 0) + 1; - return _valuesFor(iOptions)[key]; - } - - @override - Future write({ - required String key, - required String? value, - AppleOptions? iOptions, - AndroidOptions? aOptions, - LinuxOptions? lOptions, - WebOptions? webOptions, - AppleOptions? mOptions, - WindowsOptions? wOptions, - }) async { - _valuesFor(iOptions)[key] = value; - } - - Map _valuesFor(AppleOptions? iOptions) { - return iOptions?.accessibility == - KeychainAccessibility.first_unlock_this_device - ? currentValues - : legacyValues; - } -} diff --git a/test/data/mappers/domain_persistence_mappers_test.dart b/test/data/mappers/domain_persistence_mappers_test.dart index 3154e0d5..3f30f999 100644 --- a/test/data/mappers/domain_persistence_mappers_test.dart +++ b/test/data/mappers/domain_persistence_mappers_test.dart @@ -17,6 +17,7 @@ void main() { id: 'schedule-model', placeId: 'place-model', scheduleName: 'Doctor', + timeZoneId: 'Asia/Seoul', scheduleTime: DateTime(2026, 4, 1, 15), moveTime: const Duration(minutes: 30), isChanged: true, @@ -24,6 +25,10 @@ void main() { scheduleSpareTime: const Duration(minutes: 5), scheduleNote: null, latenessTime: 7, + doneStatus: 'notEnded', + preparationTemplateDeleted: false, + preparationFrozen: false, + scoreContributionRecorded: false, ), place: const Place(id: 'place-model', placeName: 'Clinic'), ).toScheduleEntity(); @@ -50,11 +55,15 @@ void main() { test('maps users to and from database rows preserving profile values', () { const row = User( id: 'user-1', - email: 'user@example.com', - name: 'User', spareTime: 12, note: 'note', - score: 4.5, + isOnboardingCompleted: true, + eligibleOutcomeCount: 4, + onTimeOutcomeCount: 3, + alarmsEnabled: true, + alarmOffsetMinutes: 5, + detailedNotificationContent: false, + dataRevision: 7, ); final entity = row.toUserEntity(); @@ -62,15 +71,12 @@ void main() { expect(entity.valueOrNull, entity); expect(entity.spareTimeOrNull, const Duration(minutes: 12)); - expect(entity.scoreOrNull, 4.5); - expect(entity.nameOrNull, 'User'); - expect(entity.emailOrNull, 'user@example.com'); + expect(entity.scoreOrNull, 75); expect(roundTrip.id, row.id); - expect(roundTrip.email, row.email); - expect(roundTrip.name, row.name); expect(roundTrip.spareTime, row.spareTime); expect(roundTrip.note, row.note); - expect(roundTrip.score, row.score); + expect(roundTrip.eligibleOutcomeCount, row.eligibleOutcomeCount); + expect(roundTrip.onTimeOutcomeCount, row.onTimeOutcomeCount); }); test('empty users cannot be converted to database rows', () { diff --git a/test/data/models/alarm_models_test.dart b/test/data/models/alarm_models_test.dart deleted file mode 100644 index f86a38e7..00000000 --- a/test/data/models/alarm_models_test.dart +++ /dev/null @@ -1,648 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/data/models/alarm_device_model.dart'; -import 'package:on_time_front/data/models/alarm_settings_model.dart'; -import 'package:on_time_front/data/models/alarm_status_report_model.dart'; -import 'package:on_time_front/data/models/alarm_window_schedule_model.dart'; -import 'package:on_time_front/data/models/scheduled_alarm_record_model.dart'; -import 'package:on_time_front/domain/entities/alarm_entities.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'; - -void main() { - test('alarm settings maps backend defaults and update request JSON', () { - final model = AlarmSettingsModel.fromJson({ - 'alarmsEnabled': false, - 'updatedAt': '2026-05-05T09:00:00.000', - }); - - expect(model.toEntity().alarmsEnabled, isFalse); - expect(model.toEntity().defaultAlarmOffsetMinutes, 5); - expect( - const UpdateAlarmSettingsRequestModel(alarmsEnabled: true).toJson(), - {'alarmsEnabled': true}, - ); - }); - - test('alarm settings round trip preserves explicit backend values', () { - final updatedAt = DateTime.utc(2026, 5, 5, 9); - final model = AlarmSettingsModel( - alarmsEnabled: true, - defaultAlarmOffsetMinutes: 11, - updatedAt: updatedAt, - ); - - expect(model.toJson(), { - 'alarmsEnabled': true, - 'defaultAlarmOffsetMinutes': 11, - 'updatedAt': updatedAt.toIso8601String(), - }); - - final fromEntity = AlarmSettingsModel.fromEntity(model.toEntity()); - expect(fromEntity.alarmsEnabled, isTrue); - expect(fromEntity.defaultAlarmOffsetMinutes, 11); - expect(fromEntity.updatedAt, updatedAt); - }); - - test('device info serializes provider wire values', () { - final json = AlarmDeviceInfoModel.fromEntity( - const AlarmDeviceInfo( - deviceId: 'device-1', - platform: 'android', - appVersion: '1.0.0', - osVersion: 'android-35', - supportsNativeAlarm: true, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, - fallbackProvider: AlarmProvider.localNotification, - ), - ).toJson(); - - expect(json['deviceId'], 'device-1'); - expect(json['nativeAlarmProvider'], 'androidAlarmManager'); - expect(json['fallbackProvider'], 'localNotification'); - }); - - test('alarm window schedule maps backend schedule and preparation JSON', () { - final entity = AlarmWindowScheduleModel.fromJson({ - 'scheduleId': 'schedule-1', - 'scheduleName': 'Morning meeting', - 'place': {'placeId': 'place-1', 'placeName': 'Office'}, - 'scheduleTime': '2026-05-05T10:00:00.000', - 'moveTime': 20, - 'scheduleSpareTime': 10, - 'doneStatus': 'NOT_ENDED', - 'preparations': [ - { - 'preparationId': 'prep-1', - 'preparationName': 'Shower', - 'preparationTime': 15, - 'nextPreparationId': 'prep-2', - }, - ], - }).toEntity(); - - expect(entity.id, 'schedule-1'); - expect(entity.place.placeName, 'Office'); - expect(entity.doneStatus, ScheduleDoneStatus.notEnded); - expect(entity.moveTime, const Duration(minutes: 20)); - expect( - entity.preparation.preparationStepList.single.nextPreparationId, - 'prep-2', - ); - }); - - test( - 'status report and registry record serialize alarm contract payloads', - () { - final now = DateTime.utc(2026, 5, 5, 9, 0, 0, 123, 456); - final statusJson = AlarmStatusReportModel( - AlarmStatusReport( - deviceId: 'device-1', - reconciledAt: now, - scheduleWindowStart: now, - scheduleWindowEnd: now.add(const Duration(days: 8)), - alarmCoverageStart: now, - alarmCoverageEnd: now.add(const Duration(days: 7)), - status: AlarmReconciliationStatus.partial, - permissionIssue: AlarmPermissionIssue.notificationPermissionDenied, - nativeAlarmProvider: AlarmProvider.none, - fallbackProvider: AlarmProvider.localNotification, - armedScheduleCount: 1, - armedScheduleIds: const ['schedule-1'], - skippedScheduleCount: 2, - failures: const [ - AlarmFailure( - scheduleId: 'schedule-2', - reason: AlarmFailureReason.platformError, - message: 'failed', - ), - ], - ), - ).toJson(); - - expect(statusJson['status'], 'partial'); - expect(statusJson['permissionIssue'], 'notificationPermissionDenied'); - expect(statusJson['reconciledAt'], '2026-05-05T09:00:00.123Z'); - expect(statusJson['armedScheduleIds'], ['schedule-1']); - expect( - (statusJson['failures'] as List).single['reason'], - 'platformError', - ); - - final recordJson = ScheduledAlarmRecordModel( - ScheduledAlarmRecord( - scheduleId: 'schedule-1', - alarmTime: now, - preparationStartTime: now.add(const Duration(minutes: 5)), - scheduleFingerprint: 'fingerprint', - nativeAlarmId: 123, - fallbackNotificationId: 123, - provider: AlarmProvider.localNotification, - scheduleTitle: 'Morning meeting', - payload: const {'type': 'schedule_alarm', 'scheduleId': 'schedule-1'}, - ), - ).toJson(); - - final decoded = ScheduledAlarmRecordModel.fromJson(recordJson).record; - expect(decoded.provider, AlarmProvider.localNotification); - expect(decoded.payload['type'], 'schedule_alarm'); - expect(decoded.scheduleFingerprint, 'fingerprint'); - }, - ); - - test('status report defaults to lower-camel and supports backend enums', () { - final now = DateTime.utc(2026, 5, 5, 9); - final model = AlarmStatusReportModel( - AlarmStatusReport( - deviceId: 'device-1', - reconciledAt: now, - scheduleWindowStart: now, - scheduleWindowEnd: now.add(const Duration(days: 8)), - alarmCoverageStart: now, - alarmCoverageEnd: now.add(const Duration(days: 7)), - status: AlarmReconciliationStatus.armed, - nativeAlarmProvider: AlarmProvider.iosAlarmKit, - fallbackProvider: AlarmProvider.localNotification, - armedScheduleCount: 1, - armedScheduleIds: const ['schedule-1'], - skippedScheduleCount: 0, - failures: const [], - ), - ); - - final json = model.toJson(); - expect(json.containsKey('permissionIssue'), isFalse); - expect(json['reconciledAt'], '2026-05-05T09:00:00.000Z'); - expect(json['status'], 'armed'); - expect(json['nativeAlarmProvider'], 'iosAlarmKit'); - expect(json['fallbackProvider'], 'localNotification'); - - final backendJson = model.toJson( - wireFormat: AlarmStatusReportWireFormat.upperSnake, - ); - expect(backendJson.containsKey('permissionIssue'), isFalse); - expect(backendJson['status'], 'ARMED'); - expect(backendJson['nativeAlarmProvider'], 'IOS_ALARM_KIT'); - }); - - test('status report serializes all upper-snake enum branches', () { - final now = DateTime.utc(2026, 5, 5, 9); - - Map reportJson({ - required AlarmReconciliationStatus status, - required AlarmProvider nativeProvider, - required AlarmProvider fallbackProvider, - AlarmPermissionIssue? permissionIssue, - AlarmFailureReason failureReason = AlarmFailureReason.unknown, - }) { - return AlarmStatusReportModel( - AlarmStatusReport( - deviceId: 'device-1', - reconciledAt: now, - scheduleWindowStart: now, - scheduleWindowEnd: now.add(const Duration(days: 8)), - alarmCoverageStart: now, - alarmCoverageEnd: now.add(const Duration(days: 7)), - status: status, - permissionIssue: permissionIssue, - nativeAlarmProvider: nativeProvider, - fallbackProvider: fallbackProvider, - armedScheduleCount: 0, - armedScheduleIds: const [], - skippedScheduleCount: 1, - failures: [AlarmFailure(reason: failureReason)], - ), - ).toJson(wireFormat: AlarmStatusReportWireFormat.upperSnake); - } - - expect( - reportJson( - status: AlarmReconciliationStatus.partial, - nativeProvider: AlarmProvider.androidAlarmManager, - fallbackProvider: AlarmProvider.none, - permissionIssue: AlarmPermissionIssue.nativePermissionDenied, - failureReason: AlarmFailureReason.preparationLoadFailed, - ), - containsPair('status', 'PARTIAL'), - ); - expect( - reportJson( - status: AlarmReconciliationStatus.disabled, - nativeProvider: AlarmProvider.localNotification, - fallbackProvider: AlarmProvider.iosAlarmKit, - permissionIssue: AlarmPermissionIssue.notificationPermissionDenied, - failureReason: AlarmFailureReason.scheduleInvalid, - ), - containsPair('permissionIssue', 'NOTIFICATION_PERMISSION_DENIED'), - ); - expect( - reportJson( - status: AlarmReconciliationStatus.permissionNeeded, - nativeProvider: AlarmProvider.none, - fallbackProvider: AlarmProvider.localNotification, - failureReason: AlarmFailureReason.platformError, - )['failures'], - [ - {'reason': 'PLATFORM_ERROR'}, - ], - ); - expect( - reportJson( - status: AlarmReconciliationStatus.unsupported, - nativeProvider: AlarmProvider.iosAlarmKit, - fallbackProvider: AlarmProvider.none, - ), - containsPair('nativeAlarmProvider', 'IOS_ALARM_KIT'), - ); - expect( - reportJson( - status: AlarmReconciliationStatus.settingsUnavailable, - nativeProvider: AlarmProvider.none, - fallbackProvider: AlarmProvider.none, - ), - containsPair('status', 'SETTINGS_UNAVAILABLE'), - ); - }); - - test('alarm enum wire values tolerate backend and unknown values', () { - expect(AlarmProvider.androidAlarmManager.wireValue, 'androidAlarmManager'); - expect(AlarmProvider.iosAlarmKit.wireValue, 'iosAlarmKit'); - expect(AlarmProvider.localNotification.wireValue, 'localNotification'); - expect(AlarmProvider.none.wireValue, 'none'); - expect( - AlarmPermissionStateWireValue.fromWireValue('granted'), - AlarmPermissionState.granted, - ); - expect( - AlarmPermissionStateWireValue.fromWireValue('denied'), - AlarmPermissionState.denied, - ); - expect( - AlarmProviderWireValue.fromWireValue('ANDROID_ALARM_MANAGER'), - AlarmProvider.androidAlarmManager, - ); - expect( - AlarmProviderWireValue.fromWireValue('IOS_ALARM_KIT'), - AlarmProvider.iosAlarmKit, - ); - expect( - AlarmProviderWireValue.fromWireValue('LOCAL_NOTIFICATION'), - AlarmProvider.localNotification, - ); - expect( - AlarmProviderWireValue.fromWireValue('unexpected'), - AlarmProvider.none, - ); - - expect( - AlarmPermissionStateWireValue.fromWireValue('notDetermined'), - AlarmPermissionState.notDetermined, - ); - expect( - AlarmPermissionStateWireValue.fromWireValue('unsupported'), - AlarmPermissionState.unsupported, - ); - expect( - AlarmPermissionIssueWireValue.fromWireValue('nativePermissionDenied'), - AlarmPermissionIssue.nativePermissionDenied, - ); - expect(AlarmPermissionIssueWireValue.fromWireValue('unknown'), isNull); - - expect( - AlarmFailureReasonWireValue.fromWireValue('PREPARATION_LOAD_FAILED'), - AlarmFailureReason.preparationLoadFailed, - ); - expect( - AlarmFailureReasonWireValue.fromWireValue('SCHEDULE_INVALID'), - AlarmFailureReason.scheduleInvalid, - ); - expect( - AlarmFailureReasonWireValue.fromWireValue('UNKNOWN'), - AlarmFailureReason.unknown, - ); - expect( - AlarmFailureReason.preparationLoadFailed.wireValue, - 'preparationLoadFailed', - ); - expect(AlarmFailureReason.scheduleInvalid.wireValue, 'scheduleInvalid'); - expect(AlarmFailureReason.platformError.wireValue, 'platformError'); - expect(AlarmFailureReason.unknown.wireValue, 'unknown'); - - expect(AlarmReconciliationStatus.armed.wireValue, 'armed'); - expect(AlarmReconciliationStatus.partial.wireValue, 'partial'); - expect(AlarmReconciliationStatus.disabled.wireValue, 'disabled'); - expect( - AlarmReconciliationStatus.permissionNeeded.wireValue, - 'permissionNeeded', - ); - expect(AlarmReconciliationStatus.unsupported.wireValue, 'unsupported'); - expect( - AlarmReconciliationStatus.settingsUnavailable.wireValue, - 'settingsUnavailable', - ); - expect( - AlarmReconciliationStatusWireValue.fromWireValue('permissionNeeded'), - AlarmReconciliationStatus.permissionNeeded, - ); - expect( - AlarmReconciliationStatusWireValue.fromWireValue('partial'), - AlarmReconciliationStatus.partial, - ); - expect( - AlarmReconciliationStatusWireValue.fromWireValue('disabled'), - AlarmReconciliationStatus.disabled, - ); - expect( - AlarmReconciliationStatusWireValue.fromWireValue('unsupported'), - AlarmReconciliationStatus.unsupported, - ); - expect( - AlarmReconciliationStatusWireValue.fromWireValue('anything-else'), - AlarmReconciliationStatus.settingsUnavailable, - ); - }); - - test( - 'schedule notification helpers derive stable notification records from schedules', - () { - final schedule = _scheduleWithPreparation( - doneStatus: ScheduleDoneStatus.notEnded, - ); - - final record = buildScheduledAlarmRecord( - schedule, - alarmOffset: const Duration(minutes: 7), - provider: AlarmProvider.androidAlarmManager, - ); - - expect(isAlarmEligibleSchedule(schedule), isTrue); - expect(record.scheduleId, schedule.id); - expect( - record.alarmTime, - schedule.preparationStartTime.subtract(const Duration(minutes: 7)), - ); - expect(record.preparationStartTime, schedule.preparationStartTime); - expect(record.nativeAlarmId, stableAlarmId(schedule.id)); - expect(record.fallbackNotificationId, stableAlarmId(schedule.id)); - expect(record.scheduleFingerprint, schedule.cacheFingerprint); - expect( - record.payload['alarmLaunchPayloadVersion'], - alarmLaunchPayloadVersion, - ); - expect(record.payload['type'], 'schedule_notification'); - expect(record.payload['promptVariant'], 'notification'); - expect(record.payload['placeName'], 'Office'); - }, - ); - - test('ended schedules are not eligible for alarm scheduling', () { - expect( - isAlarmEligibleSchedule( - _scheduleWithPreparation(doneStatus: ScheduleDoneStatus.normalEnd), - ), - isFalse, - ); - }); - - test('scheduled alarm records copy mutable scheduling fields only', () { - final original = ScheduledAlarmRecord( - scheduleId: 'schedule-1', - alarmTime: DateTime.utc(2026, 5, 15, 8), - preparationStartTime: DateTime.utc(2026, 5, 15, 8, 5), - scheduleFingerprint: 'fingerprint', - nativeAlarmId: 1, - fallbackNotificationId: 2, - provider: AlarmProvider.androidAlarmManager, - scheduleTitle: 'Morning meeting', - payload: const {'type': 'schedule_alarm'}, - ); - - final updated = original.copyWith( - nativeAlarmId: 3, - fallbackNotificationId: 4, - provider: AlarmProvider.localNotification, - payload: const {'type': 'fallback_alarm'}, - ); - - expect(updated.scheduleId, original.scheduleId); - expect(updated.nativeAlarmId, 3); - expect(updated.fallbackNotificationId, 4); - expect(updated.provider, AlarmProvider.localNotification); - expect(updated.payload['type'], 'fallback_alarm'); - }); - - test('alarm exceptions and result summaries expose user-visible context', () { - final exception = const AlarmSchedulingException( - reason: AlarmFailureReason.platformError, - permissionIssue: AlarmPermissionIssue.nativePermissionDenied, - message: 'permission missing', - ); - final now = DateTime.utc(2026, 5, 15); - final result = AlarmReconciliationResult( - status: AlarmReconciliationStatus.partial, - permissionIssue: AlarmPermissionIssue.notificationPermissionDenied, - nativeAlarmProvider: AlarmProvider.none, - fallbackProvider: AlarmProvider.localNotification, - armedScheduleIds: const ['schedule-1', 'schedule-2'], - skippedScheduleCount: 1, - failures: const [ - AlarmFailure( - scheduleId: 'schedule-3', - reason: AlarmFailureReason.scheduleInvalid, - message: 'ended', - ), - ], - scheduleWindowStart: now, - scheduleWindowEnd: now.add(const Duration(days: 8)), - alarmCoverageStart: now, - alarmCoverageEnd: now.add(const Duration(days: 7)), - ); - - expect(exception.toString(), contains('permission missing')); - expect( - const DeviceSessionNotActiveException().toString(), - 'DeviceSessionNotActiveException', - ); - expect(result.armedScheduleCount, 2); - expect(result.failures.single.reason, AlarmFailureReason.scheduleInvalid); - }); - - test( - 'alarm value objects compare the fields used by alarm sync contracts', - () { - final now = DateTime.utc(2026, 5, 15); - const device = AlarmDeviceInfo( - deviceId: 'device-1', - platform: 'android', - appVersion: '1.0.0', - osVersion: '35', - supportsNativeAlarm: true, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, - fallbackProvider: AlarmProvider.localNotification, - ); - const failure = AlarmFailure( - scheduleId: 'schedule-1', - reason: AlarmFailureReason.platformError, - message: 'permission', - ); - final result = AlarmReconciliationResult( - status: AlarmReconciliationStatus.partial, - permissionIssue: AlarmPermissionIssue.nativePermissionDenied, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, - fallbackProvider: AlarmProvider.localNotification, - armedScheduleIds: const ['schedule-1'], - skippedScheduleCount: 2, - failures: const [failure], - scheduleWindowStart: now, - scheduleWindowEnd: now.add(const Duration(days: 8)), - alarmCoverageStart: now, - alarmCoverageEnd: now.add(const Duration(days: 7)), - ); - final sameResult = AlarmReconciliationResult( - status: AlarmReconciliationStatus.partial, - permissionIssue: AlarmPermissionIssue.nativePermissionDenied, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, - fallbackProvider: AlarmProvider.localNotification, - armedScheduleIds: const ['schedule-1'], - skippedScheduleCount: 2, - failures: const [failure], - scheduleWindowStart: now, - scheduleWindowEnd: now.add(const Duration(days: 8)), - alarmCoverageStart: now, - alarmCoverageEnd: now.add(const Duration(days: 7)), - ); - final report = AlarmStatusReport( - deviceId: device.deviceId, - reconciledAt: now, - scheduleWindowStart: result.scheduleWindowStart, - scheduleWindowEnd: result.scheduleWindowEnd, - alarmCoverageStart: result.alarmCoverageStart, - alarmCoverageEnd: result.alarmCoverageEnd, - status: result.status, - permissionIssue: result.permissionIssue, - nativeAlarmProvider: result.nativeAlarmProvider, - fallbackProvider: result.fallbackProvider, - armedScheduleCount: result.armedScheduleCount, - armedScheduleIds: result.armedScheduleIds, - skippedScheduleCount: result.skippedScheduleCount, - failures: result.failures, - ); - final settings = AlarmSettings( - alarmsEnabled: true, - defaultAlarmOffsetMinutes: 7, - updatedAt: now, - ); - const capabilities = AlarmSchedulerCapabilities( - supportsNativeAlarm: true, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, - ); - final record = ScheduledAlarmRecord( - scheduleId: 'schedule-1', - alarmTime: now, - preparationStartTime: now.add(const Duration(minutes: 5)), - scheduleFingerprint: 'fingerprint', - nativeAlarmId: 10, - fallbackNotificationId: 11, - provider: AlarmProvider.androidAlarmManager, - scheduleTitle: 'Morning meeting', - payload: const {'type': 'schedule_alarm'}, - ); - - expect(settings.alarmOffset, const Duration(minutes: 7)); - expect(settings.props, [true, 7, now]); - expect(device.props, [ - 'device-1', - 'android', - '1.0.0', - '35', - true, - AlarmProvider.androidAlarmManager, - AlarmProvider.localNotification, - ]); - expect(capabilities.props, [ - true, - AlarmProvider.androidAlarmManager, - AlarmProvider.localNotification, - ]); - expect(record.props, [ - 'schedule-1', - now, - now.add(const Duration(minutes: 5)), - 'fingerprint', - 10, - 11, - AlarmProvider.androidAlarmManager, - 'Morning meeting', - const {'type': 'schedule_alarm'}, - ]); - expect(failure.props, [ - 'schedule-1', - AlarmFailureReason.platformError, - 'permission', - ]); - expect(result, equals(sameResult)); - expect(result.props, [ - AlarmReconciliationStatus.partial, - AlarmPermissionIssue.nativePermissionDenied, - AlarmProvider.androidAlarmManager, - AlarmProvider.localNotification, - const ['schedule-1'], - 2, - const [failure], - now, - now.add(const Duration(days: 8)), - now, - now.add(const Duration(days: 7)), - ]); - expect(report, equals(report)); - expect( - report.props, - containsAll([ - 'device-1', - AlarmReconciliationStatus.partial, - AlarmPermissionIssue.nativePermissionDenied, - AlarmProvider.androidAlarmManager, - AlarmProvider.localNotification, - 1, - 2, - const [failure], - ]), - ); - }, - ); -} - -ScheduleWithPreparationEntity _scheduleWithPreparation({ - required ScheduleDoneStatus doneStatus, -}) { - return ScheduleWithPreparationEntity( - id: 'schedule-1', - place: const PlaceEntity(id: 'place-1', placeName: 'Office'), - scheduleName: 'Morning meeting', - scheduleTime: DateTime.utc(2026, 5, 15, 9), - moveTime: const Duration(minutes: 20), - isChanged: false, - isStarted: false, - scheduleSpareTime: const Duration(minutes: 5), - scheduleNote: 'Bring notes', - doneStatus: doneStatus, - preparation: const PreparationWithTimeEntity( - preparationStepList: [ - PreparationStepWithTimeEntity( - id: 'prep-1', - preparationName: 'Pack', - preparationTime: Duration(minutes: 10), - nextPreparationId: 'prep-2', - ), - PreparationStepWithTimeEntity( - id: 'prep-2', - preparationName: 'Dress', - preparationTime: Duration(minutes: 15), - nextPreparationId: null, - ), - ], - ), - ); -} diff --git a/test/data/models/get_preparation_step_response_model_test.dart b/test/data/models/get_preparation_step_response_model_test.dart deleted file mode 100644 index d96e17d4..00000000 --- a/test/data/models/get_preparation_step_response_model_test.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/data/models/get_preparation_step_response_model.dart'; -import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; - -void main() { - test('maps preparation step JSON and entity durations in minutes', () { - final model = GetPreparationStepResponseModel.fromJson({ - 'preparationId': 'prep-1', - 'preparationName': 'Shower', - 'preparationTime': 12, - 'nextPreparationId': 'prep-2', - }); - - expect(model.id, 'prep-1'); - expect(model.toJson(), { - 'preparationId': 'prep-1', - 'preparationName': 'Shower', - 'preparationTime': 12, - 'nextPreparationId': 'prep-2', - }); - - final entity = model.toEntity(); - expect(entity.preparationTime, const Duration(minutes: 12)); - - final fromEntity = GetPreparationStepResponseModel.fromEntity( - const PreparationStepEntity( - id: 'prep-3', - preparationName: 'Pack bag', - preparationTime: Duration(minutes: 7), - nextPreparationId: null, - ), - ); - expect(fromEntity.id, 'prep-3'); - expect(fromEntity.preparationTime, 7); - expect(fromEntity.nextPreparationId, isNull); - }); - - group('PreparationResponseModelListExtension', () { - test('orders preparation steps by nextPreparationId chain', () { - final models = [ - GetPreparationStepResponseModel( - id: 'third', - preparationName: 'Put on shoes', - preparationTime: 3, - nextPreparationId: null, - ), - GetPreparationStepResponseModel( - id: 'first', - preparationName: 'Shower', - preparationTime: 10, - nextPreparationId: 'second', - ), - GetPreparationStepResponseModel( - id: 'second', - preparationName: 'Get dressed', - preparationTime: 5, - nextPreparationId: 'third', - ), - ]; - - final preparation = models.toPreparationEntity(); - - expect(preparation.preparationStepList.map((step) => step.id), [ - 'first', - 'second', - 'third', - ]); - }); - - test('keeps unlinked steps instead of dropping them', () { - final models = [ - GetPreparationStepResponseModel( - id: 'first', - preparationName: 'Shower', - preparationTime: 10, - nextPreparationId: 'second', - ), - GetPreparationStepResponseModel( - id: 'second', - preparationName: 'Get dressed', - preparationTime: 5, - nextPreparationId: null, - ), - GetPreparationStepResponseModel( - id: 'unlinked', - preparationName: 'Pack bag', - preparationTime: 2, - nextPreparationId: null, - ), - ]; - - final preparation = models.toPreparationEntity(); - - expect(preparation.preparationStepList.map((step) => step.id), [ - 'first', - 'second', - 'unlinked', - ]); - }); - }); -} diff --git a/test/data/models/get_schedule_response_model_test.dart b/test/data/models/get_schedule_response_model_test.dart deleted file mode 100644 index 0ed1f974..00000000 --- a/test/data/models/get_schedule_response_model_test.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/data/models/create_schedule_request_model.dart'; -import 'package:on_time_front/data/models/fcm_token_register_request_model.dart'; -import 'package:on_time_front/data/models/get_place_response_model.dart'; -import 'package:on_time_front/data/models/get_schedule_response_model.dart'; -import 'package:on_time_front/data/models/sign_in_with_apple_request_model.dart'; -import 'package:on_time_front/data/models/sign_in_with_google_request_model.dart'; -import 'package:on_time_front/data/models/update_schedule_request_model.dart'; -import 'package:on_time_front/domain/entities/place_entity.dart'; -import 'package:on_time_front/domain/entities/schedule_entity.dart'; - -void main() { - test('toEntity maps schedule response fields and durations', () { - final scheduleTime = DateTime(2026, 5, 15, 9, 30); - final model = GetScheduleResponseModel( - scheduleId: 'schedule-1', - place: const GetPlaceResponseModel( - placeId: 'place-1', - placeName: 'Office', - ), - scheduleName: 'Morning standup', - scheduleTime: scheduleTime, - moveTime: 20, - scheduleSpareTime: 5, - scheduleNote: 'Bring laptop', - latenessTime: 3, - doneStatus: 'LATE', - ); - - final entity = model.toEntity(); - - expect(entity.id, 'schedule-1'); - expect(entity.place.id, 'place-1'); - expect(entity.place.placeName, 'Office'); - expect(entity.scheduleName, 'Morning standup'); - expect(entity.scheduleTime, scheduleTime); - expect(entity.moveTime, const Duration(minutes: 20)); - expect(entity.scheduleSpareTime, const Duration(minutes: 5)); - expect(entity.scheduleNote, 'Bring laptop'); - expect(entity.latenessTime, 3); - expect(entity.doneStatus, ScheduleDoneStatus.lateEnd); - expect(entity.isChanged, isFalse); - expect(entity.isStarted, isFalse); - }); - - test( - 'toEntity maps server done status values and null lateness fallback', - () { - ScheduleDoneStatus statusFor(String? doneStatus) { - return GetScheduleResponseModel( - scheduleId: 'schedule-1', - place: const GetPlaceResponseModel( - placeId: 'place-1', - placeName: 'Office', - ), - scheduleName: 'Meeting', - scheduleTime: DateTime(2026, 5, 15), - moveTime: 10, - scheduleSpareTime: 0, - scheduleNote: '', - latenessTime: null, - doneStatus: doneStatus, - ).toEntity().doneStatus; - } - - expect(statusFor('NORMAL'), ScheduleDoneStatus.normalEnd); - expect(statusFor('ABNORMAL'), ScheduleDoneStatus.abnormalEnd); - expect(statusFor('NOT_ENDED'), ScheduleDoneStatus.notEnded); - expect(statusFor('unexpected'), ScheduleDoneStatus.notEnded); - expect(statusFor(null), ScheduleDoneStatus.notEnded); - }, - ); - - test('place response model maps entity and json representations', () { - const place = PlaceEntity(id: 'place-1', placeName: 'Office'); - - final model = GetPlaceResponseModel.fromEntity(place); - - expect(model.placeId, 'place-1'); - expect(model.placeName, 'Office'); - expect(model.toEntity(), place); - expect(model.toJson(), {'placeId': 'place-1', 'placeName': 'Office'}); - expect(GetPlaceResponseModel.fromJson(model.toJson()).toEntity(), place); - }); - - test('schedule request models trim backend constrained text fields', () { - final entity = ScheduleEntity( - id: 'schedule-1', - place: const PlaceEntity(id: 'place-1', placeName: 'Office'), - scheduleName: ' ${'Long schedule name ' * 3}', - scheduleTime: DateTime(2026, 5, 15, 9), - moveTime: const Duration(minutes: 20), - isChanged: true, - isStarted: false, - scheduleSpareTime: const Duration(minutes: 5), - scheduleNote: ' ${'note ' * 300}', - ); - - final create = CreateScheduleRequestModel.fromEntity(entity); - final update = UpdateScheduleRequestModel.fromEntity(entity); - - expect(create.scheduleId, 'schedule-1'); - expect(create.placeId, 'place-1'); - expect(create.moveTime, 20); - expect(create.isChange, isTrue); - expect(create.scheduleSpareTime, 5); - expect(create.scheduleName.length, 30); - expect(create.scheduleNote.length, 1000); - expect(update.scheduleName, create.scheduleName); - expect(update.scheduleNote, create.scheduleNote); - expect(update.toJson()['scheduleId'], 'schedule-1'); - }); - - test('auth and notification request models serialize backend payloads', () { - final google = SignInWithGoogleRequestModel( - idToken: 'google-id-token', - refreshToken: '', - ); - final appleWithoutEmail = SignInWithAppleRequestModel( - idToken: 'apple-id-token', - authCode: 'auth-code', - fullName: 'Apple User', - ); - final fcm = FcmTokenRegisterRequestModel( - firebaseToken: 'fcm-token', - deviceId: 'device-1', - ); - - expect(google.toJson(), {'idToken': 'google-id-token', 'refreshToken': ''}); - expect( - SignInWithGoogleRequestModel.fromJson(google.toJson()).idToken, - 'google-id-token', - ); - expect(appleWithoutEmail.toJson().containsKey('email'), isFalse); - expect( - SignInWithAppleRequestModel.fromJson({ - ...appleWithoutEmail.toJson(), - 'email': 'apple@example.com', - }).email, - 'apple@example.com', - ); - expect(fcm.toJson(), { - 'firebaseToken': 'fcm-token', - 'deviceId': 'device-1', - }); - expect( - FcmTokenRegisterRequestModel.fromJson(fcm.toJson()).deviceId, - 'device-1', - ); - }); -} diff --git a/test/data/models/preparation_create_models_test.dart b/test/data/models/preparation_create_models_test.dart deleted file mode 100644 index 5b1c180c..00000000 --- a/test/data/models/preparation_create_models_test.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/data/models/create_defualt_preparation_request_model.dart'; -import 'package:on_time_front/data/models/create_preparation_schedule_request_model.dart'; -import 'package:on_time_front/data/models/create_preparation_step_request_model.dart'; -import 'package:on_time_front/domain/entities/preparation_entity.dart'; -import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; - -void main() { - const step = PreparationStepEntity( - id: 'step-1', - preparationName: 'Pack bag', - preparationTime: Duration(minutes: 7), - nextPreparationId: 'step-2', - ); - - test('schedule create model serializes and restores preparation steps', () { - final model = PreparationScheduleCreateRequestModel.fromEntity(step); - - expect(model.toJson(), { - 'preparationId': 'step-1', - 'preparationName': 'Pack bag', - 'preparationTime': 7, - 'nextPreparationId': 'step-2', - }); - expect( - PreparationScheduleCreateRequestModel.fromJson(model.toJson()).toEntity(), - step, - ); - }); - - test('schedule create list extension maps ordered steps', () { - final models = - PreparationScheduleCreateRequestModelListExtension.fromEntityList([ - step, - const PreparationStepEntity( - id: 'step-2', - preparationName: 'Shoes', - preparationTime: Duration(minutes: 3), - ), - ]); - - expect(models.map((model) => model.id), ['step-1', 'step-2']); - expect(models.toEntityList().map((entity) => entity.nextPreparationId), [ - 'step-2', - null, - ]); - }); - - test( - 'default preparation create model serializes backend request fields', - () { - final model = CreatePreparationStepRequestModel.fromEntity(step); - - expect(model.toJson(), { - 'preparationId': 'step-1', - 'preparationName': 'Pack bag', - 'preparationTime': 7, - 'nextPreparationId': 'step-2', - }); - expect( - CreatePreparationStepRequestModel.fromJson(model.toJson()).toEntity(), - step, - ); - }, - ); - - test('default preparation request maps spare time note and step list', () { - const preparation = PreparationEntity(preparationStepList: [step]); - - final model = CreateDefaultPreparationRequestModel.fromEntity( - preparationEntity: preparation, - spareTime: const Duration(minutes: 12), - note: 'Bring umbrella', - ); - - expect(model.spareTime, 12); - expect(model.note, 'Bring umbrella'); - expect(model.preparationList.single.id, 'step-1'); - expect(model.toJson()['spareTime'], 12); - }); -} diff --git a/test/data/models/preparation_template_model_test.dart b/test/data/models/preparation_template_model_test.dart deleted file mode 100644 index 3b05d723..00000000 --- a/test/data/models/preparation_template_model_test.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/data/models/ordered_preparation_step_model.dart'; -import 'package:on_time_front/data/models/preparation_template_model.dart'; -import 'package:on_time_front/domain/entities/preparation_entity.dart'; -import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; - -void main() { - test('ordered preparation steps serialize zero-based orderIndex', () { - final preparation = PreparationEntity( - preparationStepList: [ - const PreparationStepEntity( - id: 'prep-1', - preparationName: 'Pack laptop', - preparationTime: Duration(minutes: 5), - nextPreparationId: 'prep-2', - ), - const PreparationStepEntity( - id: 'prep-2', - preparationName: 'Shower', - preparationTime: Duration(minutes: 15), - ), - ], - ); - - final json = OrderedPreparationStepModel.fromPreparationEntity( - preparation, - ).map((step) => step.toJson()).toList(); - - expect(json, [ - { - 'preparationId': 'prep-1', - 'preparationName': 'Pack laptop', - 'preparationTime': 5, - 'orderIndex': 0, - }, - { - 'preparationId': 'prep-2', - 'preparationName': 'Shower', - 'preparationTime': 15, - 'orderIndex': 1, - }, - ]); - }); - - test( - 'template response maps ordered steps back to linked preparation entity', - () { - final entity = PreparationTemplateModel.fromJson({ - 'templateId': 'template-1', - 'templateName': 'Work', - 'createdAt': '2026-05-14T02:10:00Z', - 'updatedAt': '2026-05-14T02:11:00Z', - 'deletedAt': null, - 'preparations': [ - { - 'preparationId': 'prep-2', - 'preparationName': 'Shower', - 'preparationTime': 15, - 'orderIndex': 1, - }, - { - 'preparationId': 'prep-1', - 'preparationName': 'Pack laptop', - 'preparationTime': 5, - 'orderIndex': 0, - }, - ], - }).toEntity(); - - expect(entity.id, 'template-1'); - expect(entity.name, 'Work'); - expect(entity.isDeleted, isFalse); - expect(entity.preparation.preparationStepList.first.id, 'prep-1'); - expect( - entity.preparation.preparationStepList.first.nextPreparationId, - 'prep-2', - ); - expect( - entity.preparation.preparationStepList.last.nextPreparationId, - isNull, - ); - }, - ); - - test('template upsert request serializes full replacement payload', () { - final request = UpsertPreparationTemplateRequestModel.fromValues( - templateId: 'template-1', - templateName: 'Work', - preparation: const PreparationEntity( - preparationStepList: [ - PreparationStepEntity( - id: 'prep-1', - preparationName: 'Pack laptop', - preparationTime: Duration(minutes: 5), - ), - ], - ), - ); - - expect(request.toJson(), { - 'templateId': 'template-1', - 'templateName': 'Work', - 'preparations': [ - { - 'preparationId': 'prep-1', - 'preparationName': 'Pack laptop', - 'preparationTime': 5, - 'orderIndex': 0, - }, - ], - }); - }); -} diff --git a/test/data/models/preparation_update_models_test.dart b/test/data/models/preparation_update_models_test.dart deleted file mode 100644 index 0d428dfe..00000000 --- a/test/data/models/preparation_update_models_test.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/data/models/update_preparation_schedule_request_model.dart'; -import 'package:on_time_front/data/models/update_preparation_user_request_model.dart'; -import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; - -void main() { - const step = PreparationStepEntity( - id: 'step-1', - preparationName: 'Shower', - preparationTime: Duration(minutes: 15), - nextPreparationId: 'step-2', - ); - - test('schedule modify model round-trips preparation steps', () { - final model = PreparationScheduleModifyRequestModel.fromEntity(step); - - expect(model.id, 'step-1'); - expect(model.preparationName, 'Shower'); - expect(model.preparationTime, 15); - expect(model.nextPreparationId, 'step-2'); - expect(model.toJson(), { - 'preparationId': 'step-1', - 'preparationName': 'Shower', - 'preparationTime': 15, - 'nextPreparationId': 'step-2', - }); - expect(model.toEntity(), step); - }); - - test('schedule modify list extension maps every step', () { - final models = - PreparationScheduleModifyRequestModelListExtension.fromEntityList([ - step, - step.copyWith(id: 'step-2', nextPreparationId: null), - ]); - - expect(models.map((model) => model.id), ['step-1', 'step-2']); - expect(models.toEntityList().map((entity) => entity.id), [ - 'step-1', - 'step-2', - ]); - }); - - test('user modify model round-trips preparation steps', () { - final model = PreparationUserModifyRequestModel.fromEntity(step); - - expect(model.id, 'step-1'); - expect(model.preparationName, 'Shower'); - expect(model.preparationTime, 15); - expect(model.nextPreparationId, 'step-2'); - expect(model.toJson(), { - 'preparationId': 'step-1', - 'preparationName': 'Shower', - 'preparationTime': 15, - 'nextPreparationId': 'step-2', - }); - expect(model.toEntity(), step); - }); - - test('user modify list extension maps every step', () { - final models = - PreparationUserModifyRequestModelListExtension.fromEntityList([ - step, - step.copyWith(id: 'step-2', nextPreparationId: null), - ]); - - expect(models.map((model) => model.id), ['step-1', 'step-2']); - expect(models.toEntityList().map((entity) => entity.id), [ - 'step-1', - 'step-2', - ]); - }); -} diff --git a/test/data/models/schedule_preparation_contract_test.dart b/test/data/models/schedule_preparation_contract_test.dart deleted file mode 100644 index dabba373..00000000 --- a/test/data/models/schedule_preparation_contract_test.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/data/models/create_schedule_request_model.dart'; -import 'package:on_time_front/data/models/get_schedule_response_model.dart'; -import 'package:on_time_front/data/models/update_schedule_request_model.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/entities/schedule_preparation_mode.dart'; - -void main() { - final schedule = ScheduleEntity( - id: 'schedule-1', - place: const PlaceEntity(id: 'place-1', placeName: 'Office'), - scheduleName: 'Morning meeting', - scheduleTime: DateTime(2026, 6, 1, 9, 30), - moveTime: const Duration(minutes: 20), - isChanged: false, - isStarted: false, - scheduleSpareTime: const Duration(minutes: 10), - scheduleNote: 'Bring laptop', - ); - - test('create schedule omits preparation fields for default source', () { - final json = CreateScheduleRequestModel.fromEntity(schedule).toJson(); - - expect(json.containsKey('preparationTemplateId'), isFalse); - expect(json.containsKey('customPreparations'), isFalse); - }); - - test('create schedule serializes template source from template id', () { - final json = CreateScheduleRequestModel.fromEntity( - schedule.copyWith( - preparationMode: SchedulePreparationMode.template, - preparationTemplateId: 'template-1', - ), - ).toJson(); - - expect(json['preparationTemplateId'], 'template-1'); - expect(json.containsKey('customPreparations'), isFalse); - }); - - test('create schedule serializes custom ordered preparations', () { - final json = CreateScheduleRequestModel.fromEntity( - schedule.copyWith( - preparationMode: SchedulePreparationMode.custom, - customPreparations: const PreparationEntity( - preparationStepList: [ - PreparationStepEntity( - id: 'prep-1', - preparationName: 'Pack laptop', - preparationTime: Duration(minutes: 5), - ), - ], - ), - ), - ).toJson(); - - expect(json.containsKey('preparationTemplateId'), isFalse); - expect(json['customPreparations'], [ - { - 'preparationId': 'prep-1', - 'preparationName': 'Pack laptop', - 'preparationTime': 5, - 'orderIndex': 0, - }, - ]); - }); - - test('update schedule preserves preparation source by default', () { - final json = UpdateScheduleRequestModel.fromEntity( - schedule.copyWith( - preparationMode: SchedulePreparationMode.template, - preparationTemplateId: 'template-1', - ), - ).toJson(); - - expect(json.containsKey('preparationMode'), isFalse); - expect(json.containsKey('preparationTemplateId'), isFalse); - expect(json.containsKey('customPreparations'), isFalse); - }); - - test('update schedule includes preparation source when requested', () { - final json = UpdateScheduleRequestModel.fromEntity( - schedule.copyWith( - preparationMode: SchedulePreparationMode.template, - preparationTemplateId: 'template-1', - ), - includePreparationSource: true, - ).toJson(); - - expect(json['preparationMode'], 'TEMPLATE'); - expect(json['preparationTemplateId'], 'template-1'); - expect(json.containsKey('customPreparations'), isFalse); - }); - - test('schedule response parses preparation metadata and frozen flag', () { - final entity = GetScheduleResponseModel.fromJson({ - 'scheduleId': 'schedule-1', - 'placeId': 'place-1', - 'placeName': 'Office', - 'scheduleName': 'Morning meeting', - 'scheduleTime': '2026-06-01T09:30:00', - 'moveTime': 20, - 'scheduleSpareTime': 10, - 'scheduleNote': '', - 'startedAt': '2026-06-01T08:30:00Z', - 'preparationMode': 'TEMPLATE', - 'preparationTemplateId': 'template-1', - 'preparationTemplateName': 'Work', - 'preparationTemplateDeleted': true, - }).toEntity(); - - expect(entity.place.placeName, 'Office'); - expect(entity.preparationMode, SchedulePreparationMode.template); - expect(entity.preparationTemplateId, 'template-1'); - expect(entity.preparationTemplateName, 'Work'); - expect(entity.preparationTemplateDeleted, isTrue); - expect(entity.preparationFrozen, isTrue); - expect(entity.isStarted, isTrue); - }); -} diff --git a/test/data/repositories/alarm_repository_impl_test.dart b/test/data/repositories/alarm_repository_impl_test.dart deleted file mode 100644 index ad693b2e..00000000 --- a/test/data/repositories/alarm_repository_impl_test.dart +++ /dev/null @@ -1,197 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; -import 'package:on_time_front/core/services/app_metadata_service.dart'; -import 'package:on_time_front/data/data_sources/alarm_remote_data_source.dart'; -import 'package:on_time_front/data/repositories/alarm_repository_impl.dart'; -import 'package:on_time_front/domain/entities/alarm_entities.dart'; -import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -void main() { - late _FakeAlarmRemoteDataSource remoteDataSource; - late _FakeAlarmSchedulerService schedulerService; - late _FakeAppMetadataProvider appMetadataProvider; - late AlarmRepositoryImpl repository; - - setUp(() { - SharedPreferences.setMockInitialValues({}); - remoteDataSource = _FakeAlarmRemoteDataSource(); - schedulerService = _FakeAlarmSchedulerService(); - appMetadataProvider = _FakeAppMetadataProvider( - const AppMetadata(version: '9.8.7', buildNumber: '654'), - ); - repository = AlarmRepositoryImpl( - remoteDataSource: remoteDataSource, - schedulerService: schedulerService, - appMetadataProvider: appMetadataProvider, - ); - }); - - test('getDeviceId reuses a valid stored device id', () async { - SharedPreferences.setMockInitialValues({ - 'alarm_device_id': '123e4567-e89b-12d3-a456-426614174000', - }); - - expect( - await repository.getDeviceId(), - '123e4567-e89b-12d3-a456-426614174000', - ); - }); - - test( - 'getDeviceId replaces an invalid stored device id with a UUID', - () async { - SharedPreferences.setMockInitialValues({'alarm_device_id': 'bad'}); - - final deviceId = await repository.getDeviceId(); - - expect(deviceId, isNot('bad')); - expect(deviceId, matches(RegExp(r'^[0-9a-f-]{36}$'))); - final prefs = await SharedPreferences.getInstance(); - expect(prefs.getString('alarm_device_id'), deviceId); - }, - ); - - test( - 'buildCurrentDeviceInfo combines persisted id, scheduler capabilities, and runtime app version', - () async { - SharedPreferences.setMockInitialValues({ - 'alarm_device_id': '123e4567-e89b-12d3-a456-426614174000', - }); - schedulerService.capabilities = const AlarmSchedulerCapabilities( - supportsNativeAlarm: true, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, - fallbackProvider: AlarmProvider.localNotification, - ); - - final info = await repository.buildCurrentDeviceInfo(); - - expect(info.deviceId, '123e4567-e89b-12d3-a456-426614174000'); - expect(info.appVersion, '9.8.7'); - expect(info.supportsNativeAlarm, isTrue); - expect(info.nativeAlarmProvider, AlarmProvider.androidAlarmManager); - expect(info.fallbackProvider, AlarmProvider.localNotification); - expect(info.platform, isNotEmpty); - expect(info.osVersion, isNotEmpty); - }, - ); - - test('alarm settings calls delegate to the remote data source', () async { - remoteDataSource.settings = const AlarmSettings( - alarmsEnabled: false, - defaultAlarmOffsetMinutes: 10, - ); - - expect(await repository.getAlarmSettings(), remoteDataSource.settings); - expect( - await repository.updateAlarmSettings(alarmsEnabled: true), - const AlarmSettings(alarmsEnabled: true), - ); - expect(remoteDataSource.updatedValues, [true]); - }); - - test('device, window, and status calls forward their payloads', () async { - const deviceInfo = AlarmDeviceInfo( - deviceId: 'device-1', - platform: 'android', - appVersion: '9.8.7', - osVersion: 'android', - supportsNativeAlarm: true, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, - fallbackProvider: AlarmProvider.localNotification, - ); - final start = DateTime(2026, 5, 15); - final end = DateTime(2026, 5, 16); - final report = _alarmStatusReport(); - - await repository.registerCurrentDevice(deviceInfo); - await repository.unregisterCurrentDevice('device-1'); - expect(await repository.getAlarmWindow(start, end), isEmpty); - await repository.postAlarmStatus(report); - - expect(remoteDataSource.registeredDevices, [deviceInfo]); - expect(remoteDataSource.unregisteredDeviceIds, ['device-1']); - expect(remoteDataSource.alarmWindowRanges, [(start, end)]); - expect(remoteDataSource.statusReports, [report]); - }); -} - -AlarmStatusReport _alarmStatusReport() { - final now = DateTime(2026, 5, 15, 9); - return AlarmStatusReport( - deviceId: 'device-1', - reconciledAt: now, - scheduleWindowStart: now, - scheduleWindowEnd: now.add(const Duration(days: 1)), - alarmCoverageStart: now, - alarmCoverageEnd: now.add(const Duration(hours: 1)), - status: AlarmReconciliationStatus.armed, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, - fallbackProvider: AlarmProvider.localNotification, - armedScheduleCount: 1, - armedScheduleIds: const ['schedule-1'], - skippedScheduleCount: 0, - failures: const [], - ); -} - -class _FakeAlarmSchedulerService extends AlarmSchedulerService { - AlarmSchedulerCapabilities capabilities = - AlarmSchedulerCapabilities.unsupported; - - @override - Future getCapabilities() async => capabilities; -} - -class _FakeAppMetadataProvider implements AppMetadataProvider { - const _FakeAppMetadataProvider(this.metadata); - - final AppMetadata metadata; - - @override - Future getMetadata() async => metadata; -} - -class _FakeAlarmRemoteDataSource implements AlarmRemoteDataSource { - AlarmSettings settings = const AlarmSettings(alarmsEnabled: true); - final updatedValues = []; - final registeredDevices = []; - final unregisteredDeviceIds = []; - final alarmWindowRanges = <(DateTime, DateTime)>[]; - final statusReports = []; - - @override - Future getAlarmSettings() async => settings; - - @override - Future updateAlarmSettings({ - required bool alarmsEnabled, - }) async { - updatedValues.add(alarmsEnabled); - return AlarmSettings(alarmsEnabled: alarmsEnabled); - } - - @override - Future registerCurrentDevice(AlarmDeviceInfo deviceInfo) async { - registeredDevices.add(deviceInfo); - } - - @override - Future unregisterCurrentDevice(String deviceId) async { - unregisteredDeviceIds.add(deviceId); - } - - @override - Future> getAlarmWindow( - DateTime startDate, - DateTime endDate, - ) async { - alarmWindowRanges.add((startDate, endDate)); - return const []; - } - - @override - Future postAlarmStatus(AlarmStatusReport report) async { - statusReports.add(report); - } -} diff --git a/test/data/repositories/analytics_preference_repository_impl_test.dart b/test/data/repositories/analytics_preference_repository_impl_test.dart deleted file mode 100644 index 7581f811..00000000 --- a/test/data/repositories/analytics_preference_repository_impl_test.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/data/data_sources/analytics_preference_local_data_source.dart'; -import 'package:on_time_front/data/data_sources/analytics_preference_remote_data_source.dart'; -import 'package:on_time_front/data/repositories/analytics_preference_repository_impl.dart'; -import 'package:on_time_front/domain/entities/analytics_preference.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -void main() { - late _FakeAnalyticsPreferenceRemoteDataSource remoteDataSource; - late AnalyticsPreferenceRepositoryImpl repository; - - setUp(() { - SharedPreferences.setMockInitialValues({}); - remoteDataSource = _FakeAnalyticsPreferenceRemoteDataSource(); - repository = AnalyticsPreferenceRepositoryImpl( - localDataSource: AnalyticsPreferenceLocalDataSourceImpl(), - remoteDataSource: remoteDataSource, - ); - }); - - test('local analytics preference defaults disabled until explicitly changed', () async { - expect((await repository.loadLocalPreference()).enabled, isFalse); - - await repository.saveLocalPreference(true); - - expect((await repository.loadLocalPreference()).enabled, isTrue); - }); - - test('account analytics preference calls delegate to the remote data source', () async { - remoteDataSource.preference = AnalyticsPreference( - enabled: true, - updatedAt: DateTime.utc(2026, 5, 26, 12), - ); - - expect(await repository.loadAccountPreference(), remoteDataSource.preference); - expect( - await repository.updateAccountPreference(false), - const AnalyticsPreference(enabled: false), - ); - expect(remoteDataSource.updatedValues, [false]); - }); -} - -class _FakeAnalyticsPreferenceRemoteDataSource - implements AnalyticsPreferenceRemoteDataSource { - AnalyticsPreference preference = const AnalyticsPreference(enabled: false); - final updatedValues = []; - - @override - Future getAnalyticsPreference() async => preference; - - @override - Future updateAnalyticsPreference({ - required bool enabled, - }) async { - updatedValues.add(enabled); - preference = AnalyticsPreference(enabled: enabled); - return preference; - } -} diff --git a/test/data/repositories/local_schedule_score_test.dart b/test/data/repositories/local_schedule_score_test.dart new file mode 100644 index 00000000..219b4801 --- /dev/null +++ b/test/data/repositories/local_schedule_score_test.dart @@ -0,0 +1,80 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/repositories/schedule_repository_impl.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/domain/entities/timed_preparation_snapshot_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/repositories/timed_preparation_repository.dart'; + +void main() { + late AppDatabase database; + late ScheduleRepositoryImpl repository; + + setUp(() async { + database = AppDatabase.forTesting(NativeDatabase.memory()); + repository = ScheduleRepositoryImpl( + database: database, + timedPreparationRepository: _NoopTimedPreparationRepository(), + ); + await database.userDao.putUser( + const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: '', + isOnboardingCompleted: true, + ), + ); + }); + + tearDown(() async { + await repository.dispose(); + await database.close(); + }); + + test('normal and late finishes contribute once while deletion preserves aggregate', () async { + await repository.createSchedule(_schedule('on-time')); + await repository.createSchedule(_schedule('late')); + + await repository.finishSchedule('on-time', 0); + await repository.finishSchedule('on-time', 0); + await repository.finishSchedule('late', 4); + await repository.deleteSchedule(await repository.getScheduleById('on-time')); + + final user = (await database.userDao.getUserById('local-profile'))!; + expect(user.eligibleOutcomeCount, 2); + expect(user.onTimeOutcomeCount, 1); + expect(user.scoreOrNull, 50); + }); +} + +ScheduleEntity _schedule(String id) => ScheduleEntity( + id: id, + place: const PlaceEntity(id: 'place', placeName: 'Office'), + scheduleName: id, + timeZoneId: 'Asia/Seoul', + occurrenceOffsetSeconds: 9 * 60 * 60, + scheduleTime: DateTime(2026, 9, 1, 9), + moveTime: const Duration(minutes: 10), + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', +); + +class _NoopTimedPreparationRepository implements TimedPreparationRepository { + @override + Future clearTimedPreparation(String scheduleId) async {} + + @override + Future getTimedPreparationSnapshot( + String scheduleId, + ) async => null; + + @override + Future saveTimedPreparationSnapshot( + String scheduleId, + TimedPreparationSnapshotEntity snapshot, + ) async {} +} diff --git a/test/data/repositories/preparation_repository_impl_test.dart b/test/data/repositories/preparation_repository_impl_test.dart deleted file mode 100644 index 491e51dd..00000000 --- a/test/data/repositories/preparation_repository_impl_test.dart +++ /dev/null @@ -1,268 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/data/data_sources/preparation_local_data_source.dart'; -import 'package:on_time_front/data/data_sources/preparation_remote_data_source.dart'; -import 'package:on_time_front/data/models/create_defualt_preparation_request_model.dart'; -import 'package:on_time_front/data/repositories/preparation_repository_impl.dart'; -import 'package:on_time_front/domain/entities/preparation_entity.dart'; -import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; -import 'package:uuid/uuid.dart'; - -void main() { - group('stream-backed behavior', () { - late _FakePreparationRemoteDataSource remoteDataSource; - late PreparationRepositoryImpl repository; - - setUp(() { - remoteDataSource = _FakePreparationRemoteDataSource(); - repository = PreparationRepositoryImpl( - preparationRemoteDataSource: remoteDataSource, - preparationLocalDataSource: _FakePreparationLocalDataSource(), - ); - }); - - test( - 'preparation stream starts empty and updates after custom create', - () async { - expect(await repository.preparationStream.first, isEmpty); - - await repository.createCustomPreparation( - _preparation('step-1'), - 'schedule-1', - ); - - expect(remoteDataSource.createdCustomSchedules, ['schedule-1']); - expect(await repository.preparationStream.first, { - 'schedule-1': _preparation('step-1'), - }); - }, - ); - - test( - 'remote schedule preparation load publishes the fetched preparation', - () async { - remoteDataSource.preparationsByScheduleId['schedule-1'] = _preparation( - 'remote-step', - ); - - await repository.getPreparationByScheduleId('schedule-1'); - - expect(await repository.preparationStream.first, { - 'schedule-1': _preparation('remote-step'), - }); - }, - ); - - test( - 'schedule preparation update publishes the edited preparation', - () async { - await repository.updatePreparationByScheduleId( - _preparation('updated-step'), - 'schedule-1', - ); - - expect(remoteDataSource.updatedScheduleIds, ['schedule-1']); - expect(await repository.preparationStream.first, { - 'schedule-1': _preparation('updated-step'), - }); - }, - ); - - test( - 'default preparation and spare time calls delegate to remote source', - () async { - remoteDataSource.defaultPreparation = _preparation('default-step'); - - await repository.createDefaultPreparation( - preparationEntity: _preparation('default-step'), - spareTime: const Duration(minutes: 5), - note: 'note', - ); - final defaultPreparation = await repository.getDefualtPreparation(); - await repository.updateDefaultPreparation(_preparation('default-step')); - await repository.updateSpareTime(const Duration(minutes: 15)); - - expect(defaultPreparation, _preparation('default-step')); - expect(remoteDataSource.createdDefaultModels, hasLength(1)); - expect(remoteDataSource.updatedDefaultPreparations, [ - _preparation('default-step'), - ]); - expect(remoteDataSource.updatedSpareTimes, [ - const Duration(minutes: 15), - ]); - }, - ); - - test( - 'remote failures are surfaced to callers without stream mutation', - () async { - remoteDataSource.throwOnNext = true; - - await expectLater( - repository.createCustomPreparation( - _preparation('step-1'), - 'schedule-1', - ), - throwsException, - ); - - expect(await repository.preparationStream.first, isEmpty); - }, - ); - }); - - group('updateDefaultPreparation persistence checks', () { - late PreparationRepositoryImpl preparationRepository; - late _FakePreparationRemoteDataSource remoteDataSource; - - final uuid = Uuid(); - - final tPreparationStep = PreparationStepEntity( - id: uuid.v7(), - preparationName: 'Dress Up', - preparationTime: const Duration(minutes: 10), - nextPreparationId: null, - ); - - final tPreparationEntity = PreparationEntity( - preparationStepList: [tPreparationStep], - ); - - setUp(() { - remoteDataSource = _FakePreparationRemoteDataSource(); - preparationRepository = PreparationRepositoryImpl( - preparationRemoteDataSource: remoteDataSource, - preparationLocalDataSource: _FakePreparationLocalDataSource(), - ); - }); - - test('calls update and reloads persisted default preparation', () async { - await preparationRepository.updateDefaultPreparation(tPreparationEntity); - - expect(remoteDataSource.updatedDefaultPreparations, [tPreparationEntity]); - expect(remoteDataSource.getDefaultCallCount, 1); - }); - - test('throws when backend does not persist updated preparation', () async { - final persistedPreparation = PreparationEntity( - preparationStepList: [ - tPreparationStep.copyWith( - preparationTime: const Duration(minutes: 5), - ), - ], - ); - remoteDataSource.persistUpdatedDefault = false; - remoteDataSource.defaultPreparation = persistedPreparation; - - final call = preparationRepository.updateDefaultPreparation( - tPreparationEntity, - ); - - expect(call, throwsA(isA())); - }); - - test('throws an exception if remote data source fails', () async { - remoteDataSource.throwOnNext = true; - - final call = preparationRepository.updateDefaultPreparation( - tPreparationEntity, - ); - - expect(call, throwsException); - }); - }); -} - -PreparationEntity _preparation(String stepId) { - return PreparationEntity( - preparationStepList: [ - PreparationStepEntity( - id: stepId, - preparationName: stepId, - preparationTime: const Duration(minutes: 10), - ), - ], - ); -} - -class _FakePreparationRemoteDataSource implements PreparationRemoteDataSource { - final createdDefaultModels = []; - final createdCustomSchedules = []; - final updatedDefaultPreparations = []; - final updatedScheduleIds = []; - final updatedSpareTimes = []; - final preparationsByScheduleId = {}; - PreparationEntity defaultPreparation = _preparation('default'); - int getDefaultCallCount = 0; - bool persistUpdatedDefault = true; - bool throwOnNext = false; - - void _maybeThrow() { - if (throwOnNext) { - throwOnNext = false; - throw Exception('remote failed'); - } - } - - @override - Future createDefaultPreparation( - CreateDefaultPreparationRequestModel model, - ) async { - _maybeThrow(); - createdDefaultModels.add(model); - } - - @override - Future createCustomPreparation( - PreparationEntity preparationEntity, - String scheduleId, - ) async { - _maybeThrow(); - createdCustomSchedules.add(scheduleId); - } - - @override - Future getPreparationByScheduleId( - String scheduleId, - ) async { - _maybeThrow(); - return preparationsByScheduleId[scheduleId] ?? _preparation('missing'); - } - - @override - Future getDefualtPreparation() async { - _maybeThrow(); - getDefaultCallCount++; - return defaultPreparation; - } - - @override - Future updateDefaultPreparation( - PreparationEntity preparationEntity, - ) async { - _maybeThrow(); - updatedDefaultPreparations.add(preparationEntity); - if (persistUpdatedDefault) { - defaultPreparation = preparationEntity; - } - } - - @override - Future updatePreparationByScheduleId( - PreparationEntity preparationEntity, - String scheduleId, - ) async { - _maybeThrow(); - updatedScheduleIds.add(scheduleId); - } - - @override - Future updateSpareTime(Duration newSpareTime) async { - _maybeThrow(); - updatedSpareTimes.add(newSpareTime); - } -} - -class _FakePreparationLocalDataSource implements PreparationLocalDataSource { - @override - noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} diff --git a/test/data/repositories/schedule_repository_impl_stream_test.dart b/test/data/repositories/schedule_repository_impl_stream_test.dart deleted file mode 100644 index 7a7b87fc..00000000 --- a/test/data/repositories/schedule_repository_impl_stream_test.dart +++ /dev/null @@ -1,418 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/data/data_sources/schedule_remote_data_source.dart'; -import 'package:on_time_front/data/models/create_schedule_request_model.dart'; -import 'package:on_time_front/data/models/get_place_response_model.dart'; -import 'package:on_time_front/data/models/get_schedule_response_model.dart'; -import 'package:on_time_front/data/models/update_schedule_request_model.dart'; -import 'package:on_time_front/data/repositories/schedule_repository_impl.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/domain/entities/timed_preparation_snapshot_entity.dart'; -import 'package:on_time_front/domain/repositories/timed_preparation_repository.dart'; - -class FakeScheduleRemoteDataSource implements ScheduleRemoteDataSource { - FakeScheduleRemoteDataSource({ - required this.getSchedulesByDateHandler, - Future Function(String id)? getScheduleByIdHandler, - Future Function(UpdateScheduleRequestModel schedule)? - updateScheduleHandler, - }) : getScheduleByIdHandler = - getScheduleByIdHandler ?? ((_) async => throw UnimplementedError()), - updateScheduleHandler = updateScheduleHandler ?? ((_) async {}); - - Future> Function(DateTime startDate, DateTime? endDate) - getSchedulesByDateHandler; - Future Function(String id) getScheduleByIdHandler; - Future Function(UpdateScheduleRequestModel schedule) - updateScheduleHandler; - - @override - Future createSchedule(CreateScheduleRequestModel schedule) async {} - - @override - Future deleteSchedule(String scheduleId) async {} - - @override - Future finishSchedule(String scheduleId, int latenessTime) async {} - - @override - Future startSchedule(String scheduleId) async {} - - @override - Future getScheduleById(String id) async { - return _responseFrom(await getScheduleByIdHandler(id)); - } - - @override - Future> getSchedulesByDate( - DateTime startDate, - DateTime? endDate, - ) async { - final schedules = await getSchedulesByDateHandler(startDate, endDate); - return schedules.map(_responseFrom).toList(); - } - - @override - Future updateSchedule(UpdateScheduleRequestModel schedule) { - return updateScheduleHandler(schedule); - } -} - -class FakeTimedPreparationRepository implements TimedPreparationRepository { - @override - Future clearTimedPreparation(String scheduleId) async {} - - @override - Future getTimedPreparationSnapshot( - String scheduleId, - ) async { - return null; - } - - @override - Future saveTimedPreparationSnapshot( - String scheduleId, - TimedPreparationSnapshotEntity snapshot, - ) async {} -} - -void main() { - test('createSchedule publishes created schedule to stream cache', () async { - final schedule = _schedule( - id: 'created', - scheduleTime: DateTime(2026, 3, 20, 9), - ); - final repository = ScheduleRepositoryImpl( - scheduleRemoteDataSource: FakeScheduleRemoteDataSource( - getSchedulesByDateHandler: (_, __) async => const [], - ), - timedPreparationRepository: FakeTimedPreparationRepository(), - ); - - await repository.createSchedule(schedule); - - final latest = await repository.scheduleStream.firstWhere( - (schedules) => schedules.any((schedule) => schedule.id == 'created'), - ); - - expect(latest.single, schedule); - }); - - test('deleteSchedule removes deleted schedule from stream cache', () async { - final schedule = _schedule( - id: 'deleted', - scheduleTime: DateTime(2026, 3, 20, 9), - ); - final repository = ScheduleRepositoryImpl( - scheduleRemoteDataSource: FakeScheduleRemoteDataSource( - getSchedulesByDateHandler: (_, __) async => const [], - ), - timedPreparationRepository: FakeTimedPreparationRepository(), - ); - final events = >[]; - final subscription = repository.scheduleStream.listen(events.add); - addTearDown(subscription.cancel); - - await repository.createSchedule(schedule); - await repository.deleteSchedule(schedule); - await pumpEventQueue(); - - expect(events.map((event) => event.map((s) => s.id).toList()), [ - [], - ['deleted'], - [], - ]); - }); - - test('getScheduleById publishes fetched schedule to stream cache', () async { - final schedule = _schedule( - id: 'fetched', - scheduleTime: DateTime(2026, 3, 20, 9), - ); - final repository = ScheduleRepositoryImpl( - scheduleRemoteDataSource: FakeScheduleRemoteDataSource( - getSchedulesByDateHandler: (_, __) async => const [], - getScheduleByIdHandler: (_) async => schedule, - ), - timedPreparationRepository: FakeTimedPreparationRepository(), - ); - - final result = await repository.getScheduleById(schedule.id); - final latest = await repository.scheduleStream.firstWhere( - (schedules) => schedules.any((schedule) => schedule.id == 'fetched'), - ); - - expect(result, schedule); - expect(latest.single, schedule); - }); - - test( - 'watchSchedulesByDate emits inclusive-start exclusive-end schedules sorted by time', - () async { - final startDate = DateTime(2026, 3, 1); - final endDate = DateTime(2026, 4, 1); - final insideLater = _schedule( - id: 'inside-later', - scheduleTime: DateTime(2026, 3, 20, 13), - ); - final insideStart = _schedule( - id: 'inside-start', - scheduleTime: startDate, - ); - final before = _schedule( - id: 'before', - scheduleTime: DateTime(2026, 2, 28, 23, 59), - ); - final exclusiveEnd = _schedule( - id: 'exclusive-end', - scheduleTime: endDate, - ); - - final repository = ScheduleRepositoryImpl( - scheduleRemoteDataSource: FakeScheduleRemoteDataSource( - getSchedulesByDateHandler: (_, __) async => [ - insideLater, - before, - exclusiveEnd, - insideStart, - ], - ), - timedPreparationRepository: FakeTimedPreparationRepository(), - ); - - final rangeStream = repository.watchSchedulesByDate(startDate, endDate); - await repository.getSchedulesByDate(startDate, endDate); - - final schedules = await rangeStream.firstWhere( - (schedules) => schedules.length == 2, - ); - - expect(schedules.map((schedule) => schedule.id), [ - 'inside-start', - 'inside-later', - ]); - }, - ); - - test( - 'active date range watches update only when their visible schedules change', - () async { - final marchStart = DateTime(2026, 3, 1); - final marchEnd = DateTime(2026, 4, 1); - final aprilStart = DateTime(2026, 4, 1); - final aprilEnd = DateTime(2026, 5, 1); - final marchSchedule = _schedule( - id: 'march', - scheduleTime: DateTime(2026, 3, 20, 9), - ); - final aprilSchedule = _schedule( - id: 'april', - scheduleTime: DateTime(2026, 4, 10, 9), - ); - - final repository = ScheduleRepositoryImpl( - scheduleRemoteDataSource: FakeScheduleRemoteDataSource( - getSchedulesByDateHandler: (startDate, _) async { - if (startDate == marchStart) { - return [marchSchedule]; - } - return [aprilSchedule]; - }, - ), - timedPreparationRepository: FakeTimedPreparationRepository(), - ); - final marchEvents = >[]; - final aprilEvents = >[]; - final marchSubscription = repository - .watchSchedulesByDate(marchStart, marchEnd) - .listen(marchEvents.add); - final aprilSubscription = repository - .watchSchedulesByDate(aprilStart, aprilEnd) - .listen(aprilEvents.add); - addTearDown(marchSubscription.cancel); - addTearDown(aprilSubscription.cancel); - await pumpEventQueue(); - - await repository.getSchedulesByDate(marchStart, marchEnd); - await pumpEventQueue(); - - expect(marchEvents.map((event) => event.map((s) => s.id).toList()), [ - [], - ['march'], - ]); - expect(aprilEvents.map((event) => event.map((s) => s.id).toList()), [ - [], - ]); - - await repository.getSchedulesByDate(aprilStart, aprilEnd); - await pumpEventQueue(); - - expect(marchEvents.map((event) => event.map((s) => s.id).toList()), [ - [], - ['march'], - ]); - expect(aprilEvents.map((event) => event.map((s) => s.id).toList()), [ - [], - ['april'], - ]); - }, - ); - - test( - 'getSchedulesByDate upserts existing schedule by id in stream cache', - () async { - final startDate = DateTime(2026, 3, 1); - final endDate = DateTime(2026, 4, 1); - - final initialSchedule = ScheduleEntity( - id: 'schedule-1', - place: PlaceEntity(id: 'place-1', placeName: 'Old Place'), - scheduleName: 'Old Name', - scheduleTime: DateTime(2026, 3, 20, 9, 0), - moveTime: const Duration(minutes: 10), - isChanged: false, - isStarted: false, - scheduleSpareTime: const Duration(minutes: 5), - scheduleNote: 'old', - ); - - final refreshedSchedule = ScheduleEntity( - id: 'schedule-1', - place: PlaceEntity(id: 'place-1', placeName: 'New Place'), - scheduleName: 'New Name', - scheduleTime: DateTime(2026, 3, 20, 9, 0), - moveTime: const Duration(minutes: 25), - isChanged: true, - isStarted: false, - scheduleSpareTime: const Duration(minutes: 15), - scheduleNote: 'new', - ); - - var callCount = 0; - final remote = FakeScheduleRemoteDataSource( - getSchedulesByDateHandler: (_, __) async { - callCount += 1; - return callCount == 1 ? [initialSchedule] : [refreshedSchedule]; - }, - ); - - final repository = ScheduleRepositoryImpl( - scheduleRemoteDataSource: remote, - timedPreparationRepository: FakeTimedPreparationRepository(), - ); - - await repository.getSchedulesByDate(startDate, endDate); - await repository.getSchedulesByDate(startDate, endDate); - - final latest = await repository.scheduleStream.firstWhere( - (schedules) => - schedules.length == 1 && schedules.first.scheduleName == 'New Name', - ); - - expect(latest.length, 1); - expect(latest.first.id, 'schedule-1'); - expect(latest.first.scheduleName, 'New Name'); - expect(latest.first.place.placeName, 'New Place'); - expect(latest.first.moveTime, const Duration(minutes: 25)); - }, - ); - - test('updateSchedule refreshes edited schedule into stream cache', () async { - final initialSchedule = ScheduleEntity( - id: 'schedule-1', - place: PlaceEntity(id: 'place-1', placeName: 'Old Place'), - scheduleName: 'Old Name', - scheduleTime: DateTime(2026, 3, 20, 9, 0), - moveTime: const Duration(minutes: 10), - isChanged: false, - isStarted: false, - scheduleSpareTime: const Duration(minutes: 5), - scheduleNote: 'old', - ); - - final editedSchedule = ScheduleEntity( - id: 'schedule-1', - place: PlaceEntity(id: 'place-1', placeName: 'New Place'), - scheduleName: 'Edited Name', - scheduleTime: DateTime(2026, 3, 20, 10, 30), - moveTime: const Duration(minutes: 20), - isChanged: false, - isStarted: false, - scheduleSpareTime: const Duration(minutes: 15), - scheduleNote: 'updated', - ); - - final repository = ScheduleRepositoryImpl( - scheduleRemoteDataSource: FakeScheduleRemoteDataSource( - getSchedulesByDateHandler: (_, __) async => [initialSchedule], - getScheduleByIdHandler: (_) async => editedSchedule, - ), - timedPreparationRepository: FakeTimedPreparationRepository(), - ); - - await repository.createSchedule(initialSchedule); - await repository.updateSchedule(editedSchedule); - - final latest = await repository.scheduleStream.firstWhere( - (schedules) => - schedules.length == 1 && - schedules.first.scheduleName == 'Edited Name' && - schedules.first.scheduleTime == DateTime(2026, 3, 20, 10, 30), - ); - - expect(latest.length, 1); - expect(latest.first.id, 'schedule-1'); - expect(latest.first.scheduleName, 'Edited Name'); - expect(latest.first.place.placeName, 'New Place'); - expect(latest.first.moveTime, const Duration(minutes: 20)); - expect(latest.first.scheduleSpareTime, const Duration(minutes: 15)); - }); -} - -ScheduleEntity _schedule({required String id, required DateTime scheduleTime}) { - return ScheduleEntity( - id: id, - place: const PlaceEntity(id: 'place-1', placeName: 'Office'), - scheduleName: id, - scheduleTime: scheduleTime, - moveTime: const Duration(minutes: 10), - isChanged: false, - isStarted: false, - scheduleSpareTime: const Duration(minutes: 5), - scheduleNote: '', - ); -} - -GetScheduleResponseModel _responseFrom(ScheduleEntity schedule) { - return GetScheduleResponseModel( - scheduleId: schedule.id, - place: GetPlaceResponseModel.fromEntity(schedule.place), - scheduleName: schedule.scheduleName, - scheduleTime: schedule.scheduleTime, - moveTime: schedule.moveTime.inMinutes, - scheduleSpareTime: schedule.scheduleSpareTime?.inMinutes ?? 0, - scheduleNote: schedule.scheduleNote, - latenessTime: schedule.latenessTime, - doneStatus: _serverDoneStatus(schedule.doneStatus), - startedAt: schedule.startedAt, - finishedAt: schedule.finishedAt, - preparationMode: schedule.preparationMode, - preparationTemplateId: schedule.preparationTemplateId, - preparationTemplateName: schedule.preparationTemplateName, - preparationTemplateDeleted: schedule.preparationTemplateDeleted, - preparationFrozen: schedule.preparationFrozen, - ); -} - -String _serverDoneStatus(ScheduleDoneStatus status) { - switch (status) { - case ScheduleDoneStatus.lateEnd: - return 'LATE'; - case ScheduleDoneStatus.normalEnd: - return 'NORMAL'; - case ScheduleDoneStatus.abnormalEnd: - return 'ABNORMAL'; - case ScheduleDoneStatus.notEnded: - return 'NOT_ENDED'; - } -} diff --git a/test/data/repositories/schedule_repository_impl_test.dart b/test/data/repositories/schedule_repository_impl_test.dart deleted file mode 100644 index 9a3b528f..00000000 --- a/test/data/repositories/schedule_repository_impl_test.dart +++ /dev/null @@ -1,425 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:on_time_front/data/models/create_schedule_request_model.dart'; -import 'package:on_time_front/data/models/get_place_response_model.dart'; -import 'package:on_time_front/data/models/get_schedule_response_model.dart'; -import 'package:on_time_front/data/models/update_schedule_request_model.dart'; -import 'package:on_time_front/data/repositories/schedule_repository_impl.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/domain/entities/timed_preparation_snapshot_entity.dart'; -import 'package:on_time_front/domain/repositories/schedule_repository.dart'; -import 'package:on_time_front/domain/repositories/timed_preparation_repository.dart'; -import 'package:uuid/uuid.dart'; - -import '../../helpers/mock.mocks.dart'; - -class FakeTimedPreparationRepository implements TimedPreparationRepository { - final List clearedIds = []; - - @override - Future clearTimedPreparation(String scheduleId) async { - clearedIds.add(scheduleId); - } - - @override - Future getTimedPreparationSnapshot( - String scheduleId, - ) async { - return null; - } - - @override - Future saveTimedPreparationSnapshot( - String scheduleId, - TimedPreparationSnapshotEntity snapshot, - ) async {} -} - -void main() { - late MockScheduleRemoteDataSource mockScheduleRemoteDataSource; - late FakeTimedPreparationRepository fakeTimedPreparationRepository; - late ScheduleRepository scheduleRepository; - - final uuid = Uuid(); - final scheduleEntityId = uuid.v7(); - - final tPlaceEntity = PlaceEntity(id: uuid.v7(), placeName: 'Office'); - - final tScheduleEntity = ScheduleEntity( - id: scheduleEntityId, - place: tPlaceEntity, - scheduleName: 'Meeting', - scheduleTime: DateTime.now(), - moveTime: Duration(minutes: 10), - isChanged: false, - isStarted: false, - scheduleSpareTime: Duration(minutes: 5), - scheduleNote: 'Discuss project updates', - ); - - final tStartDate = DateTime.now(); - final tEndDate = DateTime.now().add(Duration(days: 1)); - - setUp(() { - mockScheduleRemoteDataSource = MockScheduleRemoteDataSource(); - fakeTimedPreparationRepository = FakeTimedPreparationRepository(); - scheduleRepository = ScheduleRepositoryImpl( - scheduleRemoteDataSource: mockScheduleRemoteDataSource, - timedPreparationRepository: fakeTimedPreparationRepository, - ); - }); - - group('createSchedule', () { - test( - 'when successful [createSchedule] should create a schedule with the given schedule entity', - () async { - when( - mockScheduleRemoteDataSource.createSchedule(any), - ).thenAnswer((_) async {}); - - await scheduleRepository.createSchedule(tScheduleEntity); - - final request = - verify( - mockScheduleRemoteDataSource.createSchedule(captureAny), - ).captured.single - as CreateScheduleRequestModel; - expect(request.scheduleId, scheduleEntityId); - expect(request.placeId, tPlaceEntity.id); - expect(request.scheduleName, tScheduleEntity.scheduleName); - expect(fakeTimedPreparationRepository.clearedIds, isEmpty); - }, - ); - - test( - 'when ScheduleRemoteDataSource throws an exception [createSchedule] should throw an exception', - () async { - when( - mockScheduleRemoteDataSource.createSchedule(any), - ).thenThrow(Exception()); - - final call = scheduleRepository.createSchedule(tScheduleEntity); - - expect(call, throwsException); - expect(fakeTimedPreparationRepository.clearedIds, isEmpty); - }, - ); - }); - - group('deleteSchedule', () { - test( - 'when successful [deleteSchedule] clears timed cache for schedule id', - () async { - when( - mockScheduleRemoteDataSource.deleteSchedule(scheduleEntityId), - ).thenAnswer((_) async {}); - - await scheduleRepository.deleteSchedule(tScheduleEntity); - - verify(mockScheduleRemoteDataSource.deleteSchedule(scheduleEntityId)); - expect(fakeTimedPreparationRepository.clearedIds, [scheduleEntityId]); - }, - ); - - test( - 'when ScheduleRemoteDataSource throws an exception [deleteSchedule] should throw and not clear cache', - () async { - when( - mockScheduleRemoteDataSource.deleteSchedule(scheduleEntityId), - ).thenThrow(Exception()); - - final call = scheduleRepository.deleteSchedule(tScheduleEntity); - - expect(call, throwsException); - expect(fakeTimedPreparationRepository.clearedIds, isEmpty); - }, - ); - }); - - group('getScheduleById', () { - test( - 'when schedule is not ended [getScheduleById] should not clear timed cache', - () async { - when( - mockScheduleRemoteDataSource.getScheduleById(scheduleEntityId), - ).thenAnswer((_) async => _responseFrom(tScheduleEntity)); - - final schedule = await scheduleRepository.getScheduleById( - scheduleEntityId, - ); - - expect(schedule, tScheduleEntity); - expect(fakeTimedPreparationRepository.clearedIds, isEmpty); - }, - ); - - test( - 'when schedule is ended [getScheduleById] should clear timed cache', - () async { - final endedSchedule = tScheduleEntity.copyWith( - doneStatus: ScheduleDoneStatus.normalEnd, - ); - when( - mockScheduleRemoteDataSource.getScheduleById(scheduleEntityId), - ).thenAnswer((_) async => _responseFrom(endedSchedule)); - - final schedule = await scheduleRepository.getScheduleById( - scheduleEntityId, - ); - - expect(schedule.doneStatus, ScheduleDoneStatus.normalEnd); - expect(fakeTimedPreparationRepository.clearedIds, [scheduleEntityId]); - }, - ); - - test( - 'when ScheduleRemoteDataSource throws an exception [getScheduleById] should throw an exception', - () async { - when( - mockScheduleRemoteDataSource.getScheduleById(scheduleEntityId), - ).thenThrow(Exception()); - - final getScheduleById = scheduleRepository.getScheduleById; - - expect(getScheduleById(scheduleEntityId), throwsException); - expect(fakeTimedPreparationRepository.clearedIds, isEmpty); - }, - ); - }); - - group('getSchedulesByDate', () { - test( - 'when successful [getSchedulesByDate] should return schedules', - () async { - final schedules = [tScheduleEntity]; - when( - mockScheduleRemoteDataSource.getSchedulesByDate(tStartDate, tEndDate), - ).thenAnswer((_) async => _responsesFrom(schedules)); - - final result = await scheduleRepository.getSchedulesByDate( - tStartDate, - tEndDate, - ); - - expect(result, schedules); - expect(fakeTimedPreparationRepository.clearedIds, isEmpty); - }, - ); - - test( - 'when mixed statuses [getSchedulesByDate] clears cache only for ended schedules', - () async { - final endedNormal = tScheduleEntity.copyWith( - doneStatus: ScheduleDoneStatus.normalEnd, - ); - final endedLate = ScheduleEntity( - id: uuid.v7(), - place: tPlaceEntity, - scheduleName: 'Late End', - scheduleTime: DateTime.now().add(Duration(hours: 1)), - moveTime: Duration(minutes: 10), - isChanged: false, - isStarted: false, - scheduleSpareTime: Duration(minutes: 5), - scheduleNote: 'note', - doneStatus: ScheduleDoneStatus.lateEnd, - ); - final ongoing = ScheduleEntity( - id: uuid.v7(), - place: tPlaceEntity, - scheduleName: 'Not Ended', - scheduleTime: DateTime.now().add(Duration(hours: 2)), - moveTime: Duration(minutes: 10), - isChanged: false, - isStarted: false, - scheduleSpareTime: Duration(minutes: 5), - scheduleNote: 'note', - doneStatus: ScheduleDoneStatus.notEnded, - ); - final endedAbnormal = ScheduleEntity( - id: uuid.v7(), - place: tPlaceEntity, - scheduleName: 'Abnormal End', - scheduleTime: DateTime.now().add(Duration(hours: 3)), - moveTime: Duration(minutes: 10), - isChanged: false, - isStarted: false, - scheduleSpareTime: Duration(minutes: 5), - scheduleNote: 'note', - doneStatus: ScheduleDoneStatus.abnormalEnd, - ); - - final schedules = [endedNormal, ongoing, endedLate, endedAbnormal]; - when( - mockScheduleRemoteDataSource.getSchedulesByDate(tStartDate, tEndDate), - ).thenAnswer((_) async => _responsesFrom(schedules)); - - await scheduleRepository.getSchedulesByDate(tStartDate, tEndDate); - - expect(fakeTimedPreparationRepository.clearedIds, [ - endedNormal.id, - endedLate.id, - endedAbnormal.id, - ]); - }, - ); - - test( - 'when ScheduleRemoteDataSource throws an exception [getSchedulesByDate] should throw an exception', - () async { - when( - mockScheduleRemoteDataSource.getSchedulesByDate(tStartDate, tEndDate), - ).thenThrow(Exception()); - - final getscheduleByDate = scheduleRepository.getSchedulesByDate; - - expect(getscheduleByDate(tStartDate, tEndDate), throwsException); - expect(fakeTimedPreparationRepository.clearedIds, isEmpty); - }, - ); - }); - - group('updateSchedule', () { - test( - 'when successful [updateSchedule] clears timed cache for schedule id', - () async { - when( - mockScheduleRemoteDataSource.updateSchedule(any), - ).thenAnswer((_) async {}); - when( - mockScheduleRemoteDataSource.getScheduleById(scheduleEntityId), - ).thenAnswer((_) async => _responseFrom(tScheduleEntity)); - - await scheduleRepository.updateSchedule(tScheduleEntity); - - final request = - verify( - mockScheduleRemoteDataSource.updateSchedule(captureAny), - ).captured.single - as UpdateScheduleRequestModel; - expect(request.scheduleId, scheduleEntityId); - expect(request.placeId, tPlaceEntity.id); - expect(request.scheduleName, tScheduleEntity.scheduleName); - verify(mockScheduleRemoteDataSource.getScheduleById(scheduleEntityId)); - expect(fakeTimedPreparationRepository.clearedIds, [scheduleEntityId]); - }, - ); - - test( - 'when ScheduleRemoteDataSource throws an exception [updateSchedule] should throw and not clear cache', - () async { - when( - mockScheduleRemoteDataSource.updateSchedule(any), - ).thenThrow(Exception()); - - final call = scheduleRepository.updateSchedule(tScheduleEntity); - - expect(call, throwsException); - expect(fakeTimedPreparationRepository.clearedIds, isEmpty); - }, - ); - }); - - group('finishSchedule', () { - test( - 'when successful [finishSchedule] clears timed cache for schedule id', - () async { - when( - mockScheduleRemoteDataSource.createSchedule(any), - ).thenAnswer((_) async {}); - when( - mockScheduleRemoteDataSource.finishSchedule(scheduleEntityId, 0), - ).thenAnswer((_) async {}); - - await scheduleRepository.createSchedule(tScheduleEntity); - await scheduleRepository.finishSchedule(scheduleEntityId, 0); - - verify( - mockScheduleRemoteDataSource.finishSchedule(scheduleEntityId, 0), - ); - expect(fakeTimedPreparationRepository.clearedIds, [scheduleEntityId]); - }, - ); - - test( - 'when ScheduleRemoteDataSource throws an exception [finishSchedule] should throw and not clear cache', - () async { - when( - mockScheduleRemoteDataSource.finishSchedule(scheduleEntityId, 0), - ).thenThrow(Exception()); - - final call = scheduleRepository.finishSchedule(scheduleEntityId, 0); - - expect(call, throwsException); - expect(fakeTimedPreparationRepository.clearedIds, isEmpty); - }, - ); - }); - - group('startSchedule', () { - test('when successful [startSchedule] calls remote data source', () async { - when( - mockScheduleRemoteDataSource.startSchedule(scheduleEntityId), - ).thenAnswer((_) async {}); - - await scheduleRepository.startSchedule(scheduleEntityId); - - verify(mockScheduleRemoteDataSource.startSchedule(scheduleEntityId)); - expect(fakeTimedPreparationRepository.clearedIds, isEmpty); - }); - - test( - 'when ScheduleRemoteDataSource throws an exception [startSchedule] should throw', - () async { - when( - mockScheduleRemoteDataSource.startSchedule(scheduleEntityId), - ).thenThrow(Exception()); - - final call = scheduleRepository.startSchedule(scheduleEntityId); - - expect(call, throwsException); - }, - ); - }); -} - -List _responsesFrom( - Iterable schedules, -) { - return schedules.map(_responseFrom).toList(); -} - -GetScheduleResponseModel _responseFrom(ScheduleEntity schedule) { - return GetScheduleResponseModel( - scheduleId: schedule.id, - place: GetPlaceResponseModel.fromEntity(schedule.place), - scheduleName: schedule.scheduleName, - scheduleTime: schedule.scheduleTime, - moveTime: schedule.moveTime.inMinutes, - scheduleSpareTime: schedule.scheduleSpareTime?.inMinutes ?? 0, - scheduleNote: schedule.scheduleNote, - latenessTime: schedule.latenessTime, - doneStatus: _serverDoneStatus(schedule.doneStatus), - startedAt: schedule.startedAt, - finishedAt: schedule.finishedAt, - preparationMode: schedule.preparationMode, - preparationTemplateId: schedule.preparationTemplateId, - preparationTemplateName: schedule.preparationTemplateName, - preparationTemplateDeleted: schedule.preparationTemplateDeleted, - preparationFrozen: schedule.preparationFrozen, - ); -} - -String _serverDoneStatus(ScheduleDoneStatus status) { - switch (status) { - case ScheduleDoneStatus.lateEnd: - return 'LATE'; - case ScheduleDoneStatus.normalEnd: - return 'NORMAL'; - case ScheduleDoneStatus.abnormalEnd: - return 'ABNORMAL'; - case ScheduleDoneStatus.notEnded: - return 'NOT_ENDED'; - } -} diff --git a/test/data/repositories/user_repository_impl_test.dart b/test/data/repositories/user_repository_impl_test.dart deleted file mode 100644 index 8cd11dc3..00000000 --- a/test/data/repositories/user_repository_impl_test.dart +++ /dev/null @@ -1,463 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/core/services/google_authentication_service.dart'; -import 'package:on_time_front/data/data_sources/authentication_remote_data_source.dart'; -import 'package:on_time_front/data/data_sources/token_local_data_source.dart'; -import 'package:on_time_front/data/models/sign_in_with_apple_request_model.dart'; -import 'package:on_time_front/data/models/sign_in_with_google_request_model.dart'; -import 'package:on_time_front/data/repositories/user_repository_impl.dart'; -import 'package:on_time_front/domain/entities/google_auth_credential.dart'; -import 'package:on_time_front/domain/entities/token_entity.dart'; -import 'package:on_time_front/domain/entities/user_entity.dart'; - -void main() { - late _FakeAuthenticationRemoteDataSource remoteDataSource; - late _FakeTokenLocalDataSource tokenLocalDataSource; - late _FakeGoogleAuthenticationService googleAuthenticationService; - late UserRepositoryImpl repository; - - setUp(() { - remoteDataSource = _FakeAuthenticationRemoteDataSource(); - tokenLocalDataSource = _FakeTokenLocalDataSource(); - googleAuthenticationService = _FakeGoogleAuthenticationService(); - repository = UserRepositoryImpl( - remoteDataSource, - tokenLocalDataSource, - googleAuthenticationService, - ); - }); - - test('signIn stores backend tokens and publishes signed-in user', () async { - final emittedUsers = []; - final subscription = repository.userStream.listen(emittedUsers.add); - addTearDown(subscription.cancel); - - await repository.signIn(email: 'user@example.com', password: 'Password1!'); - await pumpEventQueue(); - - expect(remoteDataSource.signInCalls, [('user@example.com', 'Password1!')]); - expect(tokenLocalDataSource.storedTokens, [_token]); - expect(emittedUsers, [const UserEntity.empty(), _user]); - }); - - test('signUp validates password before calling backend', () async { - await expectLater( - repository.signUp( - email: 'user@example.com', - password: 'weak', - name: 'User', - ), - throwsA(isA()), - ); - - expect(remoteDataSource.signUpCalls, isEmpty); - expect(tokenLocalDataSource.storedTokens, isEmpty); - }); - - test('signUp stores tokens and publishes newly created user', () async { - const nextUser = UserEntity( - id: 'new-user', - email: 'new@example.com', - name: 'New User', - spareTime: Duration(minutes: 10), - note: 'note', - score: 4.5, - ); - remoteDataSource.authResult = (nextUser, _token); - - await repository.signUp( - email: 'new@example.com', - password: 'Password1!', - name: 'New User', - ); - - expect(remoteDataSource.signUpCalls, [ - ('new@example.com', 'Password1!', 'New User'), - ]); - expect(tokenLocalDataSource.storedTokens, [_token]); - expect(await repository.userStream.first, nextUser); - }); - - test( - 'getUser clears tokens and publishes empty user on unauthorized response', - () async { - remoteDataSource.getUserHandler = () async { - throw DioException( - requestOptions: RequestOptions(path: '/users/me'), - response: Response( - statusCode: 401, - requestOptions: RequestOptions(path: '/users/me'), - ), - ); - }; - - final user = await repository.getUser(); - - expect(user, const UserEntity.empty()); - expect(tokenLocalDataSource.deleteCount, 1); - expect(await repository.userStream.first, const UserEntity.empty()); - }, - ); - - test('signOut deletes local tokens and publishes empty user', () async { - await repository.signIn(email: 'user@example.com', password: 'Password1!'); - - await repository.signOut(); - - expect(tokenLocalDataSource.deleteCount, 1); - expect(await repository.userStream.first, const UserEntity.empty()); - }); - - test( - 'delete and feedback methods forward optional feedback to backend', - () async { - await repository.deleteUser(feedbackMessage: 'Not useful'); - await repository.deleteGoogleUser(feedbackMessage: 'Google feedback'); - await repository.deleteAppleUser(feedbackMessage: 'Apple feedback'); - await repository.postFeedback('General feedback'); - - expect(remoteDataSource.deleteUserFeedback, 'Not useful'); - expect(remoteDataSource.deleteGoogleFeedback, 'Google feedback'); - expect(remoteDataSource.deleteAppleFeedback, 'Apple feedback'); - expect(remoteDataSource.feedbackMessages, ['General feedback']); - }, - ); - - test('getUserSocialType returns null when backend lookup fails', () async { - remoteDataSource.socialTypeHandler = () async { - throw Exception('session expired'); - }; - - expect(await repository.getUserSocialType(), isNull); - }); - - test('getUser publishes backend user on successful lookup', () async { - final user = await repository.getUser(); - - expect(user, _user); - expect(await repository.userStream.first, _user); - }); - - test( - 'signInWithApple replaces local tokens and publishes backend user', - () async { - const appleUser = UserEntity( - id: 'apple-user', - email: 'apple@example.com', - name: 'Apple User', - spareTime: Duration(minutes: 5), - note: '', - score: 4.0, - ); - remoteDataSource.authResult = (appleUser, _token); - - await repository.signInWithApple( - idToken: 'id-token', - authCode: 'auth-code', - fullName: 'Apple User', - email: 'apple@example.com', - ); - - expect(tokenLocalDataSource.deleteCount, 1); - expect(tokenLocalDataSource.storedTokens, [_token]); - expect(remoteDataSource.appleRequests.single.idToken, 'id-token'); - expect(remoteDataSource.appleRequests.single.authCode, 'auth-code'); - expect(remoteDataSource.appleRequests.single.fullName, 'Apple User'); - expect(remoteDataSource.appleRequests.single.email, 'apple@example.com'); - expect(await repository.userStream.first, appleUser); - }, - ); - - test( - 'signInWithApple rethrows backend failures without publishing user', - () async { - remoteDataSource.signInWithAppleHandler = (_) async { - throw Exception('apple backend failed'); - }; - - await expectLater( - repository.signInWithApple( - idToken: 'id-token', - authCode: 'auth-code', - fullName: 'Apple User', - ), - throwsException, - ); - - expect(tokenLocalDataSource.deleteCount, 1); - expect(tokenLocalDataSource.storedTokens, isEmpty); - expect(await repository.userStream.first, const UserEntity.empty()); - }, - ); - - test( - 'signInWithGoogle replaces local tokens and publishes backend user', - () async { - const googleUser = UserEntity( - id: 'google-user', - email: 'google@example.com', - name: 'Google User', - spareTime: Duration(minutes: 15), - note: '', - score: 4.0, - ); - remoteDataSource.authResult = (googleUser, _token); - - await repository.signInWithGoogle( - const GoogleAuthCredential(idToken: 'google-id-token'), - ); - - expect(tokenLocalDataSource.deleteCount, 1); - expect(tokenLocalDataSource.storedTokens, [_token]); - expect(remoteDataSource.googleRequests.single.idToken, 'google-id-token'); - expect(remoteDataSource.googleRequests.single.refreshToken, isEmpty); - expect(await repository.userStream.first, googleUser); - }, - ); - - test('signInWithGoogle rejects credentials without an ID token', () async { - await expectLater( - repository.signInWithGoogle(const GoogleAuthCredential(idToken: '')), - throwsException, - ); - - expect(remoteDataSource.googleRequests, isEmpty); - expect(tokenLocalDataSource.deleteCount, 0); - expect(await repository.userStream.first, const UserEntity.empty()); - }); - - test( - 'backend failures are surfaced without publishing a signed-in user', - () async { - remoteDataSource.signInHandler = (_, __) async { - throw Exception('sign in failed'); - }; - - await expectLater( - repository.signIn(email: 'user@example.com', password: 'Password1!'), - throwsException, - ); - - expect(tokenLocalDataSource.storedTokens, isEmpty); - expect(await repository.userStream.first, const UserEntity.empty()); - }, - ); - - test('non-unauthorized getUser failures are rethrown', () async { - remoteDataSource.getUserHandler = () async { - throw DioException( - requestOptions: RequestOptions(path: '/users/me'), - response: Response( - statusCode: 500, - requestOptions: RequestOptions(path: '/users/me'), - ), - ); - }; - - await expectLater(repository.getUser(), throwsA(isA())); - - expect(tokenLocalDataSource.deleteCount, 0); - expect(await repository.userStream.first, const UserEntity.empty()); - }); - - test( - 'getUserSocialType returns backend social type when available', - () async { - expect(await repository.getUserSocialType(), 'GOOGLE'); - }, - ); - - test('delete operations surface backend failures', () async { - remoteDataSource.deleteUserHandler = () async { - throw Exception('delete failed'); - }; - await expectLater(repository.deleteUser(), throwsException); - - remoteDataSource.deleteGoogleHandler = () async { - throw Exception('delete google failed'); - }; - await expectLater(repository.deleteGoogleUser(), throwsException); - - remoteDataSource.deleteAppleHandler = () async { - throw Exception('delete apple failed'); - }; - await expectLater(repository.deleteAppleUser(), throwsException); - }); - - test('disconnectGoogleSignIn absorbs provider adapter failures', () async { - googleAuthenticationService.disconnectHandler = () async { - throw Exception('disconnect failed'); - }; - - await repository.disconnectGoogleSignIn(); - - expect(googleAuthenticationService.disconnectCount, 1); - }); -} - -const _user = UserEntity( - id: 'user-1', - email: 'user@example.com', - name: 'User', - spareTime: Duration(minutes: 10), - note: 'note', - score: 4.5, -); - -const _token = TokenEntity( - accessToken: 'access-token', - refreshToken: 'refresh-token', -); - -class _FakeAuthenticationRemoteDataSource - implements AuthenticationRemoteDataSource { - (UserEntity, TokenEntity) authResult = (_user, _token); - Future Function() getUserHandler = () async => _user; - Future Function() socialTypeHandler = () async => 'GOOGLE'; - Future<(UserEntity, TokenEntity)> Function(String, String)? signInHandler; - - final signInCalls = <(String, String)>[]; - final signUpCalls = <(String, String, String)>[]; - final appleRequests = []; - final googleRequests = []; - final feedbackMessages = []; - Future<(UserEntity, TokenEntity)> Function(SignInWithAppleRequestModel)? - signInWithAppleHandler; - Future Function()? deleteUserHandler; - Future Function()? deleteGoogleHandler; - Future Function()? deleteAppleHandler; - String? deleteUserFeedback; - String? deleteGoogleFeedback; - String? deleteAppleFeedback; - - @override - Future<(UserEntity, TokenEntity)> signIn( - String email, - String password, - ) async { - signInCalls.add((email, password)); - final handler = signInHandler; - if (handler != null) { - return handler(email, password); - } - return authResult; - } - - @override - Future<(UserEntity, TokenEntity)> signUp( - String email, - String password, - String name, - ) async { - signUpCalls.add((email, password, name)); - return authResult; - } - - @override - Future getUser() => getUserHandler(); - - @override - Future deleteUser({String? feedbackMessage}) async { - final handler = deleteUserHandler; - if (handler != null) { - await handler(); - } - deleteUserFeedback = feedbackMessage; - } - - @override - Future deleteGoogleMe({String? feedbackMessage}) async { - final handler = deleteGoogleHandler; - if (handler != null) { - await handler(); - } - deleteGoogleFeedback = feedbackMessage; - } - - @override - Future deleteAppleMe({String? feedbackMessage}) async { - final handler = deleteAppleHandler; - if (handler != null) { - await handler(); - } - deleteAppleFeedback = feedbackMessage; - } - - @override - Future postFeedback(String message) async { - feedbackMessages.add(message); - } - - @override - Future getUserSocialType() => socialTypeHandler(); - - @override - Future<(UserEntity, TokenEntity)> signInWithApple( - SignInWithAppleRequestModel signInWithAppleRequestModel, - ) async { - appleRequests.add(signInWithAppleRequestModel); - final handler = signInWithAppleHandler; - if (handler != null) { - return handler(signInWithAppleRequestModel); - } - return authResult; - } - - @override - Future<(UserEntity, TokenEntity)> signInWithGoogle( - SignInWithGoogleRequestModel signInWithGoogleRequestModel, - ) async { - googleRequests.add(signInWithGoogleRequestModel); - return authResult; - } -} - -class _FakeTokenLocalDataSource implements TokenLocalDataSource { - final storedTokens = []; - final storedAuthTokens = []; - int deleteCount = 0; - - @override - Future storeTokens(TokenEntity token) async { - storedTokens.add(token); - } - - @override - Future storeAuthToken(String token) async { - storedAuthTokens.add(token); - } - - @override - Future getToken() async { - return storedTokens.last; - } - - @override - Future deleteToken() async { - deleteCount += 1; - } -} - -class _FakeGoogleAuthenticationService implements GoogleAuthenticationService { - Future Function()? disconnectHandler; - int disconnectCount = 0; - - @override - Stream get authenticationCredentials => - const Stream.empty(); - - @override - bool get supportsAuthenticate => true; - - @override - Future authenticate() => throw UnimplementedError(); - - @override - Future disconnect() async { - disconnectCount += 1; - await disconnectHandler?.call(); - } - - @override - Future initialize() async {} -} diff --git a/test/data/services/device_fcm_token_registrar_test.dart b/test/data/services/device_fcm_token_registrar_test.dart deleted file mode 100644 index da18f3f7..00000000 --- a/test/data/services/device_fcm_token_registrar_test.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/data/data_sources/notification_remote_data_source.dart'; -import 'package:on_time_front/data/models/fcm_token_register_request_model.dart'; -import 'package:on_time_front/data/services/device_fcm_token_registrar.dart'; -import 'package:on_time_front/domain/repositories/alarm_repository.dart'; - -void main() { - test('registers FCM token for the current alarm device', () async { - final alarmRepository = _FakeAlarmRepository(deviceId: 'device-1'); - final remoteDataSource = _FakeNotificationRemoteDataSource(); - final registrar = DeviceFcmTokenRegistrar( - alarmRepository, - remoteDataSource, - ); - - await registrar.registerToken('fcm-token'); - - expect(remoteDataSource.registeredTokens.single.firebaseToken, 'fcm-token'); - expect(remoteDataSource.registeredTokens.single.deviceId, 'device-1'); - }); -} - -class _FakeAlarmRepository implements AlarmRepository { - _FakeAlarmRepository({required this.deviceId}); - - final String deviceId; - - @override - Future getDeviceId() async => deviceId; - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -class _FakeNotificationRemoteDataSource - implements NotificationRemoteDataSource { - final registeredTokens = []; - - @override - Future fcmTokenRegister(FcmTokenRegisterRequestModel model) async { - registeredTokens.add(model); - } -} diff --git a/test/domain/entities/preparation_timing_entity_test.dart b/test/domain/entities/preparation_timing_entity_test.dart index b73c8239..89b9cb83 100644 --- a/test/domain/entities/preparation_timing_entity_test.dart +++ b/test/domain/entities/preparation_timing_entity_test.dart @@ -237,7 +237,10 @@ void main() { test('schedule with preparation derives start time and total duration', () { expect(schedule.totalDuration, const Duration(minutes: 50)); - expect(schedule.preparationStartTime, DateTime(2026, 3, 20, 9, 10)); + expect( + schedule.preparationStartTime, + DateTime(2026, 3, 20, 9, 10).toUtc(), + ); expect(schedule.timeRemainingBeforeLeaving.inMinutes, isA()); expect(schedule.isLate, isA()); expect(schedule.cacheFingerprint, contains('s1:wash:600000:s2|')); diff --git a/test/domain/entities/product_usage_event_test.dart b/test/domain/entities/product_usage_event_test.dart deleted file mode 100644 index 6db20693..00000000 --- a/test/domain/entities/product_usage_event_test.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/domain/entities/product_usage_event.dart'; -import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; - -void main() { - test('schedule_created factory builds the catalog event', () { - final event = ProductUsageEvent.scheduleCreated( - preparationMode: SchedulePreparationMode.custom, - preparationStepCount: 3, - minutesUntilSchedule: 45, - ); - - expect(event.name, 'schedule_created'); - expect(event.workflow, 'schedule'); - expect(event.result, 'success'); - expect(event.parameters, { - 'preparation_mode': 'custom', - 'preparation_step_count': 3, - 'minutes_until_schedule': 45, - }); - expect(event.toAnalyticsParameters(platform: 'ios', appVersion: '1.2.3'), { - 'schema_version': 1, - 'workflow': 'schedule', - 'result': 'success', - 'platform': 'ios', - 'app_version': '1.2.3', - 'preparation_mode': 'custom', - 'preparation_step_count': 3, - 'minutes_until_schedule': 45, - }); - }); - - test('catalog rejects unknown product usage events', () { - expect( - () => ProductUsageEvent.fromCatalog( - name: 'raw_button_clicked', - result: ProductUsageResult.success, - ), - throwsA(isA()), - ); - }); - - test('catalog rejects parameters that are not allowlisted for the event', () { - expect( - () => ProductUsageEvent.fromCatalog( - name: 'schedule_created', - result: ProductUsageResult.success, - parameters: {'auth_provider': 'google'}, - ), - throwsA(isA()), - ); - }); - - test('catalog rejects forbidden privacy-sensitive fields', () { - expect( - () => ProductUsageEvent.fromCatalog( - name: 'schedule_created', - result: ProductUsageResult.success, - parameters: {'schedule_note': 'leave early'}, - ), - throwsA(isA()), - ); - }); - - test('catalog rejects arbitrary nested map parameter values', () { - expect( - () => ProductUsageEvent.fromCatalog( - name: 'schedule_created', - result: ProductUsageResult.success, - parameters: { - 'preparation_mode': {'raw': 'custom'}, - }, - ), - throwsA(isA()), - ); - }); - - test('catalog matches the documented first-release events', () { - final documentedCatalog = _documentedEventCatalog(); - final codeCatalog = { - for (final event in ProductUsageEventCatalog.firstReleaseEvents) - event.name: event.allowedParameterNames.toList()..sort(), - }; - - expect(codeCatalog, documentedCatalog); - }); -} - -Map> _documentedEventCatalog() { - final catalog = >{}; - final rows = File( - 'docs/Analytics-Event-Catalog.md', - ).readAsLinesSync().where((line) => line.startsWith('| `')); - - for (final row in rows) { - final cells = row.split('|').map((cell) => cell.trim()).toList(); - if (cells.length < 6) continue; - final eventName = _backtickValues(cells[1]).single; - final parameterNames = _backtickValues(cells[4]).toList()..sort(); - catalog[eventName] = parameterNames; - } - return catalog; -} - -Iterable _backtickValues(String markdown) { - return RegExp( - r'`([^`]+)`', - ).allMatches(markdown).map((match) => match.group(1)!); -} diff --git a/test/domain/entities/user_entity_test.dart b/test/domain/entities/user_entity_test.dart index aeb9e1e0..af3d29f8 100644 --- a/test/domain/entities/user_entity_test.dart +++ b/test/domain/entities/user_entity_test.dart @@ -2,21 +2,18 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/domain/entities/user_entity.dart'; void main() { - test('user exposes profile convenience values', () { + test('local profile derives punctuality from eligible outcomes', () { const entity = UserEntity( id: 'user-1', - email: 'user@example.com', - name: 'User', spareTime: Duration(minutes: 12), note: 'note', - score: 4.5, + eligibleOutcomeCount: 4, + onTimeOutcomeCount: 3, ); expect(entity.valueOrNull, entity); expect(entity.spareTimeOrNull, const Duration(minutes: 12)); - expect(entity.scoreOrNull, 4.5); - expect(entity.nameOrNull, 'User'); - expect(entity.emailOrNull, 'user@example.com'); + expect(entity.scoreOrNull, 75); }); test('empty user exposes null convenience values', () { @@ -25,7 +22,5 @@ void main() { expect(entity.valueOrNull, isNull); expect(entity.spareTimeOrNull, isNull); expect(entity.scoreOrNull, isNull); - expect(entity.nameOrNull, isNull); - expect(entity.emailOrNull, isNull); }); } diff --git a/test/domain/repositories/user_repository_boundary_test.dart b/test/domain/repositories/user_repository_boundary_test.dart deleted file mode 100644 index e781a4bf..00000000 --- a/test/domain/repositories/user_repository_boundary_test.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('UserRepository exposes app-owned authentication contract types', () { - final source = File( - 'lib/domain/repositories/user_repository.dart', - ).readAsStringSync(); - - expect( - source, - isNot(contains("package:google_sign_in/google_sign_in.dart")), - ); - expect(source, isNot(matches(RegExp(r'\bGoogleSignIn\w*')))); - }); -} diff --git a/test/domain/use-cases/analytics_preference_use_cases_test.dart b/test/domain/use-cases/analytics_preference_use_cases_test.dart deleted file mode 100644 index 896d2e2f..00000000 --- a/test/domain/use-cases/analytics_preference_use_cases_test.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/domain/entities/analytics_preference.dart'; -import 'package:on_time_front/domain/repositories/analytics_preference_repository.dart'; -import 'package:on_time_front/domain/use-cases/load_analytics_preference_use_case.dart'; -import 'package:on_time_front/domain/use-cases/update_analytics_preference_use_case.dart'; - -void main() { - test('signed-in analytics preference fails closed when account load fails', () async { - final repository = _FakeAnalyticsPreferenceRepository() - ..localPreference = const AnalyticsPreference(enabled: true) - ..loadAccountError = Exception('backend unavailable'); - final useCase = LoadAnalyticsPreferenceUseCase(repository); - - final preference = await useCase(signedIn: true); - - expect(preference.enabled, isFalse); - expect(preference.isConfirmed, isFalse); - }); - - test( - 'signed-in analytics preference update keeps local value when account update fails', - () async { - final repository = _FakeAnalyticsPreferenceRepository() - ..localPreference = const AnalyticsPreference(enabled: true) - ..updateAccountError = Exception('backend unavailable'); - final useCase = UpdateAnalyticsPreferenceUseCase(repository); - - await expectLater( - useCase(enabled: false, signedIn: true), - throwsException, - ); - - expect(repository.localPreference.enabled, isTrue); - }, - ); -} - -class _FakeAnalyticsPreferenceRepository - implements AnalyticsPreferenceRepository { - AnalyticsPreference localPreference = const AnalyticsPreference(enabled: false); - AnalyticsPreference accountPreference = - const AnalyticsPreference(enabled: false); - Object? loadAccountError; - Object? updateAccountError; - - @override - Future loadLocalPreference() async => localPreference; - - @override - Future saveLocalPreference(bool enabled) async { - localPreference = AnalyticsPreference(enabled: enabled); - } - - @override - Future loadAccountPreference() async { - final error = loadAccountError; - if (error != null) throw error; - return accountPreference; - } - - @override - Future updateAccountPreference(bool enabled) async { - final error = updateAccountError; - if (error != null) throw error; - accountPreference = AnalyticsPreference(enabled: enabled); - return accountPreference; - } -} diff --git a/test/domain/use-cases/cancel_alarms_use_cases_test.dart b/test/domain/use-cases/cancel_alarms_use_cases_test.dart index 492e518f..ece6cd2b 100644 --- a/test/domain/use-cases/cancel_alarms_use_cases_test.dart +++ b/test/domain/use-cases/cancel_alarms_use_cases_test.dart @@ -3,7 +3,6 @@ import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; import 'package:on_time_front/domain/entities/alarm_entities.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/cancel_schedule_alarm_use_case.dart'; @@ -53,24 +52,18 @@ void main() { ); test( - 'CancelAllAlarmsUseCase clears registry and unregisters device on logout', + 'CancelAllAlarmsUseCase cancels local alarms and clears the registry', () async { final registry = _FakeAlarmRegistryRepository([ _record('native', AlarmProvider.androidAlarmManager), _record('fallback', AlarmProvider.localNotification), _record('none', AlarmProvider.none), ]); - final alarmRepository = _FakeAlarmRepository(); final scheduler = _FakeAlarmSchedulerService(); final fallback = _FakeFallbackAlarmNotificationService(); - final useCase = CancelAllAlarmsUseCase( - alarmRepository, - registry, - scheduler, - fallback, - ); + final useCase = CancelAllAlarmsUseCase(registry, scheduler, fallback); - await useCase(unregisterDevice: true); + await useCase(); expect(scheduler.canceledNative.map((record) => record.scheduleId), [ 'native', @@ -79,27 +72,21 @@ void main() { 'fallback', ]); expect(registry.deleteAllCount, 1); - expect(alarmRepository.unregisteredDeviceIds, ['device-1']); }, ); - test( - 'CancelAllAlarmsUseCase tolerates unregister failures during cleanup', - () async { - final registry = _FakeAlarmRegistryRepository(const []); - final alarmRepository = _FakeAlarmRepository()..throwOnUnregister = true; - final useCase = CancelAllAlarmsUseCase( - alarmRepository, - registry, - _FakeAlarmSchedulerService(), - _FakeFallbackAlarmNotificationService(), - ); + test('CancelAllAlarmsUseCase clears an empty local registry', () async { + final registry = _FakeAlarmRegistryRepository(const []); + final useCase = CancelAllAlarmsUseCase( + registry, + _FakeAlarmSchedulerService(), + _FakeFallbackAlarmNotificationService(), + ); - await useCase(unregisterDevice: true); + await useCase(); - expect(registry.deleteAllCount, 1); - }, - ); + expect(registry.deleteAllCount, 1); + }); } ScheduledAlarmRecord _record(String scheduleId, AlarmProvider provider) { @@ -166,22 +153,3 @@ class _FakeFallbackAlarmNotificationService @override noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } - -class _FakeAlarmRepository implements AlarmRepository { - final unregisteredDeviceIds = []; - bool throwOnUnregister = false; - - @override - Future getDeviceId() async => 'device-1'; - - @override - Future unregisterCurrentDevice(String deviceId) async { - if (throwOnUnregister) { - throw Exception('backend unavailable'); - } - unregisteredDeviceIds.add(deviceId); - } - - @override - noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} diff --git a/test/domain/use-cases/create_schedule_form_submission_use_case_test.dart b/test/domain/use-cases/create_schedule_form_submission_use_case_test.dart index 9972e0fb..304b7788 100644 --- a/test/domain/use-cases/create_schedule_form_submission_use_case_test.dart +++ b/test/domain/use-cases/create_schedule_form_submission_use_case_test.dart @@ -6,7 +6,6 @@ import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/domain/use-cases/create_custom_preparation_use_case.dart'; import 'package:on_time_front/domain/use-cases/create_schedule_form_submission_use_case.dart'; import 'package:on_time_front/domain/use-cases/create_schedule_with_place_use_case.dart'; -import 'package:on_time_front/domain/use-cases/schedule_analytics_tracker.dart'; import 'package:on_time_front/domain/use-cases/schedule_form_submission.dart'; class SpyCreateScheduleWithPlaceUseCase @@ -32,31 +31,16 @@ class SpyCreateCustomPreparationUseCase } } -class SpyScheduleAnalyticsTracker implements ScheduleAnalyticsTracker { - final createdSchedules = - <({ScheduleEntity schedule, PreparationEntity preparation})>[]; - - @override - Future trackScheduleCreated({ - required ScheduleEntity schedule, - required PreparationEntity preparation, - }) async { - createdSchedules.add((schedule: schedule, preparation: preparation)); - } -} - void main() { test( - 'changed preparation creates schedule, saves custom preparation, and tracks create analytics', + 'changed preparation creates schedule and saves custom preparation locally', () async { final createScheduleUseCase = SpyCreateScheduleWithPlaceUseCase(); final createCustomPreparationUseCase = SpyCreateCustomPreparationUseCase(); - final analyticsTracker = SpyScheduleAnalyticsTracker(); final useCase = CreateScheduleFormSubmissionUseCase( createScheduleUseCase, createCustomPreparationUseCase, - analyticsTracker, ); final schedule = ScheduleEntity( id: 'schedule-1', @@ -91,9 +75,6 @@ void main() { expect(createCustomPreparationUseCase.createdPreparations, [ (preparation: preparation, id: 'schedule-1'), ]); - expect(analyticsTracker.createdSchedules, [ - (schedule: schedule, preparation: preparation), - ]); }, ); } diff --git a/test/domain/use-cases/delete_user_use_case_test.dart b/test/domain/use-cases/delete_user_use_case_test.dart deleted file mode 100644 index 1ee74c20..00000000 --- a/test/domain/use-cases/delete_user_use_case_test.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/domain/entities/google_auth_credential.dart'; -import 'package:on_time_front/domain/entities/user_entity.dart'; -import 'package:on_time_front/domain/repositories/user_repository.dart'; -import 'package:on_time_front/domain/use-cases/delete_user_use_case.dart'; - -void main() { - test('deletes normal accounts with feedback', () async { - final repository = _FakeUserRepository(socialType: null); - final useCase = DeleteUserUseCase(repository); - - await useCase('Too many notifications'); - - expect(repository.deletedNormalFeedback, 'Too many notifications'); - expect(repository.deletedGoogleFeedback, isNull); - expect(repository.deletedAppleFeedback, isNull); - }); - - test('deletes Google accounts through the Google revoke endpoint', () async { - final repository = _FakeUserRepository(socialType: 'GOOGLE'); - final useCase = DeleteUserUseCase(repository); - - await useCase('Switching apps'); - - expect(repository.deletedGoogleFeedback, 'Switching apps'); - expect(repository.didDisconnectGoogleSignIn, isTrue); - expect(repository.deletedNormalFeedback, isNull); - }); - - test('deletes Apple accounts through the Apple revoke endpoint', () async { - final repository = _FakeUserRepository(socialType: 'apple'); - final useCase = DeleteUserUseCase(repository); - - await useCase('Fresh start'); - - expect(repository.deletedAppleFeedback, 'Fresh start'); - expect(repository.deletedNormalFeedback, isNull); - }); -} - -class _FakeUserRepository implements UserRepository { - _FakeUserRepository({required this.socialType}); - - final String? socialType; - String? deletedNormalFeedback; - String? deletedGoogleFeedback; - String? deletedAppleFeedback; - bool didDisconnectGoogleSignIn = false; - - @override - Stream get userStream => const Stream.empty(); - - @override - Future deleteAppleUser({String? feedbackMessage}) async { - deletedAppleFeedback = feedbackMessage; - } - - @override - Future deleteGoogleUser({String? feedbackMessage}) async { - deletedGoogleFeedback = feedbackMessage; - } - - @override - Future deleteUser({String? feedbackMessage}) async { - deletedNormalFeedback = feedbackMessage; - } - - @override - Future disconnectGoogleSignIn() async { - didDisconnectGoogleSignIn = true; - } - - @override - Future getUserSocialType() async => socialType; - - @override - Future getUser() => throw UnimplementedError(); - - @override - Future postFeedback(String message) => throw UnimplementedError(); - - @override - Future signIn({required String email, required String password}) => - throw UnimplementedError(); - - @override - Future signInWithApple({ - required String idToken, - required String authCode, - required String fullName, - String? email, - }) => throw UnimplementedError(); - - @override - Future signInWithGoogle(GoogleAuthCredential credential) => - throw UnimplementedError(); - - @override - Future signOut() => throw UnimplementedError(); - - @override - Future signUp({ - required String email, - required String password, - required String name, - }) => throw UnimplementedError(); -} diff --git a/test/domain/use-cases/reconcile_alarms_use_case_test.dart b/test/domain/use-cases/reconcile_alarms_use_case_test.dart index 9393fcad..a4bbb176 100644 --- a/test/domain/use-cases/reconcile_alarms_use_case_test.dart +++ b/test/domain/use-cases/reconcile_alarms_use_case_test.dart @@ -2,50 +2,26 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; import 'package:on_time_front/domain/entities/alarm_entities.dart'; -import 'package:on_time_front/domain/entities/google_auth_credential.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/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/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/repositories/user_repository.dart'; import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; class FakeAlarmRepository implements AlarmRepository { AlarmSettings settings = const AlarmSettings(alarmsEnabled: true); bool throwSettings = false; bool throwAlarmWindow = false; - bool throwRegisterCurrentDevice = false; - bool throwGenericOnStatus = false; List schedules = []; DateTime? requestedWindowStart; DateTime? requestedWindowEnd; - final statusReports = []; - final registeredDevices = []; final updatedSettings = []; - bool throwDeviceSessionNotActiveOnStatus = false; int alarmWindowRequestCount = 0; - @override - Future getDeviceId() async => 'device-1'; - - @override - Future buildCurrentDeviceInfo() async { - return const AlarmDeviceInfo( - deviceId: 'device-1', - platform: 'android', - appVersion: '1.0.0', - osVersion: 'android', - supportsNativeAlarm: true, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, - fallbackProvider: AlarmProvider.localNotification, - ); - } - @override Future getAlarmSettings() async { if (throwSettings) { @@ -63,17 +39,6 @@ class FakeAlarmRepository implements AlarmRepository { return settings; } - @override - Future registerCurrentDevice(AlarmDeviceInfo deviceInfo) async { - if (throwRegisterCurrentDevice) { - throw Exception('registration failed'); - } - registeredDevices.add(deviceInfo); - } - - @override - Future unregisterCurrentDevice(String deviceId) async {} - @override Future> getAlarmWindow( DateTime startDate, @@ -87,17 +52,6 @@ class FakeAlarmRepository implements AlarmRepository { requestedWindowEnd = endDate; return schedules; } - - @override - Future postAlarmStatus(AlarmStatusReport report) async { - if (throwDeviceSessionNotActiveOnStatus) { - throw const DeviceSessionNotActiveException(); - } - if (throwGenericOnStatus) { - throw Exception('status failed'); - } - statusReports.add(report); - } } class FakeAlarmRegistryRepository implements AlarmRegistryRepository { @@ -248,72 +202,12 @@ class FakeFallbackAlarmNotificationService } } -class FakeUserRepository implements UserRepository { - bool signedOut = false; - - @override - Stream get userStream => const Stream.empty(); - - @override - Future signOut() async { - signedOut = true; - } - - @override - Future deleteAppleUser({String? feedbackMessage}) => - throw UnimplementedError(); - - @override - Future deleteGoogleUser({String? feedbackMessage}) => - throw UnimplementedError(); - - @override - Future deleteUser({String? feedbackMessage}) => - throw UnimplementedError(); - - @override - Future disconnectGoogleSignIn() => throw UnimplementedError(); - - @override - Future getUser() => throw UnimplementedError(); - - @override - Future getUserSocialType() => throw UnimplementedError(); - - @override - Future postFeedback(String message) => throw UnimplementedError(); - - @override - Future signIn({required String email, required String password}) => - throw UnimplementedError(); - - @override - Future signInWithApple({ - required String idToken, - required String authCode, - required String fullName, - String? email, - }) => throw UnimplementedError(); - - @override - Future signInWithGoogle(GoogleAuthCredential credential) => - throw UnimplementedError(); - - @override - Future signUp({ - required String email, - required String password, - required String name, - }) => throw UnimplementedError(); -} - void main() { late DateTime now; late FakeAlarmRepository alarmRepository; late FakeAlarmRegistryRepository registryRepository; late FakeAlarmSchedulerService schedulerService; late FakeFallbackAlarmNotificationService fallbackService; - late FakeUserRepository userRepository; late ReconcileAlarmsUseCase useCase; setUp(() { @@ -323,19 +217,17 @@ void main() { schedulerService = FakeAlarmSchedulerService(); fallbackService = FakeFallbackAlarmNotificationService(); fallbackService.permission = AlarmPermissionState.granted; - userRepository = FakeUserRepository(); useCase = ReconcileAlarmsUseCase.test( alarmRepository, registryRepository, schedulerService, fallbackService, nowProvider: () => now, - userRepository: userRepository, ); }); test( - 'requests padded window and schedules eligible Android records as notifications', + 'requests the full future window and schedules only eligible records', () async { final eligible = scheduleWithAlarmAt( id: 'eligible', @@ -347,7 +239,7 @@ void main() { ); final outsideCoverage = scheduleWithAlarmAt( id: 'outside', - alarmTime: now.add(const Duration(days: 7, minutes: 1)), + alarmTime: DateTime(now.year + 51, 1, 1), ); final ended = scheduleWithAlarmAt( id: 'ended', @@ -359,10 +251,7 @@ void main() { final result = await useCase(); expect(alarmRepository.requestedWindowStart, now); - expect( - alarmRepository.requestedWindowEnd, - now.add(const Duration(days: 8)), - ); + expect(alarmRepository.requestedWindowEnd, DateTime(now.year + 50, 1, 1)); expect(schedulerService.scheduledNative, isEmpty); expect( fallbackService.scheduledFallback.map((record) => record.scheduleId), @@ -371,16 +260,8 @@ void main() { expect(result.armedScheduleIds, ['eligible']); expect(result.nativeAlarmProvider, AlarmProvider.none); expect(result.fallbackProvider, AlarmProvider.localNotification); - expect( - alarmRepository.statusReports.single.nativeAlarmProvider, - AlarmProvider.none, - ); - expect( - alarmRepository.statusReports.single.fallbackProvider, - AlarmProvider.localNotification, - ); expect(result.skippedScheduleCount, 3); - expect(result.alarmCoverageEnd, now.add(const Duration(days: 7))); + expect(result.alarmCoverageEnd, DateTime(now.year + 50, 1, 1)); expect(registryRepository.records.single.scheduleId, 'eligible'); }, ); @@ -446,7 +327,58 @@ void main() { ); expect(result.nativeAlarmProvider, AlarmProvider.none); expect(result.fallbackProvider, AlarmProvider.localNotification); - expect(result.alarmCoverageEnd, now.add(const Duration(days: 7))); + expect(result.alarmCoverageEnd, DateTime(now.year + 50, 1, 1)); + }); + + test( + 'arms only the nearest 60 future alarms and reports the overflow', + () async { + alarmRepository.schedules = [ + for (var index = 60; index >= 0; index--) + scheduleWithAlarmAt( + id: 'capacity-$index', + alarmTime: now.add(Duration(minutes: index + 1)), + ), + ]; + + final result = await useCase(); + + expect(fallbackService.scheduledFallback, hasLength(60)); + expect(fallbackService.scheduledFallback.first.scheduleId, 'capacity-0'); + expect(fallbackService.scheduledFallback.last.scheduleId, 'capacity-59'); + expect(result.armedScheduleIds, hasLength(60)); + expect(result.armedScheduleIds, isNot(contains('capacity-60'))); + expect(result.skippedScheduleCount, 1); + }, + ); + + test('keeps alarm content private unless detailed content is enabled', () { + final schedule = scheduleWithAlarmAt( + id: 'private', + alarmTime: now.add(const Duration(hours: 1)), + timeZoneId: 'Asia/Seoul', + ); + + final private = buildScheduledAlarmRecord( + schedule, + alarmOffset: const Duration(minutes: 5), + provider: AlarmProvider.localNotification, + currentTimeZoneId: 'UTC', + ); + final detailed = buildScheduledAlarmRecord( + schedule, + alarmOffset: const Duration(minutes: 5), + provider: AlarmProvider.localNotification, + detailedNotificationContent: true, + currentTimeZoneId: 'UTC', + ); + + expect(private.scheduleTitle, 'OnTime'); + expect(private.payload['detailedNotificationContent'], 'false'); + expect(private.payload, isNot(contains('notificationTimeZone'))); + expect(private.payload, isNot(contains('placeName'))); + expect(detailed.scheduleTitle, 'Schedule private'); + expect(detailed.payload['notificationTimeZone'], 'Asia/Seoul'); }); test('coalesces overlapping reconciliation requests', () async { @@ -461,8 +393,6 @@ void main() { expect(results[0], results[1]); expect(alarmRepository.alarmWindowRequestCount, 1); - expect(alarmRepository.registeredDevices.length, 1); - expect(alarmRepository.statusReports.length, 1); expect(schedulerService.scheduledNative, isEmpty); expect(fallbackService.scheduledFallback.length, 1); }); @@ -758,10 +688,6 @@ void main() { result.permissionIssue, AlarmPermissionIssue.notificationPermissionDenied, ); - expect( - alarmRepository.statusReports.single.permissionIssue, - AlarmPermissionIssue.notificationPermissionDenied, - ); }, ); @@ -811,10 +737,6 @@ void main() { expect(result.status, AlarmReconciliationStatus.settingsUnavailable); expect(schedulerService.canceledNative, isEmpty); expect(registryRepository.records, [existing]); - expect( - alarmRepository.statusReports.single.status, - AlarmReconciliationStatus.settingsUnavailable, - ); }, ); @@ -833,10 +755,6 @@ void main() { contains('alarm window unavailable'), ); expect(registryRepository.records, isEmpty); - expect( - alarmRepository.statusReports.single.status, - AlarmReconciliationStatus.partial, - ); }); test( @@ -982,30 +900,29 @@ void main() { }, ); - test('unsupported providers and status post failures do not throw', () async { - schedulerService.capabilities = const AlarmSchedulerCapabilities( - supportsNativeAlarm: false, - nativeAlarmProvider: AlarmProvider.none, - fallbackProvider: AlarmProvider.none, - ); - schedulerService.nativePermission = AlarmPermissionState.unsupported; - fallbackService.permission = AlarmPermissionState.unsupported; - alarmRepository - ..throwRegisterCurrentDevice = true - ..throwGenericOnStatus = true - ..schedules = [ + test( + 'unsupported local providers return unsupported without throwing', + () async { + schedulerService.capabilities = const AlarmSchedulerCapabilities( + supportsNativeAlarm: false, + nativeAlarmProvider: AlarmProvider.none, + fallbackProvider: AlarmProvider.none, + ); + schedulerService.nativePermission = AlarmPermissionState.unsupported; + fallbackService.permission = AlarmPermissionState.unsupported; + alarmRepository.schedules = [ scheduleWithAlarmAt( id: 'unsupported', alarmTime: now.add(const Duration(hours: 1)), ), ]; - final result = await useCase(); + final result = await useCase(); - expect(result.status, AlarmReconciliationStatus.unsupported); - expect(alarmRepository.statusReports, isEmpty); - expect(registryRepository.records, isEmpty); - }); + expect(result.status, AlarmReconciliationStatus.unsupported); + expect(registryRepository.records, isEmpty); + }, + ); test( 'permission check failures degrade to denied or unsupported states', @@ -1057,28 +974,6 @@ void main() { expect(result.status, AlarmReconciliationStatus.armed); expect(registryRepository.records, isEmpty); }); - - test( - 'session invalidation cancels alarms, clears registry, and signs out', - () async { - final existing = buildScheduledAlarmRecord( - scheduleWithAlarmAt( - id: 'old-device', - alarmTime: now.add(const Duration(hours: 1)), - ), - alarmOffset: const Duration(minutes: 5), - provider: AlarmProvider.androidAlarmManager, - ); - registryRepository.records = [existing]; - alarmRepository.throwDeviceSessionNotActiveOnStatus = true; - - await useCase(); - - expect(schedulerService.canceledNative, [existing]); - expect(registryRepository.records, isEmpty); - expect(userRepository.signedOut, isTrue); - }, - ); } ScheduleWithPreparationEntity scheduleWithAlarmAt({ @@ -1086,6 +981,7 @@ ScheduleWithPreparationEntity scheduleWithAlarmAt({ required DateTime alarmTime, ScheduleDoneStatus doneStatus = ScheduleDoneStatus.notEnded, String preparationName = 'Shower', + String timeZoneId = 'UTC', }) { const offset = Duration(minutes: 5); const moveTime = Duration(minutes: 10); @@ -1099,6 +995,7 @@ ScheduleWithPreparationEntity scheduleWithAlarmAt({ id: id, place: const PlaceEntity(id: 'place-1', placeName: 'Office'), scheduleName: 'Schedule $id', + timeZoneId: timeZoneId, scheduleTime: scheduleTime, moveTime: moveTime, isChanged: false, diff --git a/test/domain/use-cases/schedule_mutation_use_cases_test.dart b/test/domain/use-cases/schedule_mutation_use_cases_test.dart index 4cf3c3f3..c878ce75 100644 --- a/test/domain/use-cases/schedule_mutation_use_cases_test.dart +++ b/test/domain/use-cases/schedule_mutation_use_cases_test.dart @@ -1,16 +1,11 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/domain/entities/google_auth_credential.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/domain/entities/user_entity.dart'; import 'package:on_time_front/domain/repositories/schedule_repository.dart'; -import 'package:on_time_front/domain/repositories/user_repository.dart'; -import 'package:on_time_front/domain/use-cases/cancel_all_alarms_use_case.dart'; import 'package:on_time_front/domain/use-cases/create_schedule_with_place_use_case.dart'; import 'package:on_time_front/domain/use-cases/delete_schedule_use_case.dart'; import 'package:on_time_front/domain/use-cases/finish_schedule_use_case.dart'; import 'package:on_time_front/domain/use-cases/schedule_mutation_alarm_effects_coordinator.dart'; -import 'package:on_time_front/domain/use-cases/sign_out_use_case.dart'; import 'package:on_time_front/domain/use-cases/start_schedule_use_case.dart'; import 'package:on_time_front/domain/use-cases/update_schedule_use_case.dart'; @@ -113,19 +108,6 @@ void main() { }, ); - test( - 'sign out clears registered alarms before clearing user session', - () async { - final userRepository = _FakeUserRepository(); - final cancelAll = _FakeCancelAllAlarmsUseCase(); - final useCase = SignOutUseCase(userRepository, cancelAll); - - await useCase(); - - expect(cancelAll.unregisterDeviceRequests, [true]); - expect(userRepository.signOutCount, 1); - }, - ); } ScheduleEntity _schedule(String id) { @@ -204,18 +186,6 @@ class _FakeScheduleRepository implements ScheduleRepository { } } -class _FakeCancelAllAlarmsUseCase implements CancelAllAlarmsUseCase { - final unregisterDeviceRequests = []; - - @override - Future call({bool unregisterDevice = false}) async { - unregisterDeviceRequests.add(unregisterDevice); - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - class _FakeScheduleMutationAlarmEffectsCoordinator implements ScheduleMutationAlarmEffectsCoordinator { final calls = []; @@ -231,60 +201,3 @@ class _FakeScheduleMutationAlarmEffectsCoordinator @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } - -class _FakeUserRepository implements UserRepository { - int signOutCount = 0; - - @override - Stream get userStream => const Stream.empty(); - - @override - Future signOut() async { - signOutCount += 1; - } - - @override - Future deleteAppleUser({String? feedbackMessage}) async {} - - @override - Future deleteGoogleUser({String? feedbackMessage}) async {} - - @override - Future deleteUser({String? feedbackMessage}) async {} - - @override - Future disconnectGoogleSignIn() async {} - - @override - Future getUser() async {} - - @override - Future getUserSocialType() async => null; - - @override - Future postFeedback(String message) async {} - - @override - Future signIn({ - required String email, - required String password, - }) async {} - - @override - Future signInWithApple({ - required String idToken, - required String authCode, - required String fullName, - String? email, - }) async {} - - @override - Future signUp({ - required String email, - required String password, - required String name, - }) async {} - - @override - Future signInWithGoogle(GoogleAuthCredential credential) async {} -} diff --git a/test/domain/use-cases/track_schedule_analytics_use_case_test.dart b/test/domain/use-cases/track_schedule_analytics_use_case_test.dart deleted file mode 100644 index eceb4baf..00000000 --- a/test/domain/use-cases/track_schedule_analytics_use_case_test.dart +++ /dev/null @@ -1,71 +0,0 @@ -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/product_usage_event.dart'; -import 'package:on_time_front/domain/entities/schedule_entity.dart'; -import 'package:on_time_front/domain/use-cases/track_product_usage_event_use_case.dart'; -import 'package:on_time_front/domain/use-cases/track_schedule_analytics_use_case.dart'; - -class SpyProductUsageEventTracker implements ProductUsageEventTracker { - final events = []; - - @override - Future track(ProductUsageEvent event) async { - events.add(event); - } -} - -void main() { - test( - 'schedule create analytics uses allowed schedule_created parameters', - () async { - final productUsageEventTracker = SpyProductUsageEventTracker(); - final tracker = TrackScheduleAnalyticsUseCase.withClock( - productUsageEventTracker, - now: () => DateTime(2027, 3, 20, 8), - ); - final schedule = ScheduleEntity( - id: 'schedule-1', - place: PlaceEntity(id: 'place-1', placeName: 'Office'), - scheduleName: 'Meeting', - scheduleTime: DateTime(2027, 3, 20, 9), - moveTime: const Duration(minutes: 30), - isChanged: false, - isStarted: false, - scheduleSpareTime: const Duration(minutes: 10), - scheduleNote: 'bring laptop', - ); - final preparation = PreparationEntity( - preparationStepList: const [ - PreparationStepEntity( - id: 'prep-1', - preparationName: 'Shower', - preparationTime: Duration(minutes: 10), - ), - PreparationStepEntity( - id: 'prep-2', - preparationName: 'Pack', - preparationTime: Duration(minutes: 5), - ), - ], - ); - - await tracker.trackScheduleCreated( - schedule: schedule, - preparation: preparation, - ); - - expect(productUsageEventTracker.events, hasLength(1)); - final event = productUsageEventTracker.events.single; - expect(event.name, 'schedule_created'); - expect(event.workflow, 'schedule'); - expect(event.result, 'success'); - expect(event.parameters, { - 'preparation_mode': 'default', - 'preparation_step_count': 2, - 'minutes_until_schedule': 60, - }); - }, - ); -} diff --git a/test/domain/use-cases/user_use_cases_test.dart b/test/domain/use-cases/user_use_cases_test.dart deleted file mode 100644 index e6d46fdf..00000000 --- a/test/domain/use-cases/user_use_cases_test.dart +++ /dev/null @@ -1,188 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/domain/entities/google_auth_credential.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/user_entity.dart'; -import 'package:on_time_front/domain/repositories/preparation_repository.dart'; -import 'package:on_time_front/domain/repositories/user_repository.dart'; -import 'package:on_time_front/domain/use-cases/load_user_use_case.dart'; -import 'package:on_time_front/domain/use-cases/onboard_use_case.dart'; -import 'package:on_time_front/domain/use-cases/stream_user_use_case.dart'; - -void main() { - test('LoadUserUseCase refreshes the current user', () async { - final repository = _FakeUserRepository(); - - await LoadUserUseCase(repository)(); - - expect(repository.getUserCount, 1); - }); - - test('StreamUserUseCase exposes the repository user stream', () async { - final repository = _FakeUserRepository(); - final user = _user('user-1'); - - repository.emit(user); - - expect(await StreamUserUseCase(repository)().first, user); - }); - - test('OnboardUseCase creates defaults before refreshing the user', () async { - final preparationRepository = _FakePreparationRepository(); - final userRepository = _FakeUserRepository(); - final preparation = _preparation('prep-1'); - - await OnboardUseCase(preparationRepository, userRepository)( - preparationEntity: preparation, - spareTime: const Duration(minutes: 15), - note: 'Need shoes', - ); - - expect(preparationRepository.createdDefaults, [ - (preparation, const Duration(minutes: 15), 'Need shoes'), - ]); - expect(userRepository.getUserCount, 1); - expect(userRepository.events, ['getUser']); - }); -} - -class _FakeUserRepository implements UserRepository { - final _controller = Stream.empty().asBroadcastStream(); - final emittedUsers = []; - final events = []; - int getUserCount = 0; - - @override - Stream get userStream async* { - for (final user in emittedUsers) { - yield user; - } - yield* _controller; - } - - void emit(UserEntity user) { - emittedUsers.add(user); - } - - @override - @override - Future getUser() async { - getUserCount += 1; - events.add('getUser'); - } - - @override - Future deleteAppleUser({String? feedbackMessage}) => - throw UnimplementedError(); - - @override - Future deleteGoogleUser({String? feedbackMessage}) => - throw UnimplementedError(); - - @override - Future deleteUser({String? feedbackMessage}) => - throw UnimplementedError(); - - @override - Future disconnectGoogleSignIn() => throw UnimplementedError(); - - @override - Future getUserSocialType() => throw UnimplementedError(); - - @override - Future postFeedback(String message) => throw UnimplementedError(); - - @override - Future signIn({required String email, required String password}) => - throw UnimplementedError(); - - @override - Future signInWithApple({ - required String idToken, - required String authCode, - required String fullName, - String? email, - }) => throw UnimplementedError(); - - @override - Future signInWithGoogle(GoogleAuthCredential credential) => - throw UnimplementedError(); - - @override - Future signOut() => throw UnimplementedError(); - - @override - Future signUp({ - required String email, - required String password, - required String name, - }) => throw UnimplementedError(); -} - -class _FakePreparationRepository implements PreparationRepository { - final createdDefaults = <(PreparationEntity, Duration, String)>[]; - - @override - Stream> get preparationStream => - const Stream.empty(); - - @override - Future createDefaultPreparation({ - required PreparationEntity preparationEntity, - required Duration spareTime, - required String note, - }) async { - createdDefaults.add((preparationEntity, spareTime, note)); - } - - @override - Future createCustomPreparation( - PreparationEntity preparationEntity, - String scheduleId, - ) => throw UnimplementedError(); - - @override - Future getDefualtPreparation() => - throw UnimplementedError(); - - @override - Future getPreparationByScheduleId(String scheduleId) => - throw UnimplementedError(); - - @override - Future updateDefaultPreparation(PreparationEntity preparationEntity) => - throw UnimplementedError(); - - @override - Future updatePreparationByScheduleId( - PreparationEntity preparationEntity, - String scheduleId, - ) => throw UnimplementedError(); - - @override - Future updateSpareTime(Duration newSpareTime) => - throw UnimplementedError(); -} - -PreparationEntity _preparation(String id) { - return PreparationEntity( - preparationStepList: [ - PreparationStepEntity( - id: id, - preparationName: 'Pack', - preparationTime: const Duration(minutes: 5), - ), - ], - ); -} - -UserEntity _user(String id) { - return UserEntity( - id: id, - email: '$id@example.com', - name: 'Test User', - spareTime: const Duration(minutes: 10), - note: 'note', - score: 1, - ); -} diff --git a/test/helpers/mock.dart b/test/helpers/mock.dart deleted file mode 100644 index 9626afd7..00000000 --- a/test/helpers/mock.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:mockito/annotations.dart'; -import 'package:on_time_front/core/dio/app_dio.dart'; -import 'package:on_time_front/data/data_sources/schedule_remote_data_source.dart'; - -import 'package:on_time_front/data/data_sources/preparation_local_data_source.dart'; -import 'package:on_time_front/data/data_sources/preparation_remote_data_source.dart'; - -@GenerateMocks([ - ScheduleRemoteDataSource, - PreparationRemoteDataSource, - PreparationLocalDataSource, - AppDio, -]) -void main() {} diff --git a/test/helpers/sodium_test_loader.dart b/test/helpers/sodium_test_loader.dart new file mode 100644 index 00000000..72ab3458 --- /dev/null +++ b/test/helpers/sodium_test_loader.dart @@ -0,0 +1,14 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:sodium/sodium_sumo.dart'; + +Future loadSodiumForTest() { + final library = switch (Platform.operatingSystem) { + 'macos' => '/opt/homebrew/lib/libsodium.dylib', + 'linux' => 'libsodium.so', + 'windows' => 'libsodium.dll', + _ => throw UnsupportedError('Unsupported unit-test platform.'), + }; + return SodiumSumoInit.init(() => DynamicLibrary.open(library)); +} diff --git a/test/local_only_boundary_test.dart b/test/local_only_boundary_test.dart new file mode 100644 index 00000000..e499aab9 --- /dev/null +++ b/test/local_only_boundary_test.dart @@ -0,0 +1,11 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../tool/check_local_only_boundary.dart'; + +void main() { + test('product source and release configuration contain no network client', () { + expect(validateLocalOnlyBoundary(Directory.current), isEmpty); + }); +} diff --git a/test/presentation/alarm_allow/alarm_allow_screen_test.dart b/test/presentation/alarm_allow/alarm_allow_screen_test.dart index a630a5c6..967f0add 100644 --- a/test/presentation/alarm_allow/alarm_allow_screen_test.dart +++ b/test/presentation/alarm_allow/alarm_allow_screen_test.dart @@ -124,7 +124,6 @@ Future<_AlarmAllowHarness> _pumpAlarmAllowScreen( fallback, ); final cancelAllUseCase = _FakeCancelAllAlarmsUseCase( - repository, registry, scheduler, fallback, @@ -265,21 +264,15 @@ class _FakeReconcileAlarmsUseCase extends ReconcileAlarmsUseCase { class _FakeCancelAllAlarmsUseCase extends CancelAllAlarmsUseCase { // ignore: use_super_parameters _FakeCancelAllAlarmsUseCase( - AlarmRepository alarmRepository, AlarmRegistryRepository registryRepository, AlarmSchedulerService schedulerService, FallbackAlarmNotificationService fallbackNotificationService, - ) : super( - alarmRepository, - registryRepository, - schedulerService, - fallbackNotificationService, - ); + ) : super(registryRepository, schedulerService, fallbackNotificationService); int callCount = 0; @override - Future call({bool unregisterDevice = false}) async { + Future call() async { callCount += 1; } } @@ -300,22 +293,6 @@ class _FakeAlarmRepository implements AlarmRepository { return AlarmSettings(alarmsEnabled: alarmsEnabled); } - @override - Future getDeviceId() async => 'device-id'; - - @override - Future buildCurrentDeviceInfo() async { - return const AlarmDeviceInfo( - deviceId: 'device-id', - platform: 'test', - appVersion: '1.0.0', - osVersion: 'test', - supportsNativeAlarm: true, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, - fallbackProvider: AlarmProvider.localNotification, - ); - } - @override Future> getAlarmWindow( DateTime startDate, @@ -323,15 +300,6 @@ class _FakeAlarmRepository implements AlarmRepository { ) async { return const []; } - - @override - Future postAlarmStatus(AlarmStatusReport report) async {} - - @override - Future registerCurrentDevice(AlarmDeviceInfo deviceInfo) async {} - - @override - Future unregisterCurrentDevice(String deviceId) async {} } class _FakeAlarmRegistry implements AlarmRegistryRepository { diff --git a/test/presentation/app/bloc/auth/auth_bloc_test.dart b/test/presentation/app/bloc/auth/auth_bloc_test.dart index f05ee5ea..439752d9 100644 --- a/test/presentation/app/bloc/auth/auth_bloc_test.dart +++ b/test/presentation/app/bloc/auth/auth_bloc_test.dart @@ -1,216 +1,32 @@ -import 'dart:async'; - import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/domain/entities/alarm_entities.dart'; import 'package:on_time_front/domain/entities/user_entity.dart'; -import 'package:on_time_front/domain/use-cases/load_user_use_case.dart'; -import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; -import 'package:on_time_front/domain/use-cases/sign_out_use_case.dart'; -import 'package:on_time_front/domain/use-cases/stream_user_use_case.dart'; import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; -import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; void main() { - late StreamController userController; - late _FakeStreamUserUseCase streamUserUseCase; - late _FakeLoadUserUseCase loadUserUseCase; - late _FakeSignOutUseCase signOutUseCase; - late _FakeScheduleBloc scheduleBloc; - late _FakeReconcileAlarmsUseCase reconcileAlarmsUseCase; - - AuthBloc buildBloc() { - return AuthBloc( - streamUserUseCase, - signOutUseCase, - loadUserUseCase, - scheduleBloc, - reconcileAlarmsUseCase, + test('completed local profile is ready for the app', () { + final state = AuthState( + user: const UserEntity( + id: 'local-profile', + spareTime: Duration(minutes: 10), + note: '', + isOnboardingCompleted: true, + ), ); - } - - setUp(() { - userController = StreamController.broadcast(); - streamUserUseCase = _FakeStreamUserUseCase(userController.stream); - loadUserUseCase = _FakeLoadUserUseCase(); - signOutUseCase = _FakeSignOutUseCase(); - scheduleBloc = _FakeScheduleBloc(); - reconcileAlarmsUseCase = _FakeReconcileAlarmsUseCase(); - }); - tearDown(() async { - await userController.close(); + expect(state.status, AuthStatus.authenticated); }); - test( - 'authenticated users subscribe schedules and reconcile alarms', - () async { - final bloc = buildBloc(); - addTearDown(bloc.close); - - bloc.add(const AuthUserSubscriptionRequested()); - await pumpEventQueue(); - userController.add(_user(isOnboardingCompleted: true)); - - final authenticated = await bloc.stream.firstWhere( - (state) => state.status == AuthStatus.authenticated, - ); - await pumpEventQueue(); - - expect(loadUserUseCase.callCount, 1); - expect(authenticated.user, _user(isOnboardingCompleted: true)); - expect(scheduleBloc.addedEvents, [const ScheduleSubscriptionRequested()]); - expect(reconcileAlarmsUseCase.callCount, 1); - }, - ); - - test('non-onboarded and empty users map to their auth statuses', () async { - final bloc = buildBloc(); - addTearDown(bloc.close); - - bloc.add(const AuthUserSubscriptionRequested()); - await pumpEventQueue(); - userController.add(_user(isOnboardingCompleted: false)); - - final onboardingState = await bloc.stream.firstWhere( - (state) => state.status == AuthStatus.onboardingNotCompleted, - ); - userController.add(const UserEntity.empty()); - final unauthenticatedState = await bloc.stream.firstWhere( - (state) => state.status == AuthStatus.unauthenticated, + test('empty or incomplete local profile starts onboarding', () { + expect(AuthState().status, AuthStatus.onboardingNotCompleted); + expect( + AuthState( + user: const UserEntity( + id: 'local-profile', + spareTime: Duration.zero, + note: '', + ), + ).status, + AuthStatus.onboardingNotCompleted, ); - - expect(onboardingState.user, _user(isOnboardingCompleted: false)); - expect(unauthenticatedState.user, const UserEntity.empty()); - expect(scheduleBloc.addedEvents, isEmpty); - expect(reconcileAlarmsUseCase.callCount, 0); }); - - test( - 'load failure emits empty unauthenticated user before listening', - () async { - loadUserUseCase.error = Exception('session expired'); - final bloc = buildBloc(); - addTearDown(bloc.close); - - bloc.add(const AuthUserSubscriptionRequested()); - - final state = await bloc.stream.firstWhere( - (state) => state.status == AuthStatus.unauthenticated, - ); - - expect(state.user, const UserEntity.empty()); - }, - ); - - test('sign out event delegates to sign out use case', () async { - final bloc = buildBloc(); - addTearDown(bloc.close); - - bloc.add(const AuthSignOutPressed()); - await pumpEventQueue(); - - expect(signOutUseCase.callCount, 1); - }); - - test('AuthState value helpers derive status from the user', () { - final authenticated = AuthState(user: _user(isOnboardingCompleted: true)); - final onboarding = AuthState(user: _user(isOnboardingCompleted: false)); - final copied = authenticated.copyWith(status: AuthStatus.loading); - - expect(authenticated.status, AuthStatus.authenticated); - expect(onboarding.status, AuthStatus.onboardingNotCompleted); - expect(const AuthState.loading().status, AuthStatus.loading); - expect(copied.status, AuthStatus.loading); - expect(copied.user, authenticated.user); - }); -} - -UserEntity _user({required bool isOnboardingCompleted}) { - return UserEntity( - id: 'user-1', - email: 'user@example.com', - name: 'User', - spareTime: const Duration(minutes: 10), - note: 'note', - score: 4.5, - isOnboardingCompleted: isOnboardingCompleted, - ); -} - -class _FakeStreamUserUseCase implements StreamUserUseCase { - _FakeStreamUserUseCase(this.stream); - - final Stream stream; - - @override - Stream call() => stream; - - @override - noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -class _FakeLoadUserUseCase implements LoadUserUseCase { - int callCount = 0; - Object? error; - - @override - Future call() async { - callCount += 1; - final nextError = error; - if (nextError != null) { - throw nextError; - } - } - - @override - noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -class _FakeSignOutUseCase implements SignOutUseCase { - int callCount = 0; - - @override - Future call() async { - callCount += 1; - } - - @override - noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -class _FakeScheduleBloc implements ScheduleBloc { - final addedEvents = []; - - @override - void add(ScheduleEvent event) { - addedEvents.add(event); - } - - @override - noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -class _FakeReconcileAlarmsUseCase implements ReconcileAlarmsUseCase { - int callCount = 0; - - @override - Future call() async { - callCount += 1; - final now = DateTime(2026, 5, 15); - return AlarmReconciliationResult( - status: AlarmReconciliationStatus.armed, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, - fallbackProvider: AlarmProvider.localNotification, - armedScheduleIds: const [], - skippedScheduleCount: 0, - failures: const [], - scheduleWindowStart: now, - scheduleWindowEnd: now.add(const Duration(days: 1)), - alarmCoverageStart: now, - alarmCoverageEnd: now.add(const Duration(hours: 1)), - ); - } - - @override - noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } diff --git a/test/presentation/app/bloc/schedule/schedule_bloc_test.dart b/test/presentation/app/bloc/schedule/schedule_bloc_test.dart index 8578f5d7..e5c028f6 100644 --- a/test/presentation/app/bloc/schedule/schedule_bloc_test.dart +++ b/test/presentation/app/bloc/schedule/schedule_bloc_test.dart @@ -791,7 +791,7 @@ void main() { ), ], ); - expect(schedule.preparationStartTime, now); + expect(schedule.preparationStartTime, now.toUtc()); bloc.add(ScheduleUpcomingReceived(schedule)); await Future.delayed(Duration.zero); @@ -819,7 +819,7 @@ void main() { ), ], ); - expect(schedule.preparationStartTime, now); + expect(schedule.preparationStartTime, now.toUtc()); markEarlySessionUseCase.sessions['early-boundary'] = now.subtract( const Duration(minutes: 1), ); @@ -1129,7 +1129,7 @@ void main() { bloc.add(const ScheduleStepSkipped()); await Future.delayed(Duration.zero); - expect(saveUseCase.calls.last.$4, startedAt); + expect(saveUseCase.calls.last.$4, startedAt.toUtc()); expect(saveUseCase.calls.last.$5, [ PreparationActionEventEntity.skipStep(stepId: 's1', occurredAt: now), ]); diff --git a/test/presentation/app/cubit/alarm_gate_cubit_test.dart b/test/presentation/app/cubit/alarm_gate_cubit_test.dart index 949f5c9f..19d5b1e5 100644 --- a/test/presentation/app/cubit/alarm_gate_cubit_test.dart +++ b/test/presentation/app/cubit/alarm_gate_cubit_test.dart @@ -410,14 +410,6 @@ class _FakeAlarmRepository implements AlarmRepository { return AlarmSettings(alarmsEnabled: alarmsEnabled); } - @override - Future getDeviceId() async => 'device-1'; - - @override - Future buildCurrentDeviceInfo() { - throw UnimplementedError(); - } - @override Future getAlarmSettings() { throw UnimplementedError(); @@ -430,21 +422,6 @@ class _FakeAlarmRepository implements AlarmRepository { ) { throw UnimplementedError(); } - - @override - Future postAlarmStatus(AlarmStatusReport report) { - throw UnimplementedError(); - } - - @override - Future registerCurrentDevice(AlarmDeviceInfo deviceInfo) { - throw UnimplementedError(); - } - - @override - Future unregisterCurrentDevice(String deviceId) { - throw UnimplementedError(); - } } class _FakeReconcileAlarmsUseCase implements ReconcileAlarmsUseCase { @@ -476,7 +453,7 @@ class _FakeCancelAllAlarmsUseCase implements CancelAllAlarmsUseCase { int callCount = 0; @override - Future call({bool unregisterDevice = false}) async { + Future call() async { callCount += 1; } diff --git a/test/presentation/app/cubit/analytics_preference_cubit_test.dart b/test/presentation/app/cubit/analytics_preference_cubit_test.dart deleted file mode 100644 index 0813929f..00000000 --- a/test/presentation/app/cubit/analytics_preference_cubit_test.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/core/services/app_metadata_service.dart'; -import 'package:on_time_front/core/services/product_analytics_service.dart'; -import 'package:on_time_front/domain/entities/analytics_preference.dart'; -import 'package:on_time_front/domain/repositories/analytics_preference_repository.dart'; -import 'package:on_time_front/domain/use-cases/load_analytics_preference_use_case.dart'; -import 'package:on_time_front/domain/use-cases/update_analytics_preference_use_case.dart'; -import 'package:on_time_front/presentation/app/cubit/analytics_preference_cubit.dart'; - -void main() { - test( - 'load fails closed when signed-in account preference cannot be loaded', - () async { - final repository = _FakeAnalyticsPreferenceRepository() - ..localPreference = const AnalyticsPreference(enabled: true) - ..loadAccountError = Exception('backend unavailable'); - final cubit = AnalyticsPreferenceCubit( - loadPreferenceUseCase: LoadAnalyticsPreferenceUseCase(repository), - updatePreferenceUseCase: UpdateAnalyticsPreferenceUseCase(repository), - analyticsService: ProductAnalyticsService( - client: _FakeAnalyticsProviderClient(), - appMetadataProvider: _FakeAppMetadataProvider(), - collectionAllowedInBuild: true, - ), - ); - addTearDown(cubit.close); - - await cubit.load(signedIn: true); - - expect(cubit.state.status, AnalyticsPreferenceStatus.failure); - expect(cubit.state.enabled, isFalse); - expect(cubit.state.canEmitEvents, isFalse); - }, - ); - - test( - 'load applies confirmed enabled preference to analytics service', - () async { - final client = _FakeAnalyticsProviderClient(); - final repository = _FakeAnalyticsPreferenceRepository() - ..localPreference = const AnalyticsPreference(enabled: true) - ..accountPreference = const AnalyticsPreference(enabled: true); - final cubit = AnalyticsPreferenceCubit( - loadPreferenceUseCase: LoadAnalyticsPreferenceUseCase(repository), - updatePreferenceUseCase: UpdateAnalyticsPreferenceUseCase(repository), - analyticsService: ProductAnalyticsService( - client: client, - appMetadataProvider: _FakeAppMetadataProvider(), - collectionAllowedInBuild: true, - ), - ); - addTearDown(cubit.close); - - await cubit.load(signedIn: true); - - expect(cubit.state.canEmitEvents, isTrue); - expect(client.collectionEnabledValues, [true]); - }, - ); -} - -class _FakeAnalyticsPreferenceRepository - implements AnalyticsPreferenceRepository { - AnalyticsPreference localPreference = const AnalyticsPreference( - enabled: false, - ); - AnalyticsPreference accountPreference = const AnalyticsPreference( - enabled: false, - ); - Object? loadAccountError; - - @override - Future loadLocalPreference() async => localPreference; - - @override - Future saveLocalPreference(bool enabled) async { - localPreference = AnalyticsPreference(enabled: enabled); - } - - @override - Future loadAccountPreference() async { - final error = loadAccountError; - if (error != null) throw error; - return accountPreference; - } - - @override - Future updateAccountPreference(bool enabled) async { - accountPreference = AnalyticsPreference(enabled: enabled); - return accountPreference; - } -} - -class _FakeAnalyticsProviderClient implements AnalyticsProviderClient { - final collectionEnabledValues = []; - - @override - Future setAnalyticsCollectionEnabled(bool enabled) async { - collectionEnabledValues.add(enabled); - } - - @override - Future logEvent({ - required String name, - required Map parameters, - }) async {} - - @override - Future setUserId(String? userId) async {} -} - -class _FakeAppMetadataProvider implements AppMetadataProvider { - @override - Future getMetadata() async { - return const AppMetadata(version: '9.8.7', buildNumber: '654'); - } -} diff --git a/test/presentation/app/cubit/notification_gate_cubit_test.dart b/test/presentation/app/cubit/notification_gate_cubit_test.dart index a13f9918..0434287f 100644 --- a/test/presentation/app/cubit/notification_gate_cubit_test.dart +++ b/test/presentation/app/cubit/notification_gate_cubit_test.dart @@ -1,4 +1,3 @@ -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/core/services/notification_service.dart'; import 'package:on_time_front/presentation/app/cubit/notification_gate_cubit.dart'; diff --git a/test/presentation/calendar/bloc/monthly_schedules_bloc_test.dart b/test/presentation/calendar/bloc/monthly_schedules_bloc_test.dart index 4746fc9f..d53bce73 100644 --- a/test/presentation/calendar/bloc/monthly_schedules_bloc_test.dart +++ b/test/presentation/calendar/bloc/monthly_schedules_bloc_test.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:dio/dio.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'; @@ -777,19 +776,7 @@ void main() { 'delete failure keeps calendar state and emits a delete failure signal', () async { deleteScheduleUseCase = StubDeleteScheduleUseCase( - (_) async => throw DioException( - requestOptions: RequestOptions(path: '/schedules/schedule-a'), - response: Response( - requestOptions: RequestOptions(path: '/schedules/schedule-a'), - statusCode: 409, - data: { - 'status': 'error', - 'code': 'SCHEDULE_ALREADY_FINISHED', - 'message': 'Finished schedules cannot be deleted.', - 'data': null, - }, - ), - ), + (_) async => throw StateError('Finished schedules cannot be deleted.'), ); final bloc = buildBloc(); diff --git a/test/presentation/home/screens/home_screen_tmp_test.dart b/test/presentation/home/screens/home_screen_tmp_test.dart index bf3a8f59..4b56924d 100644 --- a/test/presentation/home/screens/home_screen_tmp_test.dart +++ b/test/presentation/home/screens/home_screen_tmp_test.dart @@ -105,11 +105,10 @@ void main() { AuthState( user: UserEntity( id: 'user-1', - name: 'Test User', - email: 'test@example.com', spareTime: Duration.zero, note: '', - score: 80, + eligibleOutcomeCount: 5, + onTimeOutcomeCount: 4, isOnboardingCompleted: true, ), ), @@ -203,11 +202,10 @@ void main() { AuthState( user: UserEntity( id: 'user-1', - name: 'Test User', - email: 'test@example.com', spareTime: Duration.zero, note: '', - score: 80, + eligibleOutcomeCount: 5, + onTimeOutcomeCount: 4, isOnboardingCompleted: true, ), ), @@ -538,11 +536,10 @@ Widget _buildRoutedSubject({ AuthState( user: UserEntity( id: 'user-1', - name: 'Test User', - email: 'test@example.com', spareTime: Duration.zero, note: '', - score: 80, + eligibleOutcomeCount: 5, + onTimeOutcomeCount: 4, isOnboardingCompleted: true, ), ), diff --git a/test/presentation/login/screens/sign_in_main_screen_test.dart b/test/presentation/login/screens/sign_in_main_screen_test.dart deleted file mode 100644 index d6f1e701..00000000 --- a/test/presentation/login/screens/sign_in_main_screen_test.dart +++ /dev/null @@ -1,176 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/core/di/di_setup.dart'; -import 'package:on_time_front/core/services/google_authentication_service.dart'; -import 'package:on_time_front/domain/entities/google_auth_credential.dart'; -import 'package:on_time_front/domain/entities/user_entity.dart'; -import 'package:on_time_front/domain/repositories/user_repository.dart'; -import 'package:on_time_front/l10n/app_localizations.dart'; -import 'package:on_time_front/presentation/login/screens/sign_in_main_screen.dart'; -import 'package:on_time_front/presentation/shared/components/modal_wide_button.dart'; -import 'package:on_time_front/presentation/shared/theme/theme.dart'; - -void main() { - setUp(() async { - await getIt.reset(); - }); - - tearDown(() async { - await getIt.reset(); - }); - - testWidgets( - 'social sign-in buttons stay visible and ignore taps while session is pending', - (tester) async { - final signInCompleter = Completer(); - var signInAttempts = 0; - - await _pumpSubject( - tester, - onGoogleSignIn: () { - signInAttempts += 1; - return signInCompleter.future; - }, - ); - - await tester.tap(find.text('Sign in with Google')); - await tester.pump(); - await tester.tap(find.text('Sign in with Google')); - await tester.pump(); - - expect(signInAttempts, 1); - expect(find.byType(CircularProgressIndicator), findsNothing); - expect(find.text('Sign in with Google'), findsOneWidget); - final googleButton = tester.widget( - find.byType(ElevatedButton), - ); - expect( - googleButton.style?.backgroundColor?.resolve({WidgetState.disabled}), - Colors.white, - ); - expect( - googleButton.style?.foregroundColor?.resolve({WidgetState.disabled}), - Colors.black, - ); - }, - ); - - testWidgets('failed social sign-in restores buttons and shows error dialog', ( - tester, - ) async { - await _pumpSubject( - tester, - onGoogleSignIn: () async => throw Exception('backend failed'), - ); - - await tester.tap(find.text('Sign in with Google')); - await tester.pumpAndSettle(); - - expect(find.text('로그인에 실패했어요'), findsOneWidget); - expect(find.text('잠시 후 다시 시도해 주세요.'), findsOneWidget); - expect(find.text('Sign in with Google'), findsOneWidget); - expect( - tester.widget(find.byType(ModalWideButton)).variant, - ModalWideButtonVariant.destructive, - ); - }); - - testWidgets( - 'canceled social sign-in restores buttons without showing error dialog', - (tester) async { - await _pumpSubject( - tester, - onGoogleSignIn: () async => - throw const GoogleAuthenticationCanceledException(), - ); - - await tester.tap(find.text('Sign in with Google')); - await tester.pumpAndSettle(); - - expect(find.text('로그인에 실패했어요'), findsNothing); - expect(find.text('Sign in with Google'), findsOneWidget); - }, - ); - - testWidgets( - 'default Google sign-in establishes OnTime session with provider credential', - (tester) async { - const credential = GoogleAuthCredential(idToken: 'google-id-token'); - final googleAuthenticationService = _FakeGoogleAuthenticationService( - credential, - ); - final userRepository = _FakeUserRepository(); - getIt.registerSingleton( - googleAuthenticationService, - ); - getIt.registerSingleton(userRepository); - - await _pumpSubject(tester); - - await tester.tap(find.text('Sign in with Google')); - await tester.pumpAndSettle(); - - expect(googleAuthenticationService.authenticateCount, 1); - expect(userRepository.googleCredentials, [credential]); - expect(find.text('로그인에 실패했어요'), findsNothing); - }, - ); -} - -Future _pumpSubject( - WidgetTester tester, { - Future Function()? onGoogleSignIn, -}) async { - await tester.pumpWidget( - MaterialApp( - theme: themeData, - locale: const Locale('ko'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: SignInMainScreen(onGoogleSignIn: onGoogleSignIn), - ), - ); -} - -class _FakeGoogleAuthenticationService implements GoogleAuthenticationService { - _FakeGoogleAuthenticationService(this.credential); - - final GoogleAuthCredential credential; - int authenticateCount = 0; - - @override - Stream get authenticationCredentials => - const Stream.empty(); - - @override - bool get supportsAuthenticate => true; - - @override - Future authenticate() async { - authenticateCount += 1; - return credential; - } - - @override - Future disconnect() async {} - - @override - Future initialize() async {} -} - -class _FakeUserRepository implements UserRepository { - final googleCredentials = []; - - @override - Stream get userStream => const Stream.empty(); - - @override - Future signInWithGoogle(GoogleAuthCredential credential) async { - googleCredentials.add(credential); - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} diff --git a/test/presentation/my_page/delete_user_modal_test.dart b/test/presentation/my_page/delete_user_modal_test.dart deleted file mode 100644 index 20157588..00000000 --- a/test/presentation/my_page/delete_user_modal_test.dart +++ /dev/null @@ -1,302 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:on_time_front/domain/entities/google_auth_credential.dart'; -import 'package:on_time_front/domain/entities/user_entity.dart'; -import 'package:on_time_front/domain/repositories/user_repository.dart'; -import 'package:on_time_front/domain/use-cases/delete_user_use_case.dart'; -import 'package:on_time_front/l10n/app_localizations.dart'; -import 'package:on_time_front/presentation/my_page/my_page_modal/delete_user_modal.dart'; -import 'package:on_time_front/presentation/shared/components/two_action_dialog.dart'; -import 'package:on_time_front/presentation/shared/theme/theme.dart'; - -void main() { - testWidgets('opens feedback dialog after confirming delete account', ( - tester, - ) async { - final repository = _FakeUserRepository(); - final modal = DeleteUserModal( - deleteUserUseCase: DeleteUserUseCase(repository), - userRepository: repository, - ); - - await _pumpDeleteModal(tester, modal: modal); - - await tester.tap(find.text('open')); - await tester.pumpAndSettle(); - - expect(find.text('정말 탈퇴하시나요?'), findsOneWidget); - expect(find.textContaining('현재 로그인한 계정의 탈퇴'), findsOneWidget); - expect(find.text('계속 사용할게요'), findsOneWidget); - expect(find.text('그래도 탈퇴할게요'), findsOneWidget); - - await tester.tap(find.text('그래도 탈퇴할게요')); - await tester.pumpAndSettle(); - - expect(find.text('더 좋은 서비스로 다시 만나요'), findsOneWidget); - expect(find.textContaining('계정 탈퇴 요청이 전송됩니다'), findsOneWidget); - expect(find.text('탈퇴하지 않고 계속 사용하기'), findsOneWidget); - expect(find.text('의견 보내고 탈퇴하기'), findsOneWidget); - }); - - testWidgets('cancels from the first confirmation dialog', (tester) async { - var didConfirm = false; - final repository = _FakeUserRepository(); - final modal = DeleteUserModal( - deleteUserUseCase: DeleteUserUseCase(repository), - userRepository: repository, - ); - - await _pumpDeleteModal( - tester, - modal: modal, - onConfirm: () => didConfirm = true, - ); - - await tester.tap(find.text('open')); - await tester.pumpAndSettle(); - await tester.tap(find.text('계속 사용할게요')); - await tester.pumpAndSettle(); - - expect(find.text('더 좋은 서비스로 다시 만나요'), findsNothing); - expect(repository.deletedNormalFeedback, isNull); - expect(didConfirm, isFalse); - }); - - testWidgets('cancels from the feedback dialog without deleting', ( - tester, - ) async { - var didConfirm = false; - final repository = _FakeUserRepository(); - final modal = DeleteUserModal( - deleteUserUseCase: DeleteUserUseCase(repository), - userRepository: repository, - ); - - await _pumpDeleteModal( - tester, - modal: modal, - onConfirm: () => didConfirm = true, - ); - await _openFeedbackDialog(tester); - - await tester.tap(find.text('탈퇴하지 않고 계속 사용하기')); - await tester.pumpAndSettle(); - - expect(repository.deletedNormalFeedback, isNull); - expect(repository.didSignOut, isFalse); - expect(didConfirm, isFalse); - expect(find.text('더 좋은 서비스로 다시 만나요'), findsNothing); - }); - - testWidgets('shows loading state while deletion is in progress', ( - tester, - ) async { - var didConfirm = false; - final deleteCompleter = Completer(); - final repository = _FakeUserRepository(deleteCompleter: deleteCompleter); - final modal = DeleteUserModal( - deleteUserUseCase: DeleteUserUseCase(repository), - userRepository: repository, - ); - - await _pumpDeleteModal( - tester, - modal: modal, - onConfirm: () => didConfirm = true, - ); - await _openFeedbackDialog(tester); - - await tester.enterText(find.byType(TextField), 'Need a reset'); - await tester.tap(find.text('의견 보내고 탈퇴하기')); - await tester.pump(); - - expect(find.byType(CircularProgressIndicator), findsOneWidget); - expect(repository.deletedNormalFeedback, isNull); - - await tester.tap(find.text('탈퇴하지 않고 계속 사용하기')); - await tester.pump(); - expect(find.text('더 좋은 서비스로 다시 만나요'), findsOneWidget); - - deleteCompleter.complete(); - await tester.pumpAndSettle(); - - expect(repository.deletedNormalFeedback, 'Need a reset'); - expect(repository.didSignOut, isTrue); - expect(didConfirm, isTrue); - }); - - testWidgets( - 'keeps feedback dialog open and shows error when deletion fails', - (tester) async { - var didConfirm = false; - final repository = _FakeUserRepository(deleteError: Exception('failed')); - final modal = DeleteUserModal( - deleteUserUseCase: DeleteUserUseCase(repository), - userRepository: repository, - ); - - await _pumpDeleteModal( - tester, - modal: modal, - onConfirm: () => didConfirm = true, - ); - await _openFeedbackDialog(tester); - - await tester.tap(find.text('의견 보내고 탈퇴하기')); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 200)); - - expect(find.byType(TwoActionDialog), findsOneWidget); - expect(find.text('오류'), findsOneWidget); - expect(find.text('더 좋은 서비스로 다시 만나요'), findsOneWidget); - - await tester.tap(find.text('확인')); - await tester.pumpAndSettle(); - - expect(find.byType(TwoActionDialog), findsNothing); - expect(find.text('더 좋은 서비스로 다시 만나요'), findsOneWidget); - expect(find.byType(CircularProgressIndicator), findsNothing); - expect(repository.didSignOut, isFalse); - expect(didConfirm, isFalse); - }, - ); - - testWidgets('deletes account, signs out, and calls confirm on success', ( - tester, - ) async { - var didConfirm = false; - final repository = _FakeUserRepository(); - final modal = DeleteUserModal( - deleteUserUseCase: DeleteUserUseCase(repository), - userRepository: repository, - ); - - await _pumpDeleteModal( - tester, - modal: modal, - onConfirm: () => didConfirm = true, - ); - await _openFeedbackDialog(tester); - - await tester.enterText(find.byType(TextField), 'No longer needed'); - await tester.tap(find.text('의견 보내고 탈퇴하기')); - await tester.pumpAndSettle(); - - expect(repository.deletedNormalFeedback, 'No longer needed'); - expect(repository.didSignOut, isTrue); - expect(didConfirm, isTrue); - expect(find.text('더 좋은 서비스로 다시 만나요'), findsNothing); - }); -} - -Future _pumpDeleteModal( - WidgetTester tester, { - required DeleteUserModal modal, - VoidCallback? onConfirm, -}) async { - await tester.pumpWidget( - MaterialApp( - theme: themeData, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - locale: const Locale('ko'), - home: Builder( - builder: (context) { - return Scaffold( - body: Center( - child: TextButton( - onPressed: () async { - await modal.showDeleteUserModal( - context, - onConfirm: onConfirm ?? () {}, - ); - }, - child: const Text('open'), - ), - ), - ); - }, - ), - ), - ); -} - -Future _openFeedbackDialog(WidgetTester tester) async { - await tester.tap(find.text('open')); - await tester.pumpAndSettle(); - await tester.tap(find.text('그래도 탈퇴할게요')); - await tester.pumpAndSettle(); -} - -class _FakeUserRepository implements UserRepository { - _FakeUserRepository({this.deleteCompleter, this.deleteError}); - - final Completer? deleteCompleter; - final Object? deleteError; - String? deletedNormalFeedback; - bool didSignOut = false; - - @override - Stream get userStream => const Stream.empty(); - - @override - Future deleteAppleUser({String? feedbackMessage}) => - throw UnimplementedError(); - - @override - Future deleteGoogleUser({String? feedbackMessage}) => - throw UnimplementedError(); - - @override - Future deleteUser({String? feedbackMessage}) async { - if (deleteError != null) { - throw deleteError!; - } - if (deleteCompleter != null) { - await deleteCompleter!.future; - } - deletedNormalFeedback = feedbackMessage; - } - - @override - Future disconnectGoogleSignIn() => throw UnimplementedError(); - - @override - Future getUserSocialType() async => null; - - @override - Future getUser() => throw UnimplementedError(); - - @override - Future postFeedback(String message) => throw UnimplementedError(); - - @override - Future signIn({required String email, required String password}) => - throw UnimplementedError(); - - @override - Future signInWithApple({ - required String idToken, - required String authCode, - required String fullName, - String? email, - }) => throw UnimplementedError(); - - @override - Future signInWithGoogle(GoogleAuthCredential credential) => - throw UnimplementedError(); - - @override - Future signOut() async { - didSignOut = true; - } - - @override - Future signUp({ - required String email, - required String password, - required String name, - }) => throw UnimplementedError(); -} diff --git a/test/presentation/my_page/logout_modal_test.dart b/test/presentation/my_page/logout_modal_test.dart deleted file mode 100644 index d083e9f2..00000000 --- a/test/presentation/my_page/logout_modal_test.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:on_time_front/domain/entities/user_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/my_page/my_page_modal/logout_modal.dart'; -import 'package:on_time_front/presentation/shared/theme/theme.dart'; - -void main() { - testWidgets('confirming logout dispatches sign-out event', (tester) async { - final authBloc = _RecordingAuthBloc(); - - await _pumpSubject(tester, authBloc); - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - - expect(find.text('Do you want to log out?'), findsOneWidget); - - await tester.tap(find.text('Log out')); - await tester.pumpAndSettle(); - - expect(authBloc.events, [const AuthSignOutPressed()]); - }); - - testWidgets('canceling logout keeps auth bloc untouched', (tester) async { - final authBloc = _RecordingAuthBloc(); - - await _pumpSubject(tester, authBloc); - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - - expect(authBloc.events, isEmpty); - expect(find.text('Do you want to log out?'), findsNothing); - }); -} - -Future _pumpSubject(WidgetTester tester, AuthBloc authBloc) async { - await tester.pumpWidget( - BlocProvider.value( - value: authBloc, - child: MaterialApp( - theme: themeData, - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Builder( - builder: (context) { - return Scaffold( - body: TextButton( - onPressed: () => showLogoutModal(context), - child: const Text('Open'), - ), - ); - }, - ), - ), - ), - ); -} - -class _RecordingAuthBloc extends Mock implements AuthBloc { - final events = []; - - @override - AuthState get state => AuthState( - user: const UserEntity( - id: 'user-1', - email: 'user@example.com', - name: 'User', - spareTime: Duration(minutes: 10), - note: '', - score: 4, - isOnboardingCompleted: true, - ), - ); - - @override - Stream get stream => const Stream.empty(); - - @override - bool get isClosed => false; - - @override - void add(AuthEvent event) { - events.add(event); - } -} diff --git a/test/presentation/my_page/my_page_screen_test.dart b/test/presentation/my_page/my_page_screen_test.dart deleted file mode 100644 index 3805641c..00000000 --- a/test/presentation/my_page/my_page_screen_test.dart +++ /dev/null @@ -1,947 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:mockito/mockito.dart'; -import 'package:on_time_front/core/constants/external_links.dart'; -import 'package:on_time_front/core/di/di_setup.dart'; -import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; -import 'package:on_time_front/core/services/app_metadata_service.dart'; -import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; -import 'package:on_time_front/core/services/notification_service.dart'; -import 'package:on_time_front/core/services/product_analytics_service.dart'; -import 'package:on_time_front/domain/entities/alarm_entities.dart'; -import 'package:on_time_front/domain/entities/analytics_preference.dart'; -import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; -import 'package:on_time_front/domain/entities/user_entity.dart'; -import 'package:on_time_front/domain/repositories/analytics_preference_repository.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/load_analytics_preference_use_case.dart'; -import 'package:on_time_front/domain/use-cases/update_analytics_preference_use_case.dart'; -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/app/bloc/auth/auth_bloc.dart'; -import 'package:on_time_front/presentation/app/cubit/analytics_preference_cubit.dart'; -import 'package:on_time_front/presentation/my_page/my_page_screen.dart'; -import 'package:on_time_front/presentation/shared/theme/theme.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - setUp(() async { - await getIt.reset(); - final alarmRepository = _FakeAlarmRepository(); - final alarmRegistry = _FakeAlarmRegistry(); - final alarmScheduler = _FakeAlarmSchedulerService(); - final fallbackAlarmNotificationService = - _FakeFallbackAlarmNotificationService(); - getIt - ..registerSingleton(alarmRepository) - ..registerSingleton(alarmRegistry) - ..registerSingleton(alarmScheduler) - ..registerSingleton( - fallbackAlarmNotificationService, - ) - ..registerSingleton( - _FakeCancelAllAlarmsUseCase( - alarmRepository, - alarmRegistry, - alarmScheduler, - fallbackAlarmNotificationService, - ), - ) - ..registerSingleton( - _FakeReconcileAlarmsUseCase( - alarmRepository, - alarmRegistry, - alarmScheduler, - fallbackAlarmNotificationService, - ), - ); - }); - - tearDown(() async { - await getIt.reset(); - }); - - testWidgets('shows English privacy policy setting', (tester) async { - await _pumpMyPage(tester, locale: const Locale('en')); - - expect(find.text('Privacy Policy'), findsOneWidget); - }); - - testWidgets('shows Korean privacy policy setting', (tester) async { - await _pumpMyPage(tester, locale: const Locale('ko')); - - expect(find.text('개인정보 처리방침'), findsOneWidget); - }); - - testWidgets('shows loaded Help improve OnTime preference switch', ( - tester, - ) async { - final analyticsRepository = _FakeAnalyticsPreferenceRepository() - ..localPreference = const AnalyticsPreference(enabled: true) - ..accountPreference = const AnalyticsPreference(enabled: true); - final analyticsCubit = AnalyticsPreferenceCubit( - loadPreferenceUseCase: LoadAnalyticsPreferenceUseCase( - analyticsRepository, - ), - updatePreferenceUseCase: UpdateAnalyticsPreferenceUseCase( - analyticsRepository, - ), - analyticsService: ProductAnalyticsService( - client: _FakeAnalyticsProviderClient(), - appMetadataProvider: _FakeAppMetadataProvider(), - collectionAllowedInBuild: true, - ), - ); - - await _pumpMyPage( - tester, - locale: const Locale('en'), - authState: AuthState(user: _authenticatedUser), - analyticsPreferenceCubit: analyticsCubit, - ); - - expect(find.text('Help improve OnTime'), findsOneWidget); - expect( - tester.widget(find.byKey(const Key('analyticsPreferenceSwitch'))), - isA().having((switchWidget) => switchWidget.value, 'value', true), - ); - }); - - testWidgets('opens hosted privacy policy URL from setting', (tester) async { - final openedUris = []; - - await _pumpMyPage( - tester, - locale: const Locale('en'), - openPrivacyPolicy: (uri) async { - openedUris.add(uri); - return true; - }, - ); - - await tester.ensureVisible(find.text('Privacy Policy')); - await tester.tap(find.text('Privacy Policy')); - await tester.pumpAndSettle(); - - expect(openedUris, [ExternalLinks.privacyPolicyUri]); - }); - - testWidgets('shows an error dialog when privacy policy cannot open', ( - tester, - ) async { - await _pumpMyPage( - tester, - locale: const Locale('en'), - openPrivacyPolicy: (_) async => false, - ); - - await tester.ensureVisible(find.text('Privacy Policy')); - await tester.tap(find.text('Privacy Policy')); - await tester.pumpAndSettle(); - - expect(find.text('Error'), findsOneWidget); - expect(find.textContaining('privacy policy'), findsOneWidget); - }); - - testWidgets('shows already-enabled dialog when notifications are allowed', ( - tester, - ) async { - final notificationService = _FakeNotificationService( - currentStatus: AuthorizationStatus.authorized, - ); - - await _pumpMyPage( - tester, - locale: const Locale('en'), - notificationService: notificationService, - ); - - await tester.ensureVisible(find.text('Allow App Notifications')); - await tester.tap(find.text('Allow App Notifications')); - await tester.pumpAndSettle(); - - expect(find.text('Notification Already Enabled'), findsOneWidget); - expect(notificationService.requestCount, 0); - expect(notificationService.initializeCount, 0); - - await tester.tap(find.text('OK')); - await tester.pumpAndSettle(); - - expect(find.text('Notification Already Enabled'), findsNothing); - }); - - testWidgets('cancels notification permission request from rationale dialog', ( - tester, - ) async { - final notificationService = _FakeNotificationService( - currentStatus: AuthorizationStatus.denied, - ); - - await _pumpMyPage( - tester, - locale: const Locale('en'), - notificationService: notificationService, - ); - - await tester.ensureVisible(find.text('Allow App Notifications')); - await tester.tap(find.text('Allow App Notifications')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - - expect(notificationService.requestCount, 0); - expect(notificationService.initializeCount, 0); - expect(notificationService.openSettingsCount, 0); - }); - - testWidgets( - 'granting notification permission initializes notifications and confirms', - (tester) async { - final notificationService = _FakeNotificationService( - currentStatus: AuthorizationStatus.notDetermined, - requestedStatus: AuthorizationStatus.authorized, - ); - - await _pumpMyPage( - tester, - locale: const Locale('en'), - notificationService: notificationService, - ); - - await tester.ensureVisible(find.text('Allow App Notifications')); - await tester.tap(find.text('Allow App Notifications')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Allow')); - await tester.pumpAndSettle(); - - expect(notificationService.requestCount, 1); - expect(notificationService.initializeCount, 1); - expect(find.text('Notification Permission Granted'), findsOneWidget); - }, - ); - - testWidgets('denied notification permission can open app settings', ( - tester, - ) async { - final notificationService = _FakeNotificationService( - currentStatus: AuthorizationStatus.denied, - requestedStatus: AuthorizationStatus.denied, - ); - - await _pumpMyPage( - tester, - locale: const Locale('en'), - notificationService: notificationService, - ); - - await tester.ensureVisible(find.text('Allow App Notifications')); - await tester.tap(find.text('Allow App Notifications')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Allow')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Open Settings')); - await tester.pumpAndSettle(); - - expect(notificationService.requestCount, 1); - expect(notificationService.initializeCount, 0); - expect(notificationService.openSettingsCount, 1); - }); - - testWidgets('provisional notification permission opens settings dialog', ( - tester, - ) async { - final notificationService = _FakeNotificationService( - currentStatus: AuthorizationStatus.provisional, - ); - - await _pumpMyPage( - tester, - locale: const Locale('en'), - notificationService: notificationService, - ); - - await tester.ensureVisible(find.text('Allow App Notifications')); - await tester.tap(find.text('Allow App Notifications')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Open Settings')); - await tester.pumpAndSettle(); - - expect(notificationService.requestCount, 0); - expect(notificationService.openSettingsCount, 1); - }); - - testWidgets( - 'enables schedule notifications when Android exact timing is denied', - (tester) async { - final alarmRepository = - getIt.get() as _FakeAlarmRepository; - final alarmScheduler = - getIt.get() as _FakeAlarmSchedulerService; - final fallbackService = - getIt.get() - as _FakeFallbackAlarmNotificationService; - final reconcileUseCase = - getIt.get() as _FakeReconcileAlarmsUseCase; - alarmRepository.settings = const AlarmSettings(alarmsEnabled: false); - alarmScheduler - ..capabilities = const AlarmSchedulerCapabilities( - supportsNativeAlarm: true, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, - ) - ..permission = AlarmPermissionState.denied; - fallbackService.permission = AlarmPermissionState.granted; - - await _pumpMyPage(tester, locale: const Locale('en')); - - await tester.tap(find.byKey(const Key('alarmSettingsSwitch'))); - await tester.pumpAndSettle(); - - expect(find.text('Precise notification permission needed'), findsNothing); - expect(alarmScheduler.requestCount, 0); - expect(alarmRepository.updatedSettings, [true]); - expect(fallbackService.requestCount, 1); - expect(reconcileUseCase.callCount, 1); - expect( - tester - .widget(find.byKey(const Key('alarmSettingsSwitch'))) - .value, - isTrue, - ); - }, - ); - - testWidgets( - 'enabling alarms can recover approved native alarm permission through settings', - (tester) async { - final alarmRepository = - getIt.get() as _FakeAlarmRepository; - final alarmScheduler = - getIt.get() as _FakeAlarmSchedulerService; - final fallbackService = - getIt.get() - as _FakeFallbackAlarmNotificationService; - final reconcileUseCase = - getIt.get() as _FakeReconcileAlarmsUseCase; - alarmRepository.settings = const AlarmSettings(alarmsEnabled: false); - alarmScheduler - ..capabilities = const AlarmSchedulerCapabilities( - supportsNativeAlarm: true, - nativeAlarmProvider: AlarmProvider.iosAlarmKit, - ) - ..permission = AlarmPermissionState.denied - ..permissionAfterRequest = AlarmPermissionState.granted; - fallbackService.permission = AlarmPermissionState.denied; - - await _pumpMyPage(tester, locale: const Locale('en')); - - await tester.tap(find.byKey(const Key('alarmSettingsSwitch'))); - await tester.pumpAndSettle(); - await tester.tap(find.text('Open Settings')); - await tester.pumpAndSettle(); - - expect(alarmScheduler.requestCount, 1); - expect(alarmRepository.updatedSettings, [true]); - expect(fallbackService.requestCount, 1); - expect(reconcileUseCase.callCount, 1); - expect( - tester - .widget(find.byKey(const Key('alarmSettingsSwitch'))) - .value, - isTrue, - ); - }, - ); - - testWidgets('shows authenticated user account information', (tester) async { - await _pumpMyPage( - tester, - locale: const Locale('en'), - authState: AuthState( - user: const UserEntity( - id: 'user-1', - email: 'user@example.com', - name: 'User Name', - spareTime: Duration(minutes: 10), - note: '', - score: 4.5, - isOnboardingCompleted: true, - ), - ), - ); - - expect(find.text('User Name'), findsOneWidget); - expect(find.text('user@example.com'), findsOneWidget); - }); - - testWidgets('logout setting shows confirmation and dispatches sign out', ( - tester, - ) async { - final authBloc = _StubAuthBloc(AuthState()); - - await _pumpMyPage(tester, locale: const Locale('en'), authBloc: authBloc); - - await tester.ensureVisible(find.text('Log out')); - await tester.tap(find.text('Log out')); - await tester.pumpAndSettle(); - - expect(find.text('Do you want to log out?'), findsOneWidget); - - await tester.tap(find.text('Log out').last); - await tester.pumpAndSettle(); - - expect(authBloc.addedEvents.single, isA()); - }); - - testWidgets('shows precise notification status for Android alarm manager', ( - tester, - ) async { - final alarmRepository = - getIt.get() as _FakeAlarmRepository; - final alarmRegistry = - getIt.get() as _FakeAlarmRegistry; - alarmRepository.settings = const AlarmSettings(alarmsEnabled: true); - alarmRegistry.records = [ - _alarmRecord(provider: AlarmProvider.androidAlarmManager), - ]; - - await _pumpMyPage(tester, locale: const Locale('ko')); - - expect(find.text('정확한 알림'), findsOneWidget); - expect( - tester.widget(find.byKey(const Key('alarmSettingsSwitch'))).value, - isTrue, - ); - }); - - testWidgets('shows alarm status for iOS AlarmKit records', (tester) async { - final alarmRepository = - getIt.get() as _FakeAlarmRepository; - final alarmRegistry = - getIt.get() as _FakeAlarmRegistry; - alarmRepository.settings = const AlarmSettings(alarmsEnabled: true); - alarmRegistry.records = [_alarmRecord(provider: AlarmProvider.iosAlarmKit)]; - - await _pumpMyPage(tester, locale: const Locale('ko')); - - expect(find.text('알람'), findsOneWidget); - }); - - testWidgets( - 'shows fallback notification status when fallback records exist', - (tester) async { - final alarmRepository = - getIt.get() as _FakeAlarmRepository; - final alarmRegistry = - getIt.get() as _FakeAlarmRegistry; - alarmRepository.settings = const AlarmSettings(alarmsEnabled: true); - alarmRegistry.records = [ - _alarmRecord(provider: AlarmProvider.localNotification), - ]; - - await _pumpMyPage(tester, locale: const Locale('ko')); - - expect(find.text('알림'), findsOneWidget); - }, - ); - - testWidgets( - 'shows notification permission needed when no delivery can be used', - (tester) async { - final alarmRepository = - getIt.get() as _FakeAlarmRepository; - final fallbackService = - getIt.get() - as _FakeFallbackAlarmNotificationService; - alarmRepository.settings = const AlarmSettings(alarmsEnabled: true); - fallbackService.permission = AlarmPermissionState.denied; - - await _pumpMyPage(tester, locale: const Locale('ko')); - - expect(find.text('알림 권한 필요'), findsOneWidget); - }, - ); - - testWidgets( - 'shows permission-needed status when all alarm permissions fail', - (tester) async { - final alarmRepository = - getIt.get() as _FakeAlarmRepository; - final alarmScheduler = - getIt.get() as _FakeAlarmSchedulerService; - final fallbackService = - getIt.get() - as _FakeFallbackAlarmNotificationService; - alarmRepository.settings = const AlarmSettings(alarmsEnabled: true); - alarmScheduler - ..capabilities = const AlarmSchedulerCapabilities( - supportsNativeAlarm: true, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, - ) - ..permission = AlarmPermissionState.denied; - fallbackService.permission = AlarmPermissionState.denied; - - await _pumpMyPage(tester, locale: const Locale('ko')); - - expect(find.text('알림 권한 필요'), findsOneWidget); - }, - ); - - testWidgets('unauthenticated users do not render account identity', ( - tester, - ) async { - await _pumpMyPage(tester, locale: const Locale('en')); - - expect(find.text('User Name'), findsNothing); - expect(find.text('user@example.com'), findsNothing); - }); - - testWidgets('shows load error when alarm settings cannot be read', ( - tester, - ) async { - final alarmRepository = - getIt.get() as _FakeAlarmRepository; - alarmRepository.throwSettings = true; - - await _pumpMyPage(tester, locale: const Locale('ko')); - - expect(find.text('상태를 불러올 수 없음'), findsOneWidget); - }); - - testWidgets('enabling alarms with permission reconciles alarm schedule', ( - tester, - ) async { - final alarmRepository = - getIt.get() as _FakeAlarmRepository; - final alarmScheduler = - getIt.get() as _FakeAlarmSchedulerService; - final fallbackService = - getIt.get() - as _FakeFallbackAlarmNotificationService; - final reconcileUseCase = - getIt.get() as _FakeReconcileAlarmsUseCase; - alarmRepository.settings = const AlarmSettings(alarmsEnabled: false); - alarmScheduler - ..capabilities = const AlarmSchedulerCapabilities( - supportsNativeAlarm: true, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, - ) - ..permission = AlarmPermissionState.granted; - fallbackService.permission = AlarmPermissionState.granted; - - await _pumpMyPage(tester, locale: const Locale('ko')); - await tester.tap(find.byKey(const Key('alarmSettingsSwitch'))); - await tester.pumpAndSettle(); - - expect(alarmRepository.updatedSettings, [true]); - expect(fallbackService.requestCount, 1); - expect(reconcileUseCase.callCount, 1); - expect( - tester.widget(find.byKey(const Key('alarmSettingsSwitch'))).value, - isTrue, - ); - }); - - testWidgets( - 'disabling alarms updates settings and cancels registered alarms', - (tester) async { - final alarmRepository = - getIt.get() as _FakeAlarmRepository; - final cancelAllUseCase = - getIt.get() as _FakeCancelAllAlarmsUseCase; - alarmRepository.settings = const AlarmSettings(alarmsEnabled: true); - - await _pumpMyPage(tester, locale: const Locale('ko')); - await tester.tap(find.byKey(const Key('alarmSettingsSwitch'))); - await tester.pumpAndSettle(); - - expect(alarmRepository.updatedSettings, [false]); - expect(cancelAllUseCase.callCount, 1); - expect( - tester - .widget(find.byKey(const Key('alarmSettingsSwitch'))) - .value, - isFalse, - ); - }, - ); -} - -Future _pumpMyPage( - WidgetTester tester, { - required Locale locale, - PrivacyPolicyLauncher? openPrivacyPolicy, - NotificationService? notificationService, - AnalyticsPreferenceCubit? analyticsPreferenceCubit, - AuthState authState = const AuthState.loading(), - _StubAuthBloc? authBloc, -}) async { - final bloc = authBloc ?? _StubAuthBloc(authState); - final analyticsCubit = - analyticsPreferenceCubit ?? _buildAnalyticsPreferenceCubit(); - addTearDown(analyticsCubit.close); - await tester.pumpWidget( - MaterialApp( - theme: themeData, - locale: locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: BlocProvider.value( - value: bloc, - child: MyPageScreen( - openPrivacyPolicy: openPrivacyPolicy, - notificationService: notificationService, - analyticsPreferenceCubit: analyticsCubit, - ), - ), - ), - ); - await tester.pumpAndSettle(); -} - -AnalyticsPreferenceCubit _buildAnalyticsPreferenceCubit({ - _FakeAnalyticsPreferenceRepository? repository, -}) { - final analyticsRepository = - repository ?? _FakeAnalyticsPreferenceRepository(); - return AnalyticsPreferenceCubit( - loadPreferenceUseCase: LoadAnalyticsPreferenceUseCase(analyticsRepository), - updatePreferenceUseCase: UpdateAnalyticsPreferenceUseCase( - analyticsRepository, - ), - analyticsService: ProductAnalyticsService( - client: _FakeAnalyticsProviderClient(), - appMetadataProvider: _FakeAppMetadataProvider(), - collectionAllowedInBuild: true, - ), - ); -} - -class _StubAuthBloc extends Mock implements AuthBloc { - _StubAuthBloc(this._state); - - final AuthState _state; - final addedEvents = []; - - @override - AuthState get state => _state; - - @override - Stream get stream => const Stream.empty(); - - @override - bool get isClosed => false; - - @override - void add(AuthEvent event) { - addedEvents.add(event); - } -} - -const _authenticatedUser = UserEntity( - id: 'user-1', - email: 'user@example.com', - name: 'User', - spareTime: Duration(minutes: 10), - note: '', - score: 0, - isOnboardingCompleted: true, -); - -class _FakeAnalyticsPreferenceRepository - implements AnalyticsPreferenceRepository { - AnalyticsPreference localPreference = const AnalyticsPreference( - enabled: false, - ); - AnalyticsPreference accountPreference = const AnalyticsPreference( - enabled: false, - ); - - @override - Future loadLocalPreference() async => localPreference; - - @override - Future saveLocalPreference(bool enabled) async { - localPreference = AnalyticsPreference(enabled: enabled); - } - - @override - Future loadAccountPreference() async => - accountPreference; - - @override - Future updateAccountPreference(bool enabled) async { - accountPreference = AnalyticsPreference(enabled: enabled); - return accountPreference; - } -} - -class _FakeAnalyticsProviderClient implements AnalyticsProviderClient { - @override - Future setAnalyticsCollectionEnabled(bool enabled) async {} - - @override - Future logEvent({ - required String name, - required Map parameters, - }) async {} - - @override - Future setUserId(String? userId) async {} -} - -class _FakeNotificationService implements NotificationService { - _FakeNotificationService({ - required this.currentStatus, - this.requestedStatus = AuthorizationStatus.denied, - }); - - AuthorizationStatus currentStatus; - final AuthorizationStatus requestedStatus; - int requestCount = 0; - int initializeCount = 0; - int openSettingsCount = 0; - - @override - Future checkNotificationPermission() async { - return currentStatus; - } - - @override - Future requestPermission() async { - requestCount += 1; - currentStatus = requestedStatus; - return requestedStatus; - } - - @override - Future initialize() async { - initializeCount += 1; - } - - @override - Future openNotificationSettings() async { - openSettingsCount += 1; - return true; - } - - @override - noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -class _FakeAlarmRepository implements AlarmRepository { - AlarmSettings settings = const AlarmSettings(alarmsEnabled: false); - final updatedSettings = []; - bool throwSettings = false; - - @override - Future getDeviceId() => throw UnimplementedError(); - - @override - Future buildCurrentDeviceInfo() => - throw UnimplementedError(); - - @override - Future getAlarmSettings() async { - if (throwSettings) { - throw Exception('settings unavailable'); - } - return settings; - } - - @override - Future updateAlarmSettings({ - required bool alarmsEnabled, - }) async { - updatedSettings.add(alarmsEnabled); - settings = AlarmSettings(alarmsEnabled: alarmsEnabled); - return settings; - } - - @override - Future registerCurrentDevice(AlarmDeviceInfo deviceInfo) { - throw UnimplementedError(); - } - - @override - Future unregisterCurrentDevice(String deviceId) { - throw UnimplementedError(); - } - - @override - Future> getAlarmWindow( - DateTime startDate, - DateTime endDate, - ) { - throw UnimplementedError(); - } - - @override - Future postAlarmStatus(AlarmStatusReport report) { - throw UnimplementedError(); - } -} - -class _FakeAppMetadataProvider implements AppMetadataProvider { - @override - Future getMetadata() async { - return const AppMetadata(version: '9.8.7', buildNumber: '654'); - } -} - -class _FakeAlarmRegistry implements AlarmRegistryRepository { - List records = const []; - - @override - Future> loadAll() async => records; - - @override - Future upsert(ScheduledAlarmRecord record) { - throw UnimplementedError(); - } - - @override - Future deleteByScheduleId(String scheduleId) { - throw UnimplementedError(); - } - - @override - Future deleteAll() { - throw UnimplementedError(); - } - - @override - Future replaceAll(List records) { - throw UnimplementedError(); - } -} - -class _FakeAlarmSchedulerService extends AlarmSchedulerService { - AlarmSchedulerCapabilities capabilities = - AlarmSchedulerCapabilities.unsupported; - AlarmPermissionState permission = AlarmPermissionState.unsupported; - AlarmPermissionState? permissionAfterRequest; - int requestCount = 0; - - @override - Future getCapabilities() async { - return capabilities; - } - - @override - Future checkPermission() async { - return permission; - } - - @override - Future requestPermission() async { - requestCount += 1; - final nextPermission = permissionAfterRequest; - if (nextPermission != null) { - permission = nextPermission; - } - return permission; - } -} - -class _FakeFallbackAlarmNotificationService - implements FallbackAlarmNotificationService { - AlarmPermissionState permission = AlarmPermissionState.unsupported; - int requestCount = 0; - - @override - Future checkPermission() async { - return permission; - } - - @override - Future requestPermission() async { - requestCount += 1; - return permission; - } - - @override - Future scheduleFallbackAlarm(ScheduledAlarmRecord record) { - throw UnimplementedError(); - } - - @override - Future cancelFallbackAlarm(ScheduledAlarmRecord record) { - throw UnimplementedError(); - } -} - -ScheduledAlarmRecord _alarmRecord({required AlarmProvider provider}) { - return ScheduledAlarmRecord( - scheduleId: 'schedule-1', - alarmTime: DateTime(2026, 5, 15, 8), - preparationStartTime: DateTime(2026, 5, 15, 8, 5), - scheduleFingerprint: 'fingerprint', - nativeAlarmId: 1, - fallbackNotificationId: 1, - provider: provider, - scheduleTitle: 'Morning meeting', - payload: const {'type': 'schedule_alarm'}, - ); -} - -class _FakeCancelAllAlarmsUseCase extends CancelAllAlarmsUseCase { - // ignore: use_super_parameters - _FakeCancelAllAlarmsUseCase( - AlarmRepository alarmRepository, - AlarmRegistryRepository registryRepository, - AlarmSchedulerService schedulerService, - FallbackAlarmNotificationService fallbackNotificationService, - ) : super( - alarmRepository, - registryRepository, - schedulerService, - fallbackNotificationService, - ); - - int callCount = 0; - - @override - Future call({bool unregisterDevice = false}) async { - callCount += 1; - } -} - -class _FakeReconcileAlarmsUseCase extends ReconcileAlarmsUseCase { - // ignore: use_super_parameters - _FakeReconcileAlarmsUseCase( - AlarmRepository alarmRepository, - AlarmRegistryRepository registryRepository, - AlarmSchedulerService schedulerService, - FallbackAlarmNotificationService fallbackNotificationService, - ) : super.test( - alarmRepository, - registryRepository, - schedulerService, - fallbackNotificationService, - nowProvider: () => DateTime(2026), - ); - - int callCount = 0; - - @override - Future call() async { - callCount += 1; - return AlarmReconciliationResult( - status: AlarmReconciliationStatus.armed, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, - fallbackProvider: AlarmProvider.localNotification, - armedScheduleIds: const [], - skippedScheduleCount: 0, - failures: const [], - scheduleWindowStart: DateTime(2026), - scheduleWindowEnd: DateTime(2026), - alarmCoverageStart: DateTime(2026), - alarmCoverageEnd: DateTime(2026), - ); - } -} 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 296a5c9b..e20c81fd 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 @@ -382,11 +382,10 @@ class _StubAuthBloc extends Mock implements AuthBloc { AuthState get state => AuthState( user: const UserEntity( id: 'user-1', - email: 'user@example.com', - name: 'User', spareTime: Duration(minutes: 10), note: '', - score: 0, + eligibleOutcomeCount: 0, + onTimeOutcomeCount: 0, isOnboardingCompleted: true, ), ); diff --git a/test/presentation/notification_allow/notification_allow_screen_test.dart b/test/presentation/notification_allow/notification_allow_screen_test.dart index b9af91a4..2deb6989 100644 --- a/test/presentation/notification_allow/notification_allow_screen_test.dart +++ b/test/presentation/notification_allow/notification_allow_screen_test.dart @@ -1,4 +1,3 @@ -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; diff --git a/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart b/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart index 830c83ee..e523f06b 100644 --- a/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart +++ b/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart @@ -244,6 +244,7 @@ void main() { ScheduleFormScheduleDateTimeChanged( scheduleDate: DateTime(2027, 3, 21), scheduleTime: DateTime(2027, 3, 21, 10, 30), + occurrenceOffsetSeconds: 9 * 60 * 60, ), ); bloc.add(const ScheduleFormPlaceNameChanged(placeName: 'New Office')); @@ -331,6 +332,7 @@ void main() { ScheduleFormScheduleDateTimeChanged( scheduleDate: DateTime(2027, 3, 20), scheduleTime: DateTime(2027, 3, 20, 9), + occurrenceOffsetSeconds: 9 * 60 * 60, ), ) ..add(const ScheduleFormPlaceNameChanged(placeName: 'Office')) diff --git a/test/presentation/schedule_create/bloc/schedule_form_state_event_test.dart b/test/presentation/schedule_create/bloc/schedule_form_state_event_test.dart index 28908068..734891af 100644 --- a/test/presentation/schedule_create/bloc/schedule_form_state_event_test.dart +++ b/test/presentation/schedule_create/bloc/schedule_form_state_event_test.dart @@ -27,10 +27,11 @@ void main() { ScheduleFormScheduleDateTimeChanged( scheduleDate: date, scheduleTime: time, + occurrenceOffsetSeconds: 9 * 60 * 60, maxAvailableTime: const Duration(minutes: 20), previousScheduleName: 'Previous', ).props, - [date, time, const Duration(minutes: 20), 'Previous'], + [date, time, 9 * 60 * 60, const Duration(minutes: 20), 'Previous'], ); expect(const ScheduleFormPlaceNameChanged(placeName: 'Office').props, [ 'Office', 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 18269782..180141aa 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 @@ -326,11 +326,10 @@ void main() { AuthState( user: UserEntity( id: 'user-1', - email: 'user@test.com', - name: 'tester', spareTime: const Duration(minutes: 5), note: '', - score: 1, + eligibleOutcomeCount: 1, + onTimeOutcomeCount: 1, isOnboardingCompleted: true, ), ), diff --git a/test/presentation/schedule_create/schedule_date_time/schedule_date_time_cubit_test.dart b/test/presentation/schedule_create/schedule_date_time/schedule_date_time_cubit_test.dart index 93338617..92496523 100644 --- a/test/presentation/schedule_create/schedule_date_time/schedule_date_time_cubit_test.dart +++ b/test/presentation/schedule_create/schedule_date_time/schedule_date_time_cubit_test.dart @@ -332,6 +332,48 @@ void main() { expect(submitted.scheduleTime.minute, 30); expect(submitted.maxAvailableTime, const Duration(minutes: 30)); expect(submitted.previousScheduleName, 'Previous meeting'); + expect(submitted.occurrenceOffsetSeconds, 0); + }, + ); + + test( + 'DST gap is rejected and overlap requires an explicit occurrence', + () async { + final formBloc = _FakeScheduleFormBloc( + state: ScheduleFormState( + id: 'dst-schedule', + timeZoneId: 'America/New_York', + ), + ); + final cubit = ScheduleDateTimeCubit( + formBloc, + _FakeLoadAdjacentScheduleWithPreparationUseCase(), + _FakeGetAdjacentSchedulesWithPreparationUseCase(), + ); + addTearDown(cubit.close); + cubit.initialize(); + + await cubit.scheduleDateChanged(DateTime(2027, 3, 14)); + await cubit.scheduleTimeChanged(DateTime(2027, 3, 14, 2, 30)); + + expect(cubit.state.isNonexistentCivilTime, isTrue); + expect(cubit.scheduleDateTimeSubmitted(), isFalse); + + await cubit.scheduleDateChanged(DateTime(2027, 11, 7)); + await cubit.scheduleTimeChanged(DateTime(2027, 11, 7, 1, 30)); + + expect(cubit.state.occurrenceOffsetOptions, [-4 * 60 * 60, -5 * 60 * 60]); + expect(cubit.state.requiresOccurrenceChoice, isTrue); + expect(cubit.scheduleDateTimeSubmitted(), isFalse); + + cubit.occurrenceOffsetSelected(-5 * 60 * 60); + + expect(cubit.state.requiresOccurrenceChoice, isFalse); + expect(cubit.scheduleDateTimeSubmitted(), isTrue); + final submitted = formBloc.addedEvents + .whereType() + .last; + expect(submitted.occurrenceOffsetSeconds, -5 * 60 * 60); }, ); } 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 49f03e8a..f69f1b37 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 @@ -57,6 +57,32 @@ void main() { expect(find.text('2026년 05월 15일'), findsWidgets); }); + + testWidgets('shows explicit choices for a repeated DST time', (tester) async { + final formBloc = _FakeScheduleFormBloc( + ScheduleFormState(id: 'schedule-1', timeZoneId: 'America/New_York'), + ); + final cubit = ScheduleDateTimeCubit( + formBloc, + _FakeLoadAdjacentSchedulesWithPreparationUseCase(), + _FakeGetAdjacentSchedulesWithPreparationUseCase(), + ); + addTearDown(cubit.close); + cubit.initialize(); + + await cubit.scheduleDateChanged(DateTime(2027, 11, 7)); + await cubit.scheduleTimeChanged(DateTime(2027, 11, 7, 1, 30)); + await _pumpForm(tester, cubit: cubit); + + expect(find.textContaining('occurs twice'), findsOneWidget); + expect(find.text('First (UTC-04:00)'), findsOneWidget); + expect(find.text('Second (UTC-05:00)'), findsOneWidget); + + await tester.tap(find.text('Second (UTC-05:00)')); + await tester.pump(); + + expect(cubit.state.selectedOccurrenceOffsetSeconds, -5 * 60 * 60); + }); } Future _pumpForm( diff --git a/test/presentation/schedule_create/schedule_date_time/schedule_date_time_state_test.dart b/test/presentation/schedule_create/schedule_date_time/schedule_date_time_state_test.dart index d26c1c65..bd3f17b7 100644 --- a/test/presentation/schedule_create/schedule_date_time/schedule_date_time_state_test.dart +++ b/test/presentation/schedule_create/schedule_date_time/schedule_date_time_state_test.dart @@ -9,6 +9,9 @@ void main() { final state = ScheduleDateTimeState( scheduleDate: ScheduleDateInputModel.dirty(past), scheduleTime: ScheduleTimeInputModel.dirty(past), + civilTimeResolved: true, + occurrenceOffsetOptions: const [0], + selectedOccurrenceOffsetSeconds: 0, ); expect(state.isPastScheduleTime, isTrue); @@ -20,6 +23,9 @@ void main() { final state = ScheduleDateTimeState( scheduleDate: ScheduleDateInputModel.dirty(future), scheduleTime: ScheduleTimeInputModel.dirty(future), + civilTimeResolved: true, + occurrenceOffsetOptions: const [0], + selectedOccurrenceOffsetSeconds: 0, ); expect(state.isPastScheduleTime, isFalse); diff --git a/test/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form_test.dart b/test/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form_test.dart index a475d3b6..32a698ee 100644 --- a/test/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form_test.dart +++ b/test/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form_test.dart @@ -186,11 +186,10 @@ Future _pumpForm( AuthState( user: const UserEntity( id: 'user-1', - email: 'user@example.com', - name: 'User', spareTime: Duration(minutes: 8), note: '', - score: 4.0, + eligibleOutcomeCount: 1, + onTimeOutcomeCount: 1, isOnboardingCompleted: true, ), ), diff --git a/tool/check_local_only_boundary.dart b/tool/check_local_only_boundary.dart new file mode 100644 index 00000000..70a9b559 --- /dev/null +++ b/tool/check_local_only_boundary.dart @@ -0,0 +1,82 @@ +import 'dart:io'; + +const _forbiddenImports = [ + 'package:dio/', + 'package:http/', + 'package:firebase_', + 'package:google_sign_in/', + 'package:flutter_appauth/', + 'package:sign_in_with_apple/', +]; + +List validateLocalOnlyBoundary(Directory root) { + final failures = []; + final lib = Directory('${root.path}/lib'); + for (final entity in lib.listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + final content = entity.readAsStringSync(); + for (final import in _forbiddenImports) { + if (content.contains(import)) { + failures.add('${_relative(root, entity)} imports $import'); + } + } + if (RegExp(r'''['"]https?://''').hasMatch(content)) { + failures.add('${_relative(root, entity)} embeds a network URL'); + } + } + + final pubspec = File('${root.path}/pubspec.yaml').readAsStringSync(); + for (final dependency in const [ + 'dio', + 'http', + 'firebase_core', + 'firebase_messaging', + 'firebase_analytics', + 'google_sign_in', + 'flutter_appauth', + 'sign_in_with_apple', + ]) { + if (RegExp('^ $dependency:', multiLine: true).hasMatch(pubspec)) { + failures.add('pubspec.yaml declares $dependency'); + } + } + + final mainManifest = File( + '${root.path}/android/app/src/main/AndroidManifest.xml', + ).readAsStringSync(); + if (mainManifest.contains('android.permission.INTERNET')) { + failures.add('Android product manifest requests INTERNET'); + } + + for (final path in const [ + 'android/app/build.gradle', + 'android/settings.gradle', + 'ios/Runner/AppDelegate.swift', + 'ios/Runner/Info.plist', + 'ios/Runner.xcodeproj/project.pbxproj', + ]) { + final file = File('${root.path}/$path'); + if (!file.existsSync()) continue; + final content = file.readAsStringSync().toLowerCase(); + if (content.contains('firebase') || content.contains('google-services')) { + failures.add('$path contains a removed remote SDK configuration'); + } + } + + return failures; +} + +String _relative(Directory root, File file) => + file.path.substring(root.path.length + 1); + +void main() { + final failures = validateLocalOnlyBoundary(Directory.current); + if (failures.isEmpty) { + stdout.writeln('Local-only product boundary verified.'); + return; + } + for (final failure in failures) { + stderr.writeln(failure); + } + exitCode = 1; +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 301c4b55..c9a2e7f3 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,21 +6,24 @@ #include "generated_plugin_registrant.h" -#include +#include #include #include +#include +#include #include -#include void RegisterPlugins(flutter::PluginRegistry* registry) { - FirebaseCorePluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + SodiumLibsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SodiumLibsPluginCApi")); + Sqlite3FlutterLibsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); - UrlLauncherWindowsRegisterWithRegistrar( - registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 79f1d5d5..9b168dfa 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,11 +3,12 @@ # list(APPEND FLUTTER_PLUGIN_LIST - firebase_core + file_selector_windows flutter_secure_storage_windows permission_handler_windows + sodium_libs + sqlcipher_flutter_libs sqlite3_flutter_libs - url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST From 6fd1db32aa8c1d03aea6b3959fb7d39de90eb33b Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Sat, 29 Aug 2026 02:21:46 +0900 Subject: [PATCH 2/6] test: cover local-only onboarding and settings --- .../my_page/my_page_screen_test.dart | 439 ++++++++++++++++++ .../screens/onboarding_screen_test.dart | 229 +++++++++ 2 files changed, 668 insertions(+) create mode 100644 test/presentation/my_page/my_page_screen_test.dart create mode 100644 test/presentation/onboarding/screens/onboarding_screen_test.dart diff --git a/test/presentation/my_page/my_page_screen_test.dart b/test/presentation/my_page/my_page_screen_test.dart new file mode 100644 index 00000000..6c04abc6 --- /dev/null +++ b/test/presentation/my_page/my_page_screen_test.dart @@ -0,0 +1,439 @@ +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:on_time_front/core/constants/local_profile.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; +import 'package:on_time_front/core/services/detailed_notification_preference_service.dart'; +import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; +import 'package:on_time_front/core/services/notification_service.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'package:on_time_front/domain/entities/schedule_with_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/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/theme/theme.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase database; + late _FakeAlarmRepository alarmRepository; + late _FakeAlarmRegistry alarmRegistry; + late _FakeAlarmSchedulerService scheduler; + late _FakeFallbackAlarmNotificationService fallback; + late _FakeReconcileAlarmsUseCase reconcile; + late _FakeCancelAllAlarmsUseCase cancelAll; + + setUp(() async { + await getIt.reset(); + database = AppDatabase.forTesting(NativeDatabase.memory()); + await database.userDao.putUser( + const UserEntity( + id: localProfileId, + spareTime: Duration(minutes: 10), + note: '', + ), + ); + alarmRepository = _FakeAlarmRepository(); + alarmRegistry = _FakeAlarmRegistry(); + scheduler = _FakeAlarmSchedulerService(); + fallback = _FakeFallbackAlarmNotificationService(); + reconcile = _FakeReconcileAlarmsUseCase( + alarmRepository, + alarmRegistry, + scheduler, + fallback, + ); + cancelAll = _FakeCancelAllAlarmsUseCase(alarmRegistry, scheduler, fallback); + getIt + ..registerSingleton( + DetailedNotificationPreferenceService(database), + ) + ..registerSingleton(alarmRepository) + ..registerSingleton(alarmRegistry) + ..registerSingleton(scheduler) + ..registerSingleton(fallback) + ..registerSingleton(reconcile) + ..registerSingleton(cancelAll); + }); + + tearDown(() async { + await database.close(); + await getIt.reset(); + }); + + testWidgets('shows only local data and device settings', (tester) async { + await _pumpMyPage(tester); + + expect(find.text('My Page'), findsOneWidget); + expect(find.text('백업, 복원 및 로컬 데이터 초기화'), findsOneWidget); + expect(find.text('알림에 일정 이름 표시'), findsOneWidget); + expect(find.text('Sign in'), findsNothing); + expect(find.textContaining('email'), findsNothing); + expect(find.text('No scheduled notifications'), findsOneWidget); + }); + + testWidgets('detailed notification opt-in persists locally and reconciles', ( + tester, + ) async { + await _pumpMyPage(tester); + + final detailSwitch = find.widgetWithText(SwitchListTile, '알림에 일정 이름 표시'); + expect(tester.widget(detailSwitch).value, isFalse); + + await tester.tap(detailSwitch); + await tester.pumpAndSettle(); + + expect( + (await database.userDao.getAlarmSettings( + localProfileId, + )).detailedNotificationContent, + isTrue, + ); + expect(reconcile.callCount, 1); + }); + + testWidgets('disabling schedule delivery cancels every local registration', ( + tester, + ) async { + await _pumpMyPage(tester); + + await tester.tap(find.byKey(const Key('alarmSettingsSwitch'))); + await tester.pumpAndSettle(); + + expect(alarmRepository.updatedSettings, [false]); + expect(cancelAll.callCount, 1); + expect(reconcile.callCount, 0); + expect(find.text('꺼짐'), findsOneWidget); + }); + + testWidgets('fallback permission enables local schedule notifications', ( + tester, + ) async { + alarmRepository.settings = const AlarmSettings(alarmsEnabled: false); + scheduler.capabilities = AlarmSchedulerCapabilities.unsupported; + fallback.permission = AlarmPermissionState.granted; + + await _pumpMyPage(tester); + await tester.tap(find.byKey(const Key('alarmSettingsSwitch'))); + await tester.pumpAndSettle(); + + expect(fallback.requestCount, 1); + expect(alarmRepository.updatedSettings, [true]); + expect(reconcile.callCount, 1); + }); + + testWidgets('armed local notification is reported without server status', ( + tester, + ) async { + alarmRegistry.records = [ + ScheduledAlarmRecord( + scheduleId: 'schedule-1', + alarmTime: DateTime(2026, 9, 1, 9), + preparationStartTime: DateTime(2026, 9, 1, 9), + scheduleFingerprint: 'fingerprint-1', + fallbackNotificationId: 1, + provider: AlarmProvider.localNotification, + scheduleTitle: 'OnTime', + payload: const {'scheduleId': 'schedule-1'}, + ), + ]; + + await _pumpMyPage(tester); + + expect(find.text('Notification'), findsOneWidget); + }); + + testWidgets('authorized notification permission reports it is already on', ( + tester, + ) async { + final notifications = _FakeNotificationService( + currentStatus: AuthorizationStatus.authorized, + ); + await _pumpMyPage(tester, notificationService: notifications); + + await tester.ensureVisible(find.text('Allow App Notifications')); + await tester.tap(find.text('Allow App Notifications')); + await tester.pumpAndSettle(); + + expect(find.text('Notification Already Enabled'), findsOneWidget); + expect(notifications.requestCount, 0); + }); + + testWidgets('new notification grant initializes local notifications', ( + tester, + ) async { + final notifications = _FakeNotificationService( + currentStatus: AuthorizationStatus.notDetermined, + requestedStatus: AuthorizationStatus.authorized, + ); + await _pumpMyPage(tester, notificationService: notifications); + + await tester.ensureVisible(find.text('Allow App Notifications')); + await tester.tap(find.text('Allow App Notifications')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Allow')); + await tester.pumpAndSettle(); + + expect(notifications.requestCount, 1); + expect(notifications.initializeCount, 1); + expect(find.text('Notification Permission Granted'), findsOneWidget); + }); + + testWidgets('restricted notification state can open system settings', ( + tester, + ) async { + final notifications = _FakeNotificationService( + currentStatus: AuthorizationStatus.provisional, + ); + await _pumpMyPage(tester, notificationService: notifications); + + await tester.ensureVisible(find.text('Allow App Notifications')); + await tester.tap(find.text('Allow App Notifications')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open Settings')); + await tester.pumpAndSettle(); + + expect(notifications.openSettingsCount, 1); + }); + + testWidgets('local data and bundled privacy destinations remain in-app', ( + tester, + ) async { + await _pumpMyPage(tester); + + await tester.tap(find.text('백업, 복원 및 로컬 데이터 초기화')); + await tester.pumpAndSettle(); + expect(find.text('my data destination'), findsOneWidget); + + final BuildContext context = tester.element( + find.text('my data destination'), + ); + GoRouter.of(context).go('/myPage'); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Privacy Policy')); + await tester.tap(find.text('Privacy Policy')); + await tester.pumpAndSettle(); + expect(find.text('bundled privacy destination'), findsOneWidget); + }); +} + +Future _pumpMyPage( + WidgetTester tester, { + NotificationService? notificationService, +}) async { + final router = GoRouter( + initialLocation: '/myPage', + routes: [ + GoRoute( + path: '/myPage', + builder: (_, _) => + MyPageScreen(notificationService: notificationService), + ), + GoRoute( + path: '/myData', + builder: (_, _) => const Scaffold(body: Text('my data destination')), + ), + GoRoute( + path: '/privacyPolicy', + builder: (_, _) => + const Scaffold(body: Text('bundled privacy destination')), + ), + GoRoute( + path: '/defaultPreparationSpareTimeEdit', + builder: (_, _) => const Scaffold(body: Text('preparation editor')), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router( + theme: themeData, + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + ), + ); + await tester.pumpAndSettle(); +} + +class _FakeAlarmRepository implements AlarmRepository { + AlarmSettings settings = const AlarmSettings(alarmsEnabled: true); + final updatedSettings = []; + + @override + Future getAlarmSettings() async => settings; + + @override + Future updateAlarmSettings({ + required bool alarmsEnabled, + }) async { + updatedSettings.add(alarmsEnabled); + settings = AlarmSettings(alarmsEnabled: alarmsEnabled); + return settings; + } + + @override + Future> getAlarmWindow( + DateTime startDate, + DateTime endDate, + ) async => const []; +} + +class _FakeAlarmRegistry implements AlarmRegistryRepository { + List records = const []; + + @override + Future> loadAll() async => records; + + @override + Future deleteAll() async => records = const []; + + @override + Future deleteByScheduleId(String scheduleId) async {} + + @override + Future replaceAll(List records) async { + this.records = records; + } + + @override + Future upsert(ScheduledAlarmRecord record) async {} +} + +class _FakeAlarmSchedulerService extends AlarmSchedulerService { + AlarmSchedulerCapabilities capabilities = + AlarmSchedulerCapabilities.unsupported; + AlarmPermissionState permission = AlarmPermissionState.unsupported; + int requestCount = 0; + + @override + Future getCapabilities() async => capabilities; + + @override + Future checkPermission() async => permission; + + @override + Future requestPermission() async { + requestCount += 1; + return permission; + } +} + +class _FakeFallbackAlarmNotificationService + implements FallbackAlarmNotificationService { + AlarmPermissionState permission = AlarmPermissionState.granted; + int requestCount = 0; + + @override + Future checkPermission() async => permission; + + @override + Future requestPermission() async { + requestCount += 1; + return permission; + } + + @override + Future cancelFallbackAlarm(ScheduledAlarmRecord record) async {} + + @override + Future scheduleFallbackAlarm(ScheduledAlarmRecord record) async {} +} + +class _FakeReconcileAlarmsUseCase extends ReconcileAlarmsUseCase { + // ignore: use_super_parameters + _FakeReconcileAlarmsUseCase( + AlarmRepository alarmRepository, + AlarmRegistryRepository registryRepository, + AlarmSchedulerService schedulerService, + FallbackAlarmNotificationService fallbackNotificationService, + ) : super.test( + alarmRepository, + registryRepository, + schedulerService, + fallbackNotificationService, + nowProvider: () => DateTime(2026), + ); + + int callCount = 0; + + @override + Future call() async { + callCount += 1; + return AlarmReconciliationResult( + status: AlarmReconciliationStatus.armed, + nativeAlarmProvider: AlarmProvider.none, + fallbackProvider: AlarmProvider.localNotification, + armedScheduleIds: const [], + skippedScheduleCount: 0, + failures: const [], + scheduleWindowStart: DateTime(2026), + scheduleWindowEnd: DateTime(2027), + alarmCoverageStart: DateTime(2026), + alarmCoverageEnd: DateTime(2027), + ); + } +} + +class _FakeCancelAllAlarmsUseCase extends CancelAllAlarmsUseCase { + // ignore: use_super_parameters + _FakeCancelAllAlarmsUseCase( + AlarmRegistryRepository registryRepository, + AlarmSchedulerService schedulerService, + FallbackAlarmNotificationService fallbackNotificationService, + ) : super(registryRepository, schedulerService, fallbackNotificationService); + + int callCount = 0; + + @override + Future call() async { + callCount += 1; + } +} + +class _FakeNotificationService implements NotificationService { + _FakeNotificationService({ + required this.currentStatus, + this.requestedStatus = AuthorizationStatus.denied, + }); + + AuthorizationStatus currentStatus; + final AuthorizationStatus requestedStatus; + int requestCount = 0; + int initializeCount = 0; + int openSettingsCount = 0; + + @override + Future checkNotificationPermission() async => + currentStatus; + + @override + Future initialize() async { + initializeCount += 1; + } + + @override + Future openNotificationSettings() async { + openSettingsCount += 1; + return true; + } + + @override + Future requestPermission() async { + requestCount += 1; + currentStatus = requestedStatus; + return requestedStatus; + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/presentation/onboarding/screens/onboarding_screen_test.dart b/test/presentation/onboarding/screens/onboarding_screen_test.dart new file mode 100644 index 00000000..f37fdb86 --- /dev/null +++ b/test/presentation/onboarding/screens/onboarding_screen_test.dart @@ -0,0 +1,229 @@ +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/preparation_entity.dart'; +import 'package:on_time_front/domain/repositories/preparation_repository.dart'; +import 'package:on_time_front/domain/repositories/user_repository.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_time/cubit/preparation_time_cubit.dart'; +import 'package:on_time_front/presentation/onboarding/preparation_time/screens/preparation_time_form.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'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _FakeOnboardUseCase useCase; + late OnboardingCubit onboardingCubit; + + setUp(() async { + await getIt.reset(); + useCase = _FakeOnboardUseCase(); + onboardingCubit = OnboardingCubit(useCase); + getIt.registerSingleton(onboardingCubit); + }); + + tearDown(() async { + await getIt.reset(); + }); + + testWidgets('start screen enters the entirely local onboarding flow', ( + tester, + ) async { + final router = GoRouter( + initialLocation: '/onboardingStart', + routes: [ + GoRoute( + path: '/onboardingStart', + builder: (_, _) => const OnboardingStartScreen(), + ), + GoRoute( + path: '/onboarding', + builder: (_, _) => const Scaffold(body: Text('local onboarding')), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget(_app(router)); + await tester.pumpAndSettle(); + + expect(find.text('Welcome!'), findsOneWidget); + expect(find.text('Sign in'), findsNothing); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Start')); + await tester.pumpAndSettle(); + + expect(find.text('local onboarding'), findsOneWidget); + }); + + testWidgets( + 'selected preparation and timing are submitted as the Local Profile setup', + (tester) async { + final router = GoRouter( + initialLocation: '/onboarding', + routes: [ + GoRoute( + path: '/onboarding', + builder: (_, _) => const OnboardingScreen(), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget(_app(router)); + await tester.pumpAndSettle(); + + expect( + find.text('Please select your usual preparation process.'), + findsOneWidget, + ); + expect(_nextButton(tester).onPressed, isNull); + + await tester.tap(find.byType(CheckButton).first); + await tester.pump(); + expect(_nextButton(tester).onPressed, isNotNull); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Next')); + await tester.pumpAndSettle(); + expect(find.textContaining('order'), findsOneWidget); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Next')); + await tester.pumpAndSettle(); + expect( + find.text('Please tell us the time required for each step.'), + findsOneWidget, + ); + expect(_nextButton(tester).onPressed, isNull); + + final timeContext = tester.element(find.byType(PreparationTimeForm)); + timeContext.read().preparationTimeChanged( + 0, + const Duration(minutes: 10), + ); + await tester.pump(); + expect(_nextButton(tester).onPressed, isNotNull); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Next')); + await tester.pumpAndSettle(); + expect(find.text('Set your spare time'), findsOneWidget); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Next')); + await tester.pumpAndSettle(); + + expect(useCase.submissions, hasLength(1)); + final submission = useCase.submissions.single; + expect(submission.spareTime, const Duration(minutes: 30)); + expect(submission.preparation.preparationStepList, hasLength(1)); + expect( + submission.preparation.preparationStepList.single.preparationTime, + const Duration(minutes: 10), + ); + }, + ); + + testWidgets( + 'failed local profile creation keeps the form and reports error', + (tester) async { + useCase.error = StateError('local database unavailable'); + onboardingCubit.onboardingFormChanged( + preparationStepList: const [ + OnboardingPreparationStepState( + id: 'step-1', + preparationName: 'Pack', + preparationTime: Duration(minutes: 5), + ), + ], + spareTime: const Duration(minutes: 20), + ); + onboardingCubit.onboardingFormValidated(isValid: true); + final router = GoRouter( + initialLocation: '/onboarding', + routes: [ + GoRoute( + path: '/onboarding', + builder: (_, _) => const OnboardingScreen(), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget(_app(router)); + await tester.pumpAndSettle(); + + for (var index = 0; index < 3; index += 1) { + await tester.tap(find.widgetWithText(ElevatedButton, 'Next')); + await tester.pumpAndSettle(); + } + await tester.tap(find.widgetWithText(ElevatedButton, 'Next')); + await tester.pumpAndSettle(); + + expect(find.text('Error'), findsOneWidget); + expect(useCase.submissions, hasLength(1)); + expect(find.byType(OnboardingScreen), findsOneWidget); + }, + ); +} + +MaterialApp _app(GoRouter router) => MaterialApp.router( + theme: themeData, + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, +); + +ElevatedButton _nextButton(WidgetTester tester) => + tester.widget(find.widgetWithText(ElevatedButton, 'Next')); + +class _OnboardingSubmission { + const _OnboardingSubmission({ + required this.preparation, + required this.spareTime, + required this.note, + }); + + final PreparationEntity preparation; + final Duration spareTime; + final String note; +} + +class _FakeOnboardUseCase extends OnboardUseCase { + _FakeOnboardUseCase() + : super(_FakePreparationRepository(), _FakeUserRepository()); + + final submissions = <_OnboardingSubmission>[]; + Object? error; + + @override + Future call({ + required PreparationEntity preparationEntity, + required Duration spareTime, + required String note, + }) async { + submissions.add( + _OnboardingSubmission( + preparation: preparationEntity, + spareTime: spareTime, + note: note, + ), + ); + if (error case final value?) throw value; + } +} + +class _FakePreparationRepository implements PreparationRepository { + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeUserRepository implements UserRepository { + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} From 3085b715ba46c8f168075ea3a1d05eac86d188e2 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Sat, 29 Aug 2026 02:31:16 +0900 Subject: [PATCH 3/6] fix: preserve local onboarding state --- lib/presentation/my_page/my_page_screen.dart | 7 ++- .../cubit/preparation_order_cubit.dart | 36 +++++++------ .../cubit/onboarding_cubit_test.dart | 52 +++++++++++++++++++ .../screens/onboarding_screen_test.dart | 17 ++++-- 4 files changed, 89 insertions(+), 23 deletions(-) diff --git a/lib/presentation/my_page/my_page_screen.dart b/lib/presentation/my_page/my_page_screen.dart index 41bf340e..3ac237ce 100644 --- a/lib/presentation/my_page/my_page_screen.dart +++ b/lib/presentation/my_page/my_page_screen.dart @@ -97,8 +97,7 @@ class _DetailedNotificationTile extends StatefulWidget { _DetailedNotificationTileState(); } -class _DetailedNotificationTileState - extends State<_DetailedNotificationTile> { +class _DetailedNotificationTileState extends State<_DetailedNotificationTile> { bool _enabled = false; bool _loading = true; @@ -402,8 +401,8 @@ class _FrameView extends StatelessWidget { Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; - return Container( - decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), + return Material( + color: Theme.of(context).colorScheme.surface, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 19), child: Column( diff --git a/lib/presentation/onboarding/preparation_order/cubit/preparation_order_cubit.dart b/lib/presentation/onboarding/preparation_order/cubit/preparation_order_cubit.dart index 584a7b9d..b897532a 100644 --- a/lib/presentation/onboarding/preparation_order/cubit/preparation_order_cubit.dart +++ b/lib/presentation/onboarding/preparation_order/cubit/preparation_order_cubit.dart @@ -31,21 +31,27 @@ class PreparationOrderCubit extends Cubit { } void preparationOrderSaved() { - final newList = state.toOnboardingState().preparationStepList; - final oldList = onboardingCubit.state.preparationStepList; - - assert(newList.length == oldList.length); - - for (int i = 0; i < oldList.length; i++) { - for (int j = 0; i < oldList.length; j++) { - if (oldList[j].id == newList[i].id) { - oldList[j] = oldList[j].copyWith( - nextPreparationId: newList[i].nextPreparationId, + final orderedList = state.toOnboardingState().preparationStepList; + final existingSteps = { + for (final step in onboardingCubit.state.preparationStepList) + step.id: step, + }; + + assert(orderedList.length == existingSteps.length); + + final reorderedSteps = orderedList + .map((step) { + final existingStep = existingSteps[step.id]; + return OnboardingPreparationStepState( + id: step.id, + preparationName: step.preparationName, + preparationTime: + existingStep?.preparationTime ?? step.preparationTime, + nextPreparationId: step.nextPreparationId, ); - break; - } - } - } - onboardingCubit.onboardingFormChanged(preparationStepList: newList); + }) + .toList(growable: false); + + onboardingCubit.onboardingFormChanged(preparationStepList: reorderedSteps); } } diff --git a/test/presentation/onboarding/cubit/onboarding_cubit_test.dart b/test/presentation/onboarding/cubit/onboarding_cubit_test.dart index cef0954e..5f78c8a5 100644 --- a/test/presentation/onboarding/cubit/onboarding_cubit_test.dart +++ b/test/presentation/onboarding/cubit/onboarding_cubit_test.dart @@ -4,6 +4,7 @@ import 'package:on_time_front/domain/repositories/preparation_repository.dart'; import 'package:on_time_front/domain/repositories/user_repository.dart'; import 'package:on_time_front/domain/use-cases/onboard_use_case.dart'; 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'; void main() { test( @@ -86,6 +87,57 @@ void main() { ); }, ); + + test( + 'PreparationOrderCubit reorders immutable steps without losing durations', + () { + final onboardingCubit = OnboardingCubit(_FakeOnboardUseCase()); + addTearDown(onboardingCubit.close); + onboardingCubit.onboardingFormChanged( + preparationStepList: const [ + OnboardingPreparationStepState( + id: 'step-1', + preparationName: 'Shower', + preparationTime: Duration(minutes: 10), + nextPreparationId: 'step-2', + ), + OnboardingPreparationStepState( + id: 'step-2', + preparationName: 'Pack', + preparationTime: Duration(minutes: 5), + ), + ], + ); + final orderCubit = PreparationOrderCubit( + onboardingCubit: onboardingCubit, + ); + addTearDown(orderCubit.close); + + orderCubit.preparationOrderChanged(0, 2); + orderCubit.preparationOrderSaved(); + + expect( + onboardingCubit.state.preparationStepList + .map((step) => step.id) + .toList(), + ['step-2', 'step-1'], + ); + expect( + onboardingCubit.state.preparationStepList + .map((step) => step.preparationTime) + .toList(), + const [Duration(minutes: 5), Duration(minutes: 10)], + ); + expect( + onboardingCubit.state.preparationStepList.first.nextPreparationId, + 'step-1', + ); + expect( + onboardingCubit.state.preparationStepList.last.nextPreparationId, + isNull, + ); + }, + ); } class _OnboardingSubmission { diff --git a/test/presentation/onboarding/screens/onboarding_screen_test.dart b/test/presentation/onboarding/screens/onboarding_screen_test.dart index f37fdb86..31000891 100644 --- a/test/presentation/onboarding/screens/onboarding_screen_test.dart +++ b/test/presentation/onboarding/screens/onboarding_screen_test.dart @@ -81,7 +81,10 @@ void main() { await tester.pumpAndSettle(); expect( - find.text('Please select your usual preparation process.'), + find.text( + 'Please select your usual preparation process.', + findRichText: true, + ), findsOneWidget, ); expect(_nextButton(tester).onPressed, isNull); @@ -92,12 +95,15 @@ void main() { await tester.tap(find.widgetWithText(ElevatedButton, 'Next')); await tester.pumpAndSettle(); - expect(find.textContaining('order'), findsOneWidget); + expect(find.textContaining('order', findRichText: true), findsOneWidget); await tester.tap(find.widgetWithText(ElevatedButton, 'Next')); await tester.pumpAndSettle(); expect( - find.text('Please tell us the time required for each step.'), + find.text( + 'Please tell us the time required for each step.', + findRichText: true, + ), findsOneWidget, ); expect(_nextButton(tester).onPressed, isNull); @@ -112,7 +118,10 @@ void main() { await tester.tap(find.widgetWithText(ElevatedButton, 'Next')); await tester.pumpAndSettle(); - expect(find.text('Set your spare time'), findsOneWidget); + expect( + find.text('Set your spare time', findRichText: true), + findsOneWidget, + ); await tester.tap(find.widgetWithText(ElevatedButton, 'Next')); await tester.pumpAndSettle(); From 713da9b41d3bdb7656478dd7977b76738da2ef2d Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Sat, 29 Aug 2026 02:37:33 +0900 Subject: [PATCH 4/6] test: align local settings fixtures --- test/presentation/my_page/my_page_screen_test.dart | 6 ++++-- .../onboarding/screens/onboarding_screen_test.dart | 11 +++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/test/presentation/my_page/my_page_screen_test.dart b/test/presentation/my_page/my_page_screen_test.dart index 6c04abc6..9d9320b6 100644 --- a/test/presentation/my_page/my_page_screen_test.dart +++ b/test/presentation/my_page/my_page_screen_test.dart @@ -310,8 +310,10 @@ class _FakeAlarmRegistry implements AlarmRegistryRepository { } class _FakeAlarmSchedulerService extends AlarmSchedulerService { - AlarmSchedulerCapabilities capabilities = - AlarmSchedulerCapabilities.unsupported; + AlarmSchedulerCapabilities capabilities = const AlarmSchedulerCapabilities( + supportsNativeAlarm: false, + nativeAlarmProvider: AlarmProvider.none, + ); AlarmPermissionState permission = AlarmPermissionState.unsupported; int requestCount = 0; diff --git a/test/presentation/onboarding/screens/onboarding_screen_test.dart b/test/presentation/onboarding/screens/onboarding_screen_test.dart index 31000891..b9cef5a3 100644 --- a/test/presentation/onboarding/screens/onboarding_screen_test.dart +++ b/test/presentation/onboarding/screens/onboarding_screen_test.dart @@ -81,7 +81,7 @@ void main() { await tester.pumpAndSettle(); expect( - find.text( + find.textContaining( 'Please select your usual preparation process.', findRichText: true, ), @@ -100,7 +100,7 @@ void main() { await tester.tap(find.widgetWithText(ElevatedButton, 'Next')); await tester.pumpAndSettle(); expect( - find.text( + find.textContaining( 'Please tell us the time required for each step.', findRichText: true, ), @@ -119,7 +119,7 @@ void main() { await tester.tap(find.widgetWithText(ElevatedButton, 'Next')); await tester.pumpAndSettle(); expect( - find.text('Set your spare time', findRichText: true), + find.textContaining('Set your spare time', findRichText: true), findsOneWidget, ); @@ -171,11 +171,14 @@ void main() { await tester.pumpAndSettle(); } await tester.tap(find.widgetWithText(ElevatedButton, 'Next')); - await tester.pumpAndSettle(); + await tester.pump(); expect(find.text('Error'), findsOneWidget); expect(useCase.submissions, hasLength(1)); expect(find.byType(OnboardingScreen), findsOneWidget); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); }, ); } From b8d2d3244c06539809a11cb26c08b6bb115f20d6 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Sat, 29 Aug 2026 02:41:27 +0900 Subject: [PATCH 5/6] fix: persist visible onboarding spare time --- .../cubit/schedule_spare_time_cubit.dart | 2 +- .../cubit/schedule_spare_time_state.dart | 2 +- .../screens/schedule_spare_time_form.dart | 76 +++++++++---------- .../screens/onboarding_screen_test.dart | 9 ++- 4 files changed, 47 insertions(+), 42 deletions(-) diff --git a/lib/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_cubit.dart b/lib/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_cubit.dart index c00f1274..dd1e5fce 100644 --- a/lib/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_cubit.dart +++ b/lib/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_cubit.dart @@ -10,7 +10,7 @@ class ScheduleSpareTimeCubit extends Cubit { final OnboardingCubit onboardingCubit; final Duration lowerBound = Duration(minutes: 10); - final Duration stepSize = Duration(minutes: 5); + final Duration stepSize = Duration(minutes: 10); void initialize() { emit(ScheduleSpareTimeState.fromOnboardingState(onboardingCubit.state)); diff --git a/lib/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_state.dart b/lib/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_state.dart index 3c221ee4..11ceb193 100644 --- a/lib/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_state.dart +++ b/lib/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_state.dart @@ -2,7 +2,7 @@ part of 'schedule_spare_time_cubit.dart'; class ScheduleSpareTimeState extends Equatable { ScheduleSpareTimeState({Duration? spareTime}) - : spareTime = spareTime ?? Duration(minutes: 10); + : spareTime = spareTime ?? Duration(minutes: 30); final Duration spareTime; diff --git a/lib/presentation/onboarding/schedule_spare_time/screens/schedule_spare_time_form.dart b/lib/presentation/onboarding/schedule_spare_time/screens/schedule_spare_time_form.dart index eb078ee8..3f3be683 100644 --- a/lib/presentation/onboarding/schedule_spare_time/screens/schedule_spare_time_form.dart +++ b/lib/presentation/onboarding/schedule_spare_time/screens/schedule_spare_time_form.dart @@ -1,7 +1,9 @@ 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/onboarding/components/onboarding_page_view_layout.dart'; import 'package:on_time_front/presentation/onboarding/schedule_spare_time/components/shcedule_spare_time_field.dart'; +import 'package:on_time_front/presentation/onboarding/schedule_spare_time/cubit/schedule_spare_time_cubit.dart'; class ScheduleSpareTimeForm extends StatefulWidget { const ScheduleSpareTimeForm({super.key}); @@ -11,50 +13,48 @@ class ScheduleSpareTimeForm extends StatefulWidget { } class _ScheduleSpareTimeFormState extends State { - Duration spareTime = Duration(minutes: 30); - final Duration lowerBound = Duration(minutes: 10); + @override + void initState() { + super.initState(); + context.read().initialize(); + } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; - return OnboardingPageViewLayout( - title: AppLocalizations.of(context)!.setSpareTimeTitle, - subTitle: RichText( - text: TextSpan( - text: '${AppLocalizations.of(context)!.setSpareTimeDescription}\n', - style: textTheme.titleSmall?.copyWith(color: colorScheme.outline), - children: [ - TextSpan( - text: AppLocalizations.of(context)!.setSpareTimeWarning, - style: textTheme.titleSmall?.copyWith( - color: colorScheme.outline, - fontWeight: FontWeight.bold, - ), + return BlocBuilder( + builder: (context, state) { + final cubit = context.read(); + return OnboardingPageViewLayout( + title: AppLocalizations.of(context)!.setSpareTimeTitle, + subTitle: RichText( + text: TextSpan( + text: + '${AppLocalizations.of(context)!.setSpareTimeDescription}\n', + style: textTheme.titleSmall?.copyWith(color: colorScheme.outline), + children: [ + TextSpan( + text: AppLocalizations.of(context)!.setSpareTimeWarning, + style: textTheme.titleSmall?.copyWith( + color: colorScheme.outline, + fontWeight: FontWeight.bold, + ), + ), + ], ), - ], - ), - ), - child: ScheduleSpareTimeField( - lowerBound: lowerBound, - spareTime: spareTime, - minimumWarningMessage: AppLocalizations.of( - context, - )!.spareTimeMinimumWarning, - onSpareTimeDecreased: () { - setState(() { - final updatedSpareTime = spareTime - Duration(minutes: 10); - if (updatedSpareTime >= lowerBound) { - spareTime = updatedSpareTime; - } - }); - }, - onSpareTimeIncreased: () { - setState(() { - spareTime += Duration(minutes: 10); - }); - }, - ), + ), + child: ScheduleSpareTimeField( + lowerBound: cubit.lowerBound, + spareTime: state.spareTime, + minimumWarningMessage: AppLocalizations.of( + context, + )!.spareTimeMinimumWarning, + onSpareTimeDecreased: cubit.spareTimeDecreased, + onSpareTimeIncreased: cubit.spareTimeIncreased, + ), + ); + }, ); } } diff --git a/test/presentation/onboarding/screens/onboarding_screen_test.dart b/test/presentation/onboarding/screens/onboarding_screen_test.dart index b9cef5a3..e434c623 100644 --- a/test/presentation/onboarding/screens/onboarding_screen_test.dart +++ b/test/presentation/onboarding/screens/onboarding_screen_test.dart @@ -113,7 +113,7 @@ void main() { 0, const Duration(minutes: 10), ); - await tester.pump(); + await tester.pumpAndSettle(); expect(_nextButton(tester).onPressed, isNotNull); await tester.tap(find.widgetWithText(ElevatedButton, 'Next')); @@ -122,13 +122,18 @@ void main() { find.textContaining('Set your spare time', findRichText: true), findsOneWidget, ); + expect(find.text('30분'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + expect(find.text('40분'), findsOneWidget); await tester.tap(find.widgetWithText(ElevatedButton, 'Next')); await tester.pumpAndSettle(); expect(useCase.submissions, hasLength(1)); final submission = useCase.submissions.single; - expect(submission.spareTime, const Duration(minutes: 30)); + expect(submission.spareTime, const Duration(minutes: 40)); expect(submission.preparation.preparationStepList, hasLength(1)); expect( submission.preparation.preparationStepList.single.preparationTime, From 16394002d9c06e354f79eb22477da7a7a4f110ee Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Sat, 29 Aug 2026 02:54:28 +0900 Subject: [PATCH 6/6] fix: align offline database tooling --- lib/core/database/open_database_web.dart | 17 +- linux/flutter/generated_plugin_registrant.cc | 4 - linux/flutter/generated_plugins.cmake | 1 - macos/Flutter/GeneratedPluginRegistrant.swift | 2 - pubspec.lock | 16 - pubspec.yaml | 1 - .../flutter/generated_plugin_registrant.cc | 14 +- .../linux/flutter/generated_plugins.cmake | 4 +- .../Flutter/GeneratedPluginRegistrant.swift | 22 +- widgetbook/pubspec.lock | 510 +++++------------- widgetbook/pubspec.yaml | 8 +- .../flutter/generated_plugin_registrant.cc | 11 +- .../windows/flutter/generated_plugins.cmake | 5 +- .../flutter/generated_plugin_registrant.cc | 3 - windows/flutter/generated_plugins.cmake | 1 - 15 files changed, 191 insertions(+), 428 deletions(-) diff --git a/lib/core/database/open_database_web.dart b/lib/core/database/open_database_web.dart index a85e6cf4..6fe240f5 100644 --- a/lib/core/database/open_database_web.dart +++ b/lib/core/database/open_database_web.dart @@ -1,13 +1,14 @@ import 'package:drift/drift.dart'; -import 'package:drift_flutter/drift_flutter.dart'; +import 'package:drift/wasm.dart'; import 'package:on_time_front/core/database/installation_key_store.dart'; QueryExecutor openOnTimeDatabase(InstallationKeyStore keyStore) { - return driftDatabase( - name: 'ontime_local_dev', - web: DriftWebOptions( - sqlite3Wasm: Uri.parse('sqlite3.wasm'), - driftWorker: Uri.parse('drift_worker.dart.js'), - ), - ); + return LazyDatabase(() async { + final result = await WasmDatabase.open( + databaseName: 'ontime_local_dev', + sqlite3Uri: Uri.parse('sqlite3.wasm'), + driftWorkerUri: Uri.parse('drift_worker.dart.js'), + ); + return result.resolvedExecutor; + }); } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 2e3d085c..08fcf36f 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -10,7 +10,6 @@ #include #include #include -#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = @@ -25,7 +24,4 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) sqlcipher_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); sqlite3_flutter_libs_plugin_register_with_registrar(sqlcipher_flutter_libs_registrar); - g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); - sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index aa0b9c82..aa68294d 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -7,7 +7,6 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux sodium_libs sqlcipher_flutter_libs - sqlite3_flutter_libs ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index dfcd99e8..59616e3b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -13,7 +13,6 @@ import path_provider_foundation import shared_preferences_foundation import sodium_libs import sqlcipher_flutter_libs -import sqlite3_flutter_libs func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) @@ -24,5 +23,4 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SodiumLibsPlugin.register(with: registry.registrar(forPlugin: "SodiumLibsPlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) - Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index eb4f86aa..9ba6557f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -272,14 +272,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.31.0" - drift_flutter: - dependency: "direct main" - description: - name: drift_flutter - sha256: c07120854742a0cae2f7501a0da02493addde550db6641d284983c08762e60a7 - url: "https://pub.dev" - source: hosted - version: "0.2.8" equatable: dependency: "direct main" description: @@ -1129,14 +1121,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.9.4" - sqlite3_flutter_libs: - dependency: transitive - description: - name: sqlite3_flutter_libs - sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad - url: "https://pub.dev" - source: hosted - version: "0.5.42" sqlparser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 17070578..dd6a2874 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,7 +38,6 @@ dependencies: drift: ^2.31.0 - drift_flutter: ^0.2.8 json_annotation: ^4.9.0 diff --git a/widgetbook/linux/flutter/generated_plugin_registrant.cc b/widgetbook/linux/flutter/generated_plugin_registrant.cc index a35cce61..7ceedb9c 100644 --- a/widgetbook/linux/flutter/generated_plugin_registrant.cc +++ b/widgetbook/linux/flutter/generated_plugin_registrant.cc @@ -6,17 +6,25 @@ #include "generated_plugin_registrant.h" +#include #include -#include +#include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); - g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = + g_autoptr(FlPluginRegistrar) sodium_libs_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "SodiumLibsPlugin"); + sodium_libs_plugin_register_with_registrar(sodium_libs_registrar); + g_autoptr(FlPluginRegistrar) sqlcipher_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); - sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); + sqlite3_flutter_libs_plugin_register_with_registrar(sqlcipher_flutter_libs_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/widgetbook/linux/flutter/generated_plugins.cmake b/widgetbook/linux/flutter/generated_plugins.cmake index 2aa89bb0..5d4b63a2 100644 --- a/widgetbook/linux/flutter/generated_plugins.cmake +++ b/widgetbook/linux/flutter/generated_plugins.cmake @@ -3,8 +3,10 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux flutter_secure_storage_linux - sqlite3_flutter_libs + sodium_libs + sqlcipher_flutter_libs url_launcher_linux ) diff --git a/widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift b/widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift index b3c853f5..d87e6546 100644 --- a/widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,32 +5,24 @@ import FlutterMacOS import Foundation -import firebase_analytics -import firebase_core -import firebase_messaging -import flutter_appauth +import file_selector_macos import flutter_local_notifications import flutter_secure_storage_darwin -import google_sign_in_ios +import package_info_plus import path_provider_foundation import shared_preferences_foundation -import sign_in_with_apple -import sqlite3_flutter_libs +import sodium_libs +import sqlcipher_flutter_libs import url_launcher_macos -import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin")) - FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) - FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) - FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) - FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin")) + SodiumLibsPlugin.register(with: registry.registrar(forPlugin: "SodiumLibsPlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) - WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) } diff --git a/widgetbook/pubspec.lock b/widgetbook/pubspec.lock index 63daab29..78ca20b6 100644 --- a/widgetbook/pubspec.lock +++ b/widgetbook/pubspec.lock @@ -5,18 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b url: "https://pub.dev" source: hosted - version: "85.0.0" - _flutterfire_internals: - dependency: transitive - description: - name: _flutterfire_internals - sha256: "78f98c1f9c4dbbd22c2bb7b7f17c4a5c06150e8b2cb791a0947979ad0d3dabd5" - url: "https://pub.dev" - source: hosted - version: "1.3.73" + version: "100.0.0" accessibility_tools: dependency: transitive description: @@ -29,10 +21,10 @@ packages: dependency: transitive description: name: analyzer - sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" url: "https://pub.dev" source: hosted - version: "7.7.1" + version: "13.0.0" args: dependency: transitive description: @@ -41,14 +33,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" - asn1lib: - dependency: transitive - description: - name: asn1lib - sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" - url: "https://pub.dev" - source: hosted - version: "1.6.5" assets: dependency: "direct main" description: @@ -84,18 +68,18 @@ packages: dependency: transitive description: name: build - sha256: "7174c5d84b0fed00a1f5e7543597b35d67560465ae3d909f0889b8b20419d5e3" + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "4.0.7" build_config: dependency: transitive description: name: build_config - sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + sha256: "94eaf6708fe64408c632ef2689ca3777b112f9421306ccf4f8c84d7c5c9f83f8" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.3.2" build_daemon: dependency: transitive description: @@ -104,30 +88,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.4" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: "82730bf3d9043366ba8c02e4add05842a10739899520a6a22ddbd22d333bd5bb" - url: "https://pub.dev" - source: hosted - version: "3.0.1" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "32c6b3d172f1f46b7c4df6bc4a47b8d88afb9e505dd4ace4af80b3c37e89832b" + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" url: "https://pub.dev" source: hosted - version: "2.6.1" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: "4b188774b369104ad96c0e4ca2471e5162f0566ce277771b179bed5eabf2d048" - url: "https://pub.dev" - source: hosted - version: "9.2.1" + version: "2.15.1" built_collection: dependency: transitive description: @@ -192,6 +160,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" crypto: dependency: transitive description: @@ -200,6 +176,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: transitive description: @@ -212,10 +196,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af" + sha256: "59d53ef8eaed9d288ed9767618e2b31c4fa0383a127db59d5eb2e737a7638a60" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.9" dbus: dependency: transitive description: @@ -232,22 +216,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" - dio: - dependency: transitive - description: - name: dio - sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9" - url: "https://pub.dev" - source: hosted - version: "5.8.0+1" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" - url: "https://pub.dev" - source: hosted - version: "2.1.1" dotted_line: dependency: transitive description: @@ -264,22 +232,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.31.0" - drift_flutter: - dependency: transitive - description: - name: drift_flutter - sha256: c07120854742a0cae2f7501a0da02493addde550db6641d284983c08762e60a7 - url: "https://pub.dev" - source: hosted - version: "0.2.8" - encrypt: - dependency: transitive - description: - name: encrypt - sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" - url: "https://pub.dev" - source: hosted - version: "5.0.3" equatable: dependency: transitive description: @@ -300,90 +252,90 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" - file: + version: "2.2.0" + ffi_leak_tracker: dependency: transitive description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" url: "https://pub.dev" source: hosted - version: "7.0.1" - firebase_analytics: + version: "0.1.2" + file: dependency: transitive description: - name: firebase_analytics - sha256: "1c46136d9226e7e070013582097d997248a34cf94856c7e95f4fdf346bf4a397" + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "12.4.3" - firebase_analytics_platform_interface: + version: "7.0.1" + file_selector: dependency: transitive description: - name: firebase_analytics_platform_interface - sha256: "0b4ae09407352ec462a2403b8addf18bdf94a35e0e0c9dda198274516c32456a" + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a url: "https://pub.dev" source: hosted - version: "6.0.3" - firebase_analytics_web: + version: "1.1.0" + file_selector_android: dependency: transitive description: - name: firebase_analytics_web - sha256: e66c02ab6491393767b197dfb37908178295cfdb1c5d30e33cad9d7276d1c5fa + name: file_selector_android + sha256: "7c76473740e33a11343c8fce88166049230850d2f19cd8a652ea935fcb8c9206" url: "https://pub.dev" source: hosted - version: "0.6.1+9" - firebase_core: + version: "0.5.2+10" + file_selector_ios: dependency: transitive description: - name: firebase_core - sha256: d2625088d8f8836a7a74a7eb94a5372d70ad88382602ba2dcc02805c294d0d16 + name: file_selector_ios + sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c" url: "https://pub.dev" source: hosted - version: "4.11.0" - firebase_core_platform_interface: + version: "0.5.3+6" + file_selector_linux: dependency: transitive description: - name: firebase_core_platform_interface - sha256: "913e7c96ef83a80ad7e1c3f8a059167b3de23b5d5e07fa3ed8f11abe24de98b6" + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab url: "https://pub.dev" source: hosted - version: "7.1.0" - firebase_core_web: + version: "0.9.4+1" + file_selector_macos: dependency: transitive description: - name: firebase_core_web - sha256: "30ba3ae56f5beb2cea836033201570612c911661889f815eca73b6056c7b55bf" + name: file_selector_macos + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 url: "https://pub.dev" source: hosted - version: "3.9.0" - firebase_messaging: + version: "0.9.5+1" + file_selector_platform_interface: dependency: transitive description: - name: firebase_messaging - sha256: ce21a510e5a9aed67a0404476981e19ec0361a0301eeba547dc93dc2e7dec99a + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" url: "https://pub.dev" source: hosted - version: "16.4.1" - firebase_messaging_platform_interface: + version: "2.7.0" + file_selector_web: dependency: transitive description: - name: firebase_messaging_platform_interface - sha256: e10f6d521e7ed663d0ea2f4ec7de4c6729f8c2ce25d32faf6d6b4219da8515c2 + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" url: "https://pub.dev" source: hosted - version: "4.9.0" - firebase_messaging_web: + version: "0.9.5" + file_selector_windows: dependency: transitive description: - name: firebase_messaging_web - sha256: "7ab45dfaf8efcd1a769baa9b8debbd0da281f5d3fc07274296b564396a980292" + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec url: "https://pub.dev" source: hosted - version: "4.2.1" + version: "0.9.3+6" fixnum: dependency: transitive description: @@ -397,22 +349,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_appauth: - dependency: transitive - description: - name: flutter_appauth - sha256: d8be972036909e99c022bbb8edcad126dbd2cbaceaa2eb85e35791b599f3e9cf - url: "https://pub.dev" - source: hosted - version: "12.0.1" - flutter_appauth_platform_interface: - dependency: transitive - description: - name: flutter_appauth_platform_interface - sha256: b7c7d4f288af7b3119a9db0ea00cf5e93135d0e83c3687172848bc5c4fdec992 - url: "https://pub.dev" - source: hosted - version: "12.0.1" flutter_bloc: dependency: transitive description: @@ -510,10 +446,10 @@ packages: dependency: transitive description: name: flutter_secure_storage_windows - sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.2.2" flutter_svg: dependency: "direct main" description: @@ -556,14 +492,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.dev" - source: hosted - version: "4.0.0" get_it: dependency: transitive description: @@ -588,54 +516,6 @@ packages: url: "https://pub.dev" source: hosted version: "17.3.0" - google_identity_services_web: - dependency: transitive - description: - name: google_identity_services_web - sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" - url: "https://pub.dev" - source: hosted - version: "0.3.3+1" - google_sign_in: - dependency: transitive - description: - name: google_sign_in - sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" - url: "https://pub.dev" - source: hosted - version: "7.2.0" - google_sign_in_android: - dependency: transitive - description: - name: google_sign_in_android - sha256: "5b89f1d3c095cc53dfa4f23fbfa88da06dff3fdeb1c86656f30cf8b4ca0e7af8" - url: "https://pub.dev" - source: hosted - version: "7.2.13" - google_sign_in_ios: - dependency: transitive - description: - name: google_sign_in_ios - sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950 - url: "https://pub.dev" - source: hosted - version: "6.3.0" - google_sign_in_platform_interface: - dependency: transitive - description: - name: google_sign_in_platform_interface - sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - google_sign_in_web: - dependency: transitive - description: - name: google_sign_in_web - sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 - url: "https://pub.dev" - source: hosted - version: "1.1.3" graphs: dependency: transitive description: @@ -644,14 +524,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: transitive description: name: http - sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -700,14 +588,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" json_annotation: dependency: transitive description: @@ -716,78 +596,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.9.0" - kakao_flutter_sdk: - dependency: transitive - description: - name: kakao_flutter_sdk - sha256: "63ef8b145593d2e0180c507958856e63e4e78e6be545b8428cf411b273ff2683" - url: "https://pub.dev" - source: hosted - version: "1.9.7+3" - kakao_flutter_sdk_auth: - dependency: transitive - description: - name: kakao_flutter_sdk_auth - sha256: "028d8803b7545cc5f41d20cda43b813683700e45c6c13aa4ca56f4b9c673305c" - url: "https://pub.dev" - source: hosted - version: "1.9.7+3" - kakao_flutter_sdk_common: - dependency: transitive - description: - name: kakao_flutter_sdk_common - sha256: "1c4944cc50c363d4626e9006ab39ee496c8f5ca602b96154272b90d40544aaca" - url: "https://pub.dev" - source: hosted - version: "1.9.7+3" - kakao_flutter_sdk_friend: - dependency: transitive - description: - name: kakao_flutter_sdk_friend - sha256: "822866e9c9987f766dec09d103f2e5d4de9d919781f6c38c309d1a1a7bab7d60" - url: "https://pub.dev" - source: hosted - version: "1.9.7+3" - kakao_flutter_sdk_navi: - dependency: transitive - description: - name: kakao_flutter_sdk_navi - sha256: c865cf94c7c0a1287844dc4b3def56360f227352ade99bdbb08b1bdeff5f7657 - url: "https://pub.dev" - source: hosted - version: "1.9.7+3" - kakao_flutter_sdk_share: - dependency: transitive - description: - name: kakao_flutter_sdk_share - sha256: "04e1fcd44c47d98782dfb7d13f93ed6a4e4da700b580da37c30bb9f24058ed63" - url: "https://pub.dev" - source: hosted - version: "1.9.7+3" - kakao_flutter_sdk_talk: - dependency: transitive - description: - name: kakao_flutter_sdk_talk - sha256: "5c61373beb9a3d5a8d7425cb0af13ba443c5077f7bbca09376c799a63c83e4c3" - url: "https://pub.dev" - source: hosted - version: "1.9.7+3" - kakao_flutter_sdk_template: - dependency: transitive - description: - name: kakao_flutter_sdk_template - sha256: ee330ad9a4ebed85ec214a7f58dd25eca0d0e5680168db5a04426a217973ac9f - url: "https://pub.dev" - source: hosted - version: "1.9.7+3" - kakao_flutter_sdk_user: - dependency: transitive - description: - name: kakao_flutter_sdk_user - sha256: "5157feeafe58d677d314baa5ccdcb435fd9680c485c3b23e9ad7d97a0c93694f" - url: "https://pub.dev" - source: hosted - version: "1.9.7+3" leak_tracker: dependency: transitive description: @@ -874,7 +682,7 @@ packages: path: ".." relative: true source: path - version: "1.0.0+1" + version: "1.1.0+56" package_config: dependency: transitive description: @@ -883,6 +691,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" + url: "https://pub.dev" + source: hosted + version: "10.2.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" path: dependency: transitive description: @@ -1019,14 +843,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - pointycastle: - dependency: transitive - description: - name: pointycastle - sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" - url: "https://pub.dev" - source: hosted - version: "3.9.1" pool: dependency: transitive description: @@ -1147,30 +963,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" - sign_in_with_apple: - dependency: transitive - description: - name: sign_in_with_apple - sha256: d284f8e235ab0c5be09a1e9146466912bae8f15f0bd5eb3c4cb999b57cd5c3f9 - url: "https://pub.dev" - source: hosted - version: "8.1.0" - sign_in_with_apple_platform_interface: - dependency: transitive - description: - name: sign_in_with_apple_platform_interface - sha256: "981bca52cf3bb9c3ad7ef44aace2d543e5c468bb713fd8dda4275ff76dfa6659" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - sign_in_with_apple_web: - dependency: transitive - description: - name: sign_in_with_apple_web - sha256: f316400827f52cafcf50d00e1a2e8a0abc534ca1264e856a81c5f06bd5b10fed - url: "https://pub.dev" - source: hosted - version: "3.0.0" simple_gesture_detector: dependency: transitive description: @@ -1184,14 +976,30 @@ packages: description: flutter source: sdk version: "0.0.0" + sodium: + dependency: transitive + description: + name: sodium + sha256: "515b86c186f4caca49051caf858d878ca7cc4ff4542411e9febb50654eac8a62" + url: "https://pub.dev" + source: hosted + version: "3.4.6" + sodium_libs: + dependency: transitive + description: + name: sodium_libs + sha256: f3f9c516b4183226b7a08ca43a765ebc9e02cfd92e46e8a6cc490f98ffe73052 + url: "https://pub.dev" + source: hosted + version: "3.4.6+4" source_gen: dependency: transitive description: name: source_gen - sha256: "7b19d6ba131c6eb98bfcbf8d56c1a7002eba438af2e7ae6f8398b2b0f4f381e3" + sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "4.2.4" source_span: dependency: transitive description: @@ -1208,22 +1016,22 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" - sqlite3: + sqlcipher_flutter_libs: dependency: transitive description: - name: sqlite3 - sha256: c0503c69b44d5714e6abbf4c1f51a3c3cc42b75ce785f44404765e4635481d38 + name: sqlcipher_flutter_libs + sha256: dd1fcc74d5baf3c36ad53e2652b2d06c9f8747494a3ccde0076e88b159dfe622 url: "https://pub.dev" source: hosted - version: "2.7.6" - sqlite3_flutter_libs: + version: "0.6.8" + sqlite3: dependency: transitive description: - name: sqlite3_flutter_libs - sha256: e07232b998755fe795655c56d1f5426e0190c9c435e1752d39e7b1cd33699c71 + name: sqlite3 + sha256: c0503c69b44d5714e6abbf4c1f51a3c3cc42b75ce785f44404765e4635481d38 url: "https://pub.dev" source: hosted - version: "0.5.34" + version: "2.7.6" stack_trace: dependency: transitive description: @@ -1256,6 +1064,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" + url: "https://pub.dev" + source: hosted + version: "3.4.1+2" table_calendar: dependency: transitive description: @@ -1288,14 +1104,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.10.1" - timing: - dependency: transitive - description: - name: timing - sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" - url: "https://pub.dev" - source: hosted - version: "1.0.2" typed_data: dependency: transitive description: @@ -1304,6 +1112,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unorm_dart: + dependency: transitive + description: + name: unorm_dart + sha256: "0c69186b03ca6addab0774bcc0f4f17b88d4ce78d9d4d8f0619e30a99ead58e7" + url: "https://pub.dev" + source: hosted + version: "0.3.2" url_launcher: dependency: transitive description: @@ -1448,46 +1264,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" - webview_flutter: - dependency: transitive - description: - name: webview_flutter - sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba - url: "https://pub.dev" - source: hosted - version: "4.13.0" - webview_flutter_android: - dependency: transitive - description: - name: webview_flutter_android - sha256: f6e6afef6e234801da77170f7a1847ded8450778caf2fe13979d140484be3678 - url: "https://pub.dev" - source: hosted - version: "4.7.0" - webview_flutter_platform_interface: - dependency: transitive - description: - name: webview_flutter_platform_interface - sha256: f0dc2dc3a2b1e3a6abdd6801b9355ebfeb3b8f6cde6b9dc7c9235909c4a1f147 - url: "https://pub.dev" - source: hosted - version: "2.13.1" - webview_flutter_wkwebview: - dependency: transitive - description: - name: webview_flutter_wkwebview - sha256: a3d461fe3467014e05f3ac4962e5fdde2a4bf44c561cb53e9ae5c586600fdbc3 - url: "https://pub.dev" - source: hosted - version: "3.22.0" widgetbook: dependency: "direct main" description: name: widgetbook - sha256: a9d58080174ba5666e8515311b4c952ba02ddcb5cc9d9db244d0aac447988052 + sha256: "88b10102d294d0bec64ca294c81f15b1a620ce66df950067ff8f89274f1c95e8" url: "https://pub.dev" source: hosted - version: "3.14.3" + version: "3.25.0" widgetbook_annotation: dependency: "direct main" description: @@ -1500,18 +1284,18 @@ packages: dependency: "direct dev" description: name: widgetbook_generator - sha256: "17556f26f786881aa7346935e1314a95c52dabb99bdee8407b8385f934ea5e69" + sha256: "8fd3b4208bb74cbe60cd6fc99b443561b65542658b20d6dd700a2496edbd5647" url: "https://pub.dev" source: hosted - version: "3.16.0" + version: "3.24.0" win32: dependency: transitive description: name: win32 - sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d url: "https://pub.dev" source: hosted - version: "5.14.0" + version: "6.4.0" xdg_directories: dependency: transitive description: diff --git a/widgetbook/pubspec.yaml b/widgetbook/pubspec.yaml index 1dd61496..41faaaca 100644 --- a/widgetbook/pubspec.yaml +++ b/widgetbook/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: path: ../assets flutter: sdk: flutter - widgetbook: ^3.10.0 + widgetbook: ^3.25.0 widgetbook_annotation: ^3.2.0 flutter_svg: ^2.0.14 uuid: ^4.5.1 @@ -22,8 +22,8 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^4.0.0 - widgetbook_generator: ^3.9.0 - build_runner: ^2.4.13 + widgetbook_generator: ^3.24.0 + build_runner: ^2.15.1 flutter: - uses-material-design: true \ No newline at end of file + uses-material-design: true diff --git a/widgetbook/windows/flutter/generated_plugin_registrant.cc b/widgetbook/windows/flutter/generated_plugin_registrant.cc index 301c4b55..0e239095 100644 --- a/widgetbook/windows/flutter/generated_plugin_registrant.cc +++ b/widgetbook/windows/flutter/generated_plugin_registrant.cc @@ -6,19 +6,22 @@ #include "generated_plugin_registrant.h" -#include +#include #include #include -#include +#include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { - FirebaseCorePluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + SodiumLibsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SodiumLibsPluginCApi")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/widgetbook/windows/flutter/generated_plugins.cmake b/widgetbook/windows/flutter/generated_plugins.cmake index 79f1d5d5..c2cd331d 100644 --- a/widgetbook/windows/flutter/generated_plugins.cmake +++ b/widgetbook/windows/flutter/generated_plugins.cmake @@ -3,10 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST - firebase_core + file_selector_windows flutter_secure_storage_windows permission_handler_windows - sqlite3_flutter_libs + sodium_libs + sqlcipher_flutter_libs url_launcher_windows ) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index c9a2e7f3..617ba24a 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -11,7 +11,6 @@ #include #include #include -#include void RegisterPlugins(flutter::PluginRegistry* registry) { FileSelectorWindowsRegisterWithRegistrar( @@ -24,6 +23,4 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("SodiumLibsPluginCApi")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); - Sqlite3FlutterLibsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 9b168dfa..b8383b01 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -8,7 +8,6 @@ list(APPEND FLUTTER_PLUGIN_LIST permission_handler_windows sodium_libs sqlcipher_flutter_libs - sqlite3_flutter_libs ) list(APPEND FLUTTER_FFI_PLUGIN_LIST