From 4459852f83cf269e3ef5f4e34e651a82f9ace5c6 Mon Sep 17 00:00:00 2001 From: nathanAndrinoid Date: Fri, 5 Jun 2026 03:22:35 +0000 Subject: [PATCH] spelling --- .flutter-plugins-dependencies | 2 +- lib/admin/widgets/admin_app_bar_action.dart | 2 +- .../remote/question_source_api_exception.dart | 9 + .../remote/question_source_remote_store.dart | 87 ++++ .../remote/spelling_words_remote_store.dart | 120 ++++++ lib/main.dart | 8 + lib/models/incoming_question.dart | 78 ++++ lib/models/question_source_preference.dart | 88 ++++ .../question_source_config_repository.dart | 162 ++++++++ lib/routes/app_routes.dart | 4 + lib/screens/home_screen.dart | 217 ++++++---- lib/screens/spelling_list_screen.dart | 335 +++++++++++++++ lib/services/questions_api_service.dart | 42 +- lib/services/questions_hub_service.dart | 142 ++++++- lib/services/spelling_text_to_speech.dart | 40 ++ .../spelling_tts/spelling_tts_backend.dart | 5 + .../spelling_tts/spelling_tts_flutter.dart | 43 ++ .../spelling_tts/spelling_tts_web.dart | 102 +++++ lib/utils/spelling_spoken_word.dart | 24 ++ lib/widgets/hub_toolbar_icon_button.dart | 82 ++++ lib/widgets/market_question_placeholder.dart | 65 +++ lib/widgets/profile_session.dart | 8 +- lib/widgets/question_presentation_router.dart | 51 +++ .../question_source_settings_sheet.dart | 337 +++++++++++++++ .../spelling_question_placeholder.dart | 65 +++ lib/widgets/spelling_question_tile.dart | 230 +++++++++++ lib/widgets/swipe_question_tile.dart | 31 +- macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.lock | 40 ++ pubspec.yaml | 2 + scripts/build-production-web.sh | 11 +- scripts/stamp-web-cache-bust.sh | 15 + server/README.md | 207 +++++----- server/bin/server.dart | 34 ++ .../lib/handlers/question_source_handler.dart | 82 ++++ server/lib/handlers/questions_handler.dart | 88 ++-- .../lib/handlers/spelling_words_handler.dart | 137 +++++++ server/lib/pipeline/question_pipeline.dart | 382 +++++++++++++++++- server/lib/pipeline/spelling_word_list.dart | 68 ++++ server/lib/question_dispatcher.dart | 288 +++++++++++++ server/lib/question_service.dart | 52 ++- server/lib/question_source_mix.dart | 70 ++++ server/lib/question_source_preferences.dart | 215 ++++++++++ .../lib/question_source_preferences_db.dart | 78 ++++ server/lib/questions_db.dart | 285 ++++++++++++- .../lib/spelling/spelling_answer_policy.dart | 6 + server/lib/spelling/spelling_item_record.dart | 42 ++ .../lib/spelling/spelling_item_selector.dart | 56 +++ server/lib/spelling/spelling_items_db.dart | 250 ++++++++++++ .../spelling/spelling_progress_policy.dart | 38 ++ .../spelling/spelling_question_links_db.dart | 48 +++ .../spelling/user_spelling_progress_db.dart | 114 ++++++ .../018_question_source_preferences.sql | 2 + server/migrations/019_spelling_catalog.sql | 35 ++ .../020_spelling_question_links.sql | 14 + .../021_questions_user_response_text.sql | 4 + server/test/helpers/test_db.dart | 33 +- ...et_history_prospective_questions_test.dart | 7 +- .../question_dispatcher_mix_test.dart | 125 ++++++ .../question_source_preferences_test.dart | 186 +++++++++ .../integration/spelling_catalog_test.dart | 209 ++++++++++ .../integration/spelling_only_panel_test.dart | 62 +++ .../integration/spelling_pipeline_test.dart | 253 ++++++++++++ .../integration/spelling_words_api_test.dart | 64 +++ .../pipeline/spelling_word_list_test.dart | 17 + server/test/question_dispatcher_test.dart | 121 ++++++ server/test/question_source_mix_test.dart | 89 ++++ .../question_source_preferences_test.dart | 78 ++++ .../test/questions_client_payload_test.dart | 55 +++ .../spelling/spelling_answer_policy_test.dart | 9 + .../spelling/spelling_item_selector_test.dart | 82 ++++ .../spelling_progress_policy_test.dart | 39 ++ .../spelling/spelling_words_catalog_test.dart | 19 + .../widgets/admin_app_bar_action_test.dart | 2 +- test/models/incoming_question_test.dart | 47 +++ test/question_source_settings_test.dart | 139 +++++++ ...uestion_source_config_repository_test.dart | 137 +++++++ test/utils/spelling_spoken_word_test.dart | 29 ++ .../question_presentation_router_test.dart | 133 ++++++ .../spelling_question_placeholder_test.dart | 24 ++ .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 82 files changed, 6745 insertions(+), 264 deletions(-) create mode 100644 lib/data/remote/question_source_api_exception.dart create mode 100644 lib/data/remote/question_source_remote_store.dart create mode 100644 lib/data/remote/spelling_words_remote_store.dart create mode 100644 lib/models/question_source_preference.dart create mode 100644 lib/repositories/question_source_config_repository.dart create mode 100644 lib/routes/app_routes.dart create mode 100644 lib/screens/spelling_list_screen.dart create mode 100644 lib/services/spelling_text_to_speech.dart create mode 100644 lib/services/spelling_tts/spelling_tts_backend.dart create mode 100644 lib/services/spelling_tts/spelling_tts_flutter.dart create mode 100644 lib/services/spelling_tts/spelling_tts_web.dart create mode 100644 lib/utils/spelling_spoken_word.dart create mode 100644 lib/widgets/hub_toolbar_icon_button.dart create mode 100644 lib/widgets/market_question_placeholder.dart create mode 100644 lib/widgets/question_presentation_router.dart create mode 100644 lib/widgets/question_source_settings_sheet.dart create mode 100644 lib/widgets/spelling_question_placeholder.dart create mode 100644 lib/widgets/spelling_question_tile.dart create mode 100644 server/lib/handlers/question_source_handler.dart create mode 100644 server/lib/handlers/spelling_words_handler.dart create mode 100644 server/lib/pipeline/spelling_word_list.dart create mode 100644 server/lib/question_dispatcher.dart create mode 100644 server/lib/question_source_mix.dart create mode 100644 server/lib/question_source_preferences.dart create mode 100644 server/lib/question_source_preferences_db.dart create mode 100644 server/lib/spelling/spelling_answer_policy.dart create mode 100644 server/lib/spelling/spelling_item_record.dart create mode 100644 server/lib/spelling/spelling_item_selector.dart create mode 100644 server/lib/spelling/spelling_items_db.dart create mode 100644 server/lib/spelling/spelling_progress_policy.dart create mode 100644 server/lib/spelling/spelling_question_links_db.dart create mode 100644 server/lib/spelling/user_spelling_progress_db.dart create mode 100644 server/migrations/018_question_source_preferences.sql create mode 100644 server/migrations/019_spelling_catalog.sql create mode 100644 server/migrations/020_spelling_question_links.sql create mode 100644 server/migrations/021_questions_user_response_text.sql create mode 100644 server/test/integration/question_dispatcher_mix_test.dart create mode 100644 server/test/integration/question_source_preferences_test.dart create mode 100644 server/test/integration/spelling_catalog_test.dart create mode 100644 server/test/integration/spelling_only_panel_test.dart create mode 100644 server/test/integration/spelling_pipeline_test.dart create mode 100644 server/test/integration/spelling_words_api_test.dart create mode 100644 server/test/pipeline/spelling_word_list_test.dart create mode 100644 server/test/question_dispatcher_test.dart create mode 100644 server/test/question_source_mix_test.dart create mode 100644 server/test/question_source_preferences_test.dart create mode 100644 server/test/questions_client_payload_test.dart create mode 100644 server/test/spelling/spelling_answer_policy_test.dart create mode 100644 server/test/spelling/spelling_item_selector_test.dart create mode 100644 server/test/spelling/spelling_progress_policy_test.dart create mode 100644 server/test/spelling/spelling_words_catalog_test.dart create mode 100644 test/models/incoming_question_test.dart create mode 100644 test/question_source_settings_test.dart create mode 100644 test/repositories/question_source_config_repository_test.dart create mode 100644 test/utils/spelling_spoken_word_test.dart create mode 100644 test/widgets/question_presentation_router_test.dart create mode 100644 test/widgets/spelling_question_placeholder_test.dart diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index 2302a1b..06dfd63 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_mobile","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_mobile-0.2.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_ios","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_ios-6.3.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/chh/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/chh/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_ios","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_ios-6.4.1/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_mobile","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_mobile-0.2.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_android","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_android-7.2.11/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/chh/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/chh/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/chh/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"shared_preferences_android","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_android-2.4.23/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/chh/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_android","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_android-6.3.29/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_desktop","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_desktop-0.2.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_ios","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_ios-6.3.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/chh/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/chh/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_macos","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_macos-3.2.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_desktop","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_desktop-0.2.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/chh/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/chh/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_linux-2.4.1/","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/chh/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_linux","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.2/","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_desktop","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_desktop-0.2.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/chh/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/chh/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_windows-2.4.1/","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/chh/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_windows","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_windows-3.1.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","dependencies":[],"dev_dependency":false},{"name":"firebase_auth_web","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_auth_web-6.2.1/","dependencies":["firebase_core_web"],"dev_dependency":false},{"name":"firebase_core_web","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_core_web-3.7.0/","dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_web","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_web-0.1.0/","dependencies":["google_sign_in_web"],"dev_dependency":false},{"name":"google_sign_in_web","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_web-1.1.3/","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_web-2.4.3/","dependencies":[],"dev_dependency":false},{"name":"url_launcher_web","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_web-2.4.3/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"connectivity_plus","dependencies":[]},{"name":"firebase_auth","dependencies":["firebase_auth_web","firebase_core"]},{"name":"firebase_auth_web","dependencies":["firebase_core","firebase_core_web"]},{"name":"firebase_core","dependencies":["firebase_core_web"]},{"name":"firebase_core_web","dependencies":[]},{"name":"google_sign_in","dependencies":["google_sign_in_android","google_sign_in_ios","google_sign_in_web"]},{"name":"google_sign_in_all_platforms","dependencies":["google_sign_in_all_platforms_desktop","google_sign_in_all_platforms_mobile","google_sign_in_all_platforms_web"]},{"name":"google_sign_in_all_platforms_desktop","dependencies":["url_launcher"]},{"name":"google_sign_in_all_platforms_mobile","dependencies":["google_sign_in"]},{"name":"google_sign_in_all_platforms_web","dependencies":["google_sign_in","google_sign_in_web","google_sign_in_all_platforms_mobile"]},{"name":"google_sign_in_android","dependencies":[]},{"name":"google_sign_in_ios","dependencies":[]},{"name":"google_sign_in_web","dependencies":[]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"sqlite3_flutter_libs","dependencies":[]},{"name":"url_launcher","dependencies":["url_launcher_android","url_launcher_ios","url_launcher_linux","url_launcher_macos","url_launcher_web","url_launcher_windows"]},{"name":"url_launcher_android","dependencies":[]},{"name":"url_launcher_ios","dependencies":[]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_macos","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]}],"date_created":"2026-06-04 13:56:09.675305","version":"3.44.1","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_picker","path":"/home/chh/.pub-cache/hosted/pub.dev/file_picker-8.3.7/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_tts","path":"/home/chh/.pub-cache/hosted/pub.dev/flutter_tts-4.2.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_mobile","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_mobile-0.2.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_ios","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_ios-6.3.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/chh/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/chh/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_ios","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_ios-6.4.1/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_picker","path":"/home/chh/.pub-cache/hosted/pub.dev/file_picker-8.3.7/","native_build":true,"dependencies":["flutter_plugin_android_lifecycle"],"dev_dependency":false},{"name":"firebase_auth","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_plugin_android_lifecycle","path":"/home/chh/.pub-cache/hosted/pub.dev/flutter_plugin_android_lifecycle-2.0.35/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_tts","path":"/home/chh/.pub-cache/hosted/pub.dev/flutter_tts-4.2.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_mobile","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_mobile-0.2.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_android","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_android-7.2.11/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/chh/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/chh/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/chh/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"shared_preferences_android","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_android-2.4.23/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/chh/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_android","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_android-6.3.29/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_picker","path":"/home/chh/.pub-cache/hosted/pub.dev/file_picker-8.3.7/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_tts","path":"/home/chh/.pub-cache/hosted/pub.dev/flutter_tts-4.2.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_desktop","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_desktop-0.2.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_ios","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_ios-6.3.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/chh/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/chh/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_macos","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_macos-3.2.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_picker","path":"/home/chh/.pub-cache/hosted/pub.dev/file_picker-8.3.7/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_desktop","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_desktop-0.2.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/chh/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/chh/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_linux-2.4.1/","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/chh/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_linux","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.2/","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_picker","path":"/home/chh/.pub-cache/hosted/pub.dev/file_picker-8.3.7/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_tts","path":"/home/chh/.pub-cache/hosted/pub.dev/flutter_tts-4.2.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_desktop","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_desktop-0.2.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/chh/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/chh/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_windows-2.4.1/","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/chh/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_windows","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_windows-3.1.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","dependencies":[],"dev_dependency":false},{"name":"file_picker","path":"/home/chh/.pub-cache/hosted/pub.dev/file_picker-8.3.7/","dependencies":[],"dev_dependency":false},{"name":"firebase_auth_web","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_auth_web-6.2.1/","dependencies":["firebase_core_web"],"dev_dependency":false},{"name":"firebase_core_web","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_core_web-3.7.0/","dependencies":[],"dev_dependency":false},{"name":"flutter_tts","path":"/home/chh/.pub-cache/hosted/pub.dev/flutter_tts-4.2.5/","dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_web","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_web-0.1.0/","dependencies":["google_sign_in_web"],"dev_dependency":false},{"name":"google_sign_in_web","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_web-1.1.3/","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_web-2.4.3/","dependencies":[],"dev_dependency":false},{"name":"url_launcher_web","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_web-2.4.3/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"connectivity_plus","dependencies":[]},{"name":"file_picker","dependencies":["flutter_plugin_android_lifecycle"]},{"name":"firebase_auth","dependencies":["firebase_auth_web","firebase_core"]},{"name":"firebase_auth_web","dependencies":["firebase_core","firebase_core_web"]},{"name":"firebase_core","dependencies":["firebase_core_web"]},{"name":"firebase_core_web","dependencies":[]},{"name":"flutter_plugin_android_lifecycle","dependencies":[]},{"name":"flutter_tts","dependencies":[]},{"name":"google_sign_in","dependencies":["google_sign_in_android","google_sign_in_ios","google_sign_in_web"]},{"name":"google_sign_in_all_platforms","dependencies":["google_sign_in_all_platforms_desktop","google_sign_in_all_platforms_mobile","google_sign_in_all_platforms_web"]},{"name":"google_sign_in_all_platforms_desktop","dependencies":["url_launcher"]},{"name":"google_sign_in_all_platforms_mobile","dependencies":["google_sign_in"]},{"name":"google_sign_in_all_platforms_web","dependencies":["google_sign_in","google_sign_in_web","google_sign_in_all_platforms_mobile"]},{"name":"google_sign_in_android","dependencies":[]},{"name":"google_sign_in_ios","dependencies":[]},{"name":"google_sign_in_web","dependencies":[]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"sqlite3_flutter_libs","dependencies":[]},{"name":"url_launcher","dependencies":["url_launcher_android","url_launcher_ios","url_launcher_linux","url_launcher_macos","url_launcher_web","url_launcher_windows"]},{"name":"url_launcher_android","dependencies":[]},{"name":"url_launcher_ios","dependencies":[]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_macos","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]}],"date_created":"2026-06-05 02:34:16.711710","version":"3.44.1","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/lib/admin/widgets/admin_app_bar_action.dart b/lib/admin/widgets/admin_app_bar_action.dart index 2f5b478..774df53 100644 --- a/lib/admin/widgets/admin_app_bar_action.dart +++ b/lib/admin/widgets/admin_app_bar_action.dart @@ -35,7 +35,7 @@ class _AdminAppBarActionState extends State { onPressed: () { Navigator.of(context).pushNamed(AdminRoutes.marketHistoryLog); }, - icon: const Icon(Icons.admin_panel_settings_outlined), + icon: const Icon(Icons.admin_panel_settings), ); }, ); diff --git a/lib/data/remote/question_source_api_exception.dart b/lib/data/remote/question_source_api_exception.dart new file mode 100644 index 0000000..9fe1428 --- /dev/null +++ b/lib/data/remote/question_source_api_exception.dart @@ -0,0 +1,9 @@ +class QuestionSourceApiException implements Exception { + QuestionSourceApiException(this.statusCode, this.body); + + final int statusCode; + final String body; + + @override + String toString() => 'QuestionSourceApiException($statusCode): $body'; +} diff --git a/lib/data/remote/question_source_remote_store.dart b/lib/data/remote/question_source_remote_store.dart new file mode 100644 index 0000000..b0e2e69 --- /dev/null +++ b/lib/data/remote/question_source_remote_store.dart @@ -0,0 +1,87 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../../config/api_config.dart'; +import '../../models/question_source_preference.dart'; +import '../../services/auth_service.dart'; +import 'question_source_api_exception.dart'; + +/// HTTP client for per-user question source preferences. +class QuestionSourceRemoteStore { + QuestionSourceRemoteStore({http.Client? client}) + : _client = client ?? http.Client(); + + final http.Client _client; + + Future?> fetchPreferences() async { + final String? token = await AuthService.instance.getIdToken(); + if (token == null) { + return null; + } + + final http.Response response = await _client.get( + Uri.parse('$apiBaseUrl/v1/me/question-sources'), + headers: _authHeaders(token), + ); + + if (response.statusCode != 200) { + throw QuestionSourceApiException(response.statusCode, response.body); + } + + final Map body = + jsonDecode(response.body) as Map; + final List sources = + body['sources'] as List? ?? []; + return sources + .map( + (dynamic entry) => QuestionSourcePreference.fromJson( + entry as Map, + ), + ) + .toList(); + } + + Future> savePreferences( + List sources, + ) async { + final String? token = await AuthService.instance.getIdToken(); + if (token == null) { + throw StateError('Not signed in'); + } + + final http.Response response = await _client.put( + Uri.parse('$apiBaseUrl/v1/me/question-sources'), + headers: _authHeaders(token), + body: jsonEncode({ + 'sources': sources + .map((QuestionSourcePreference source) => source.toStorageJson()) + .toList(), + }), + ); + + if (response.statusCode != 200) { + throw QuestionSourceApiException(response.statusCode, response.body); + } + + final Map body = + jsonDecode(response.body) as Map; + final List saved = + body['sources'] as List? ?? []; + return saved + .map( + (dynamic entry) => QuestionSourcePreference.fromJson( + entry as Map, + ), + ) + .toList(); + } + + Map _authHeaders(String token) { + return { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + } +} diff --git a/lib/data/remote/spelling_words_remote_store.dart b/lib/data/remote/spelling_words_remote_store.dart new file mode 100644 index 0000000..1e3ef80 --- /dev/null +++ b/lib/data/remote/spelling_words_remote_store.dart @@ -0,0 +1,120 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../../config/api_config.dart'; +import '../../services/auth_service.dart'; + +class SpellingWordsApiException implements Exception { + SpellingWordsApiException(this.statusCode, this.body); + + final int statusCode; + final String body; + + @override + String toString() => 'SpellingWordsApiException($statusCode): $body'; +} + +/// HTTP client for the shared spelling word catalog. +class SpellingWordsRemoteStore { + SpellingWordsRemoteStore({http.Client? client}) + : _client = client ?? http.Client(); + + final http.Client _client; + + static const String _basePath = '/v1/me/spelling-words'; + + Future> fetchWords() async { + final String? token = await AuthService.instance.getIdToken(); + if (token == null) { + throw StateError('Not signed in'); + } + + final http.Response response = await _client.get( + Uri.parse('$apiBaseUrl$_basePath'), + headers: _authHeaders(token), + ); + if (response.statusCode != 200) { + throw SpellingWordsApiException(response.statusCode, response.body); + } + + final Map body = + jsonDecode(response.body) as Map; + final List words = body['words'] as List? ?? []; + return words.map((dynamic w) => w as String).toList(growable: false); + } + + Future> addWord(String word) async { + final String? token = await AuthService.instance.getIdToken(); + if (token == null) { + throw StateError('Not signed in'); + } + + final http.Response response = await _client.post( + Uri.parse('$apiBaseUrl$_basePath'), + headers: _authHeaders(token), + body: jsonEncode({'word': word}), + ); + if (response.statusCode != 201) { + throw SpellingWordsApiException(response.statusCode, response.body); + } + + final Map body = + jsonDecode(response.body) as Map; + final List words = body['words'] as List? ?? []; + return words.map((dynamic w) => w as String).toList(growable: false); + } + + Future<({int added, int skipped})> importText(String text) async { + final String? token = await AuthService.instance.getIdToken(); + if (token == null) { + throw StateError('Not signed in'); + } + + final http.Response response = await _client.post( + Uri.parse('$apiBaseUrl$_basePath/import'), + headers: { + ..._authHeaders(token), + 'Content-Type': 'text/plain; charset=utf-8', + }, + body: text, + ); + if (response.statusCode != 200) { + throw SpellingWordsApiException(response.statusCode, response.body); + } + + final Map body = + jsonDecode(response.body) as Map; + return ( + added: (body['added'] as num?)?.toInt() ?? 0, + skipped: (body['skipped'] as num?)?.toInt() ?? 0, + ); + } + + Future exportText() async { + final String? token = await AuthService.instance.getIdToken(); + if (token == null) { + throw StateError('Not signed in'); + } + + final http.Response response = await _client.get( + Uri.parse('$apiBaseUrl$_basePath/export'), + headers: { + 'Authorization': 'Bearer $token', + 'Accept': 'text/plain', + }, + ); + if (response.statusCode != 200) { + throw SpellingWordsApiException(response.statusCode, response.body); + } + return response.body; + } + + Map _authHeaders(String token) { + return { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + } +} diff --git a/lib/main.dart b/lib/main.dart index da6ad27..b8ef82c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,8 +5,10 @@ import 'admin/screens/market_history_log_screen.dart'; import 'admin/widgets/admin_gate.dart'; import 'bootstrap.dart'; import 'models/app_user.dart'; +import 'routes/app_routes.dart'; import 'screens/home_screen.dart'; import 'screens/landing_screen.dart'; +import 'screens/spelling_list_screen.dart'; import 'services/auth_service.dart'; import 'theme/app_theme.dart'; import 'widgets/profile_session.dart'; @@ -36,6 +38,12 @@ class CyberHybridHubApp extends StatelessWidget { ), ); } + if (settings.name == AppRoutes.spellingWordList) { + return MaterialPageRoute( + settings: settings, + builder: (BuildContext context) => const SpellingListScreen(), + ); + } return null; }, ); diff --git a/lib/models/incoming_question.dart b/lib/models/incoming_question.dart index b1acfcc..f0fec91 100644 --- a/lib/models/incoming_question.dart +++ b/lib/models/incoming_question.dart @@ -5,8 +5,16 @@ class IncomingQuestion { required this.text, required this.sentAt, this.unansweredCount = 1, + this.sourceId = defaultSourceId, + this.presentation = defaultPresentation, + this.spokenWord, + this.audioTitle, + this.needsPrepare = false, }); + static const String defaultSourceId = 'market_history'; + static const String defaultPresentation = 'market_glyph_slider'; + final String id; final String text; final DateTime sentAt; @@ -14,12 +22,82 @@ class IncomingQuestion { /// Unanswered questions in the database for this user (from API). final int unansweredCount; + /// Question source (`market_history`, `spelling`, …). + final String sourceId; + + /// UI presentation key (`market_glyph_slider`, `spelling_word`, …). + final String presentation; + + /// Word read aloud after server-side selection at tile load. + final String? spokenWord; + + /// Question id referenced as the audio utterance title. + final String? audioTitle; + + /// True when the server still needs tile-load word selection. + final bool needsPrepare; + + /// Spelling pipeline source (word quiz and follow-up prompts). + bool get isSpelling => sourceId == 'spelling'; + + /// Typed spelling tile (hear word, type answer). + bool get usesSpellingWordTile { + if (presentation == 'spelling_word' || needsPrepare) { + return true; + } + if (!isSpelling) { + return false; + } + if (spokenWord != null && spokenWord!.isNotEmpty) { + return true; + } + return !isSpellingFollowUp; + } + + /// Spelling branch prompt to continue (+1) or pause (-1); uses swipe UI. + bool get isSpellingFollowUp { + if (!isSpelling || presentation == 'spelling_word') { + return false; + } + final String lower = text.toLowerCase(); + return lower.contains('enter +1') || + lower.contains('another word') || + lower.contains('to pause'); + } + + bool get isMarketHistory => sourceId == 'market_history'; + factory IncomingQuestion.fromJson(Map json) { return IncomingQuestion( id: json['id'] as String, text: json['text'] as String, sentAt: DateTime.parse(json['sentAt'] as String).toUtc(), unansweredCount: (json['unansweredCount'] as num?)?.toInt() ?? 1, + sourceId: json['sourceId'] as String? ?? defaultSourceId, + presentation: json['presentation'] as String? ?? defaultPresentation, + spokenWord: json['spokenWord'] as String?, + audioTitle: json['audioTitle'] as String?, + needsPrepare: json['needsPrepare'] as bool? ?? false, + ); + } + + IncomingQuestion copyWith({ + String? text, + String? spokenWord, + String? audioTitle, + int? unansweredCount, + bool? needsPrepare, + }) { + return IncomingQuestion( + id: id, + text: text ?? this.text, + sentAt: sentAt, + unansweredCount: unansweredCount ?? this.unansweredCount, + sourceId: sourceId, + presentation: presentation, + spokenWord: spokenWord ?? this.spokenWord, + audioTitle: audioTitle ?? this.audioTitle, + needsPrepare: needsPrepare ?? this.needsPrepare, ); } } diff --git a/lib/models/question_source_preference.dart b/lib/models/question_source_preference.dart new file mode 100644 index 0000000..7049f47 --- /dev/null +++ b/lib/models/question_source_preference.dart @@ -0,0 +1,88 @@ +/// User preference for one question delivery pipeline / API source. +class QuestionSourcePreference { + const QuestionSourcePreference({ + required this.id, + required this.label, + required this.description, + this.enabled = true, + this.ratio = 25, + this.comingSoon = false, + }); + + /// Pipeline key or source identifier (matches server `pipeline_key` / `source_tag`). + final String id; + final String label; + final String description; + + /// Whether this source may contribute questions. + final bool enabled; + + /// Relative weight (0–100). Effective mix uses enabled sources only. + final int ratio; + + /// UI-only stub: shown in settings but not yet wired to a pipeline. + final bool comingSoon; + + QuestionSourcePreference copyWith({ + String? label, + String? description, + bool? enabled, + int? ratio, + bool? comingSoon, + }) { + return QuestionSourcePreference( + id: id, + label: label ?? this.label, + description: description ?? this.description, + enabled: enabled ?? this.enabled, + ratio: ratio ?? this.ratio, + comingSoon: comingSoon ?? this.comingSoon, + ); + } + + Map toJson() { + return { + 'id': id, + 'label': label, + 'description': description, + 'enabled': enabled, + 'ratio': ratio, + 'comingSoon': comingSoon, + }; + } + + /// Payload persisted to the API (labels come from server defaults). + Map toStorageJson() { + return { + 'id': id, + 'enabled': enabled, + 'ratio': ratio, + }; + } + + factory QuestionSourcePreference.fromJson(Map json) { + return QuestionSourcePreference( + id: json['id'] as String, + label: json['label'] as String, + description: json['description'] as String? ?? '', + enabled: json['enabled'] as bool? ?? true, + ratio: (json['ratio'] as num?)?.round().clamp(0, 100) ?? 25, + comingSoon: json['comingSoon'] as bool? ?? false, + ); + } + + @override + bool operator ==(Object other) { + return other is QuestionSourcePreference && + other.id == id && + other.label == label && + other.description == description && + other.enabled == enabled && + other.ratio == ratio && + other.comingSoon == comingSoon; + } + + @override + int get hashCode => + Object.hash(id, label, description, enabled, ratio, comingSoon); +} diff --git a/lib/repositories/question_source_config_repository.dart b/lib/repositories/question_source_config_repository.dart new file mode 100644 index 0000000..f586214 --- /dev/null +++ b/lib/repositories/question_source_config_repository.dart @@ -0,0 +1,162 @@ +import 'package:flutter/foundation.dart'; + +import '../data/remote/question_source_remote_store.dart'; +import '../models/question_source_preference.dart'; + +/// Question source mix persisted via `/v1/me/question-sources`. +class QuestionSourceConfigRepository { + QuestionSourceConfigRepository._(); + + static final QuestionSourceConfigRepository instance = + QuestionSourceConfigRepository._(); + + static const List defaultPreferences = + [ + QuestionSourcePreference( + id: 'market_history', + label: 'Market question', + description: 'Prospective price-move guessing game', + ratio: 50, + ), + QuestionSourcePreference( + id: 'spelling', + label: 'Spelling', + description: 'Spell-check quiz from curated word lists', + ratio: 50, + ), + ]; + + final QuestionSourceRemoteStore _remote = QuestionSourceRemoteStore(); + + final ValueNotifier> preferences = + ValueNotifier>( + List.from(defaultPreferences), + ); + + String? _activeUid; + + List get current => + mergeWithDefaults(preferences.value); + + Future startSession(String firebaseUid) async { + if (_activeUid == firebaseUid) { + return; + } + _activeUid = firebaseUid; + await loadFromRemote(); + } + + Future endSession() async { + _activeUid = null; + resetToDefaults(); + } + + Future loadFromRemote() async { + try { + final List? remote = + await _remote.fetchPreferences(); + if (remote != null) { + replaceAll(remote); + } + } catch (_) { + replaceAll(defaultPreferences); + } + } + + Future saveAll(List next) async { + final List normalized = mergeWithDefaults(next); + if (_activeUid == null) { + replaceAll(normalized); + return; + } + + final List saved = + await _remote.savePreferences(normalized); + replaceAll(saved); + } + + void replaceAll(List next) { + preferences.value = mergeWithDefaults(next); + } + + /// Keeps saved toggles/weights while picking up new default sources and labels. + static List mergeWithDefaults( + List saved, + ) { + final Map byId = + { + for (final QuestionSourcePreference source in saved) source.id: source, + }; + + return defaultPreferences + .map((QuestionSourcePreference defaults) { + final QuestionSourcePreference? existing = byId[defaults.id]; + if (existing == null) { + return defaults; + } + return existing.copyWith( + label: defaults.label, + description: defaults.description, + comingSoon: defaults.comingSoon, + enabled: defaults.comingSoon ? false : existing.enabled, + ); + }) + .toList(growable: false); + } + + void resetToDefaults() { + replaceAll(defaultPreferences); + } + + /// True when spelling is the only enabled, non–coming-soon source. + static bool isSpellingOnlyEnabled(List sources) { + final Iterable enabled = sources.where( + (QuestionSourcePreference s) => s.enabled && !s.comingSoon, + ); + return enabled.length == 1 && enabled.first.id == 'spelling'; + } + + /// True when market history is the only enabled, non–coming-soon source. + static bool isMarketOnlyEnabled(List sources) { + final Iterable enabled = sources.where( + (QuestionSourcePreference s) => s.enabled && !s.comingSoon, + ); + return enabled.length == 1 && enabled.first.id == 'market_history'; + } + + static bool isMarketHistoryEnabled(List sources) { + for (final QuestionSourcePreference source in sources) { + if (source.id == 'market_history') { + return source.enabled && !source.comingSoon; + } + } + return false; + } + + static bool isSpellingEnabled(List sources) { + for (final QuestionSourcePreference source in sources) { + if (source.id == 'spelling') { + return source.enabled && !source.comingSoon; + } + } + return false; + } + + /// Effective share (0–100) for each enabled source; empty when none enabled. + static Map effectiveMix( + List sources, + ) { + final Iterable enabled = + sources.where((QuestionSourcePreference s) => s.enabled); + final int totalWeight = + enabled.fold(0, (int sum, QuestionSourcePreference s) => sum + s.ratio); + if (totalWeight <= 0) { + return {}; + } + + return { + for (final QuestionSourcePreference source in enabled) + source.id: ((source.ratio / totalWeight) * 100).round(), + }; + } +} diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart new file mode 100644 index 0000000..baa9dc8 --- /dev/null +++ b/lib/routes/app_routes.dart @@ -0,0 +1,4 @@ +/// Named routes for non-admin screens. +abstract final class AppRoutes { + static const String spellingWordList = '/spelling-words'; +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 8b0ffe2..e9a80d7 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -11,8 +11,13 @@ import '../repositories/user_profile_repository.dart'; import '../services/auth_service.dart'; import '../services/questions_hub_service.dart'; import '../theme/app_theme.dart'; +import '../widgets/hub_toolbar_icon_button.dart'; import '../widgets/profile_avatar.dart'; -import '../widgets/swipe_question_tile.dart'; +import '../widgets/question_source_settings_sheet.dart'; +import '../repositories/question_source_config_repository.dart'; +import '../widgets/question_presentation_router.dart'; +import '../widgets/market_question_placeholder.dart'; +import '../widgets/spelling_question_placeholder.dart'; class HomeScreen extends StatelessWidget { const HomeScreen({ @@ -45,32 +50,67 @@ class HomeScreen extends StatelessWidget { Scaffold( appBar: AppBar( backgroundColor: Colors.transparent, - centerTitle: true, + elevation: 0, + scrolledUnderElevation: 0, + automaticallyImplyLeading: false, + titleSpacing: 8, + centerTitle: false, title: ListenableBuilder( listenable: Listenable.merge([ QuestionsHubService.instance.hasPendingQuestion, QuestionsHubService.instance.pendingQuestion, QuestionsHubService.instance.pendingQuestionCount, QuestionsHubService.instance.guessScoreSummary, + QuestionsHubService.instance.questionPanelOpen, + QuestionsHubService.instance.questionQueue, + QuestionSourceConfigRepository.instance.preferences, ]), builder: (BuildContext context, Widget? child) { final int count = QuestionsHubService.instance.pendingQuestionCount.value; final bool hasPending = QuestionsHubService.instance.hasPendingQuestion.value; + final sources = QuestionSourceConfigRepository.instance.current; + final bool spellingOnly = + QuestionSourceConfigRepository.isSpellingOnlyEnabled(sources); + final bool marketOnly = + QuestionSourceConfigRepository.isMarketOnlyEnabled(sources); + final bool showEnvelope = spellingOnly || + marketOnly || + (hasPending && count >= 1); final GuessScoreSummary score = QuestionsHubService.instance.guessScoreSummary.value; + final IncomingQuestion? active = + QuestionsHubService.instance.currentQuestion; + final bool panelOpen = + QuestionsHubService.instance.questionPanelOpen.value; + final bool showMarketScore = !panelOpen || + active == null || + active.isMarketHistory; return Row( mainAxisSize: MainAxisSize.min, children: [ - _CumulativeScoreChip( - summary: score, - onPressed: () => _showGuessScoreStatsDialog(context, score), - ), - if (hasPending && count >= 1) ...[ + if (showMarketScore) + _CumulativeScoreChip( + summary: score, + onPressed: () => + _showGuessScoreStatsDialog(context, score), + ), + if (showMarketScore && showEnvelope) const SizedBox(width: 8), - _QuestionEnvelopeButton( - count: count, + if (showEnvelope) ...[ + if (!showMarketScore) const SizedBox(width: 8), + HubToolbarIconButton( + icon: Icons.mail_outline, + iconColor: AppColors.accent, + badgeCount: count >= 1 ? count : null, + tooltip: count == 1 + ? '1 question' + : count > 1 + ? '$count questions' + : marketOnly + ? 'Market questions' + : 'Spelling questions', onPressed: () => QuestionsHubService.instance.openQuestionPanel(), ), @@ -82,15 +122,22 @@ class HomeScreen extends StatelessWidget { actions: [ const AdminAppBarAction(), IconButton( - onPressed: () => UserProfileRepository.instance.sync(), + key: const Key('question-source-settings-button'), + tooltip: 'Question sources', + onPressed: () => QuestionSourceSettingsSheet.show(context), + icon: const Icon(Icons.settings), + ), + IconButton( tooltip: 'Sync profile', + onPressed: () => UserProfileRepository.instance.sync(), icon: Icon(Icons.sync, color: syncIconColor), ), IconButton( - onPressed: () => AuthService.instance.signOut(), tooltip: 'Sign out', + onPressed: () => AuthService.instance.signOut(), icon: const Icon(Icons.logout), ), + const SizedBox(width: 4), ], ), body: DecoratedBox( @@ -110,14 +157,31 @@ class HomeScreen extends StatelessWidget { QuestionsHubService.instance.pendingQuestion, QuestionsHubService.instance.questionActionBusy, QuestionsHubService.instance.pendingQuestionCount, + QuestionSourceConfigRepository.instance.preferences, ]), builder: (BuildContext context, Widget? child) { final QuestionsHubService hub = QuestionsHubService.instance; final bool panelOpen = hub.questionPanelOpen.value; - final bool hasPending = hub.hasPendingQuestion.value; final IncomingQuestion? question = hub.currentQuestion; - final bool showQuestionPanel = - panelOpen && hasPending && question != null; + final sources = QuestionSourceConfigRepository.instance.current; + final bool marketEnabled = + QuestionSourceConfigRepository.isMarketHistoryEnabled( + sources, + ); + final bool spellingEnabled = + QuestionSourceConfigRepository.isSpellingEnabled(sources); + final bool showSpellingPlaceholder = panelOpen && + spellingEnabled && + !marketEnabled && + (question == null || !question.isSpelling); + final bool showMarketPlaceholder = panelOpen && + marketEnabled && + !spellingEnabled && + (question == null || !question.isMarketHistory); + final bool showQuestionPanel = panelOpen && + question != null && + !showSpellingPlaceholder && + !showMarketPlaceholder; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -143,13 +207,14 @@ class HomeScreen extends StatelessWidget { ), const SizedBox(height: 4), Expanded( - child: SwipeQuestionTile( - key: ValueKey(question.id), - questionId: question.id, + child: QuestionPresentationRouter( + question: question, busy: hub.questionActionBusy.value, - onSwipeRight: (num answer) => + onAnswer: (num answer) => hub.submitCurrentAnswer(answer: answer), - onSwipeLeft: hub.deferCurrentQuestion, + onAnswerText: hub.submitCurrentSpellingAnswer, + onPrepared: hub.applyPreparedQuestion, + onDefer: hub.deferCurrentQuestion, ), ), ], @@ -157,7 +222,63 @@ class HomeScreen extends StatelessWidget { ), ), ), - if (!showQuestionPanel) + if (showSpellingPlaceholder) + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Spacer(), + IconButton( + onPressed: hub.closeQuestionPanel, + tooltip: 'Close', + icon: const Icon(Icons.close), + ), + ], + ), + Expanded( + child: SpellingQuestionPlaceholder( + busy: hub.questionActionBusy.value, + onRetry: hub.openQuestionPanel, + ), + ), + ], + ), + ), + ), + if (showMarketPlaceholder) + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Spacer(), + IconButton( + onPressed: hub.closeQuestionPanel, + tooltip: 'Close', + icon: const Icon(Icons.close), + ), + ], + ), + Expanded( + child: MarketQuestionPlaceholder( + busy: hub.questionActionBusy.value, + onRetry: hub.openQuestionPanel, + ), + ), + ], + ), + ), + ), + if (!showQuestionPanel && + !showSpellingPlaceholder && + !showMarketPlaceholder) Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(24), @@ -289,62 +410,6 @@ class HomeScreen extends StatelessWidget { } } -class _QuestionEnvelopeButton extends StatelessWidget { - const _QuestionEnvelopeButton({ - required this.count, - required this.onPressed, - }); - - final int count; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return Stack( - clipBehavior: Clip.none, - alignment: Alignment.center, - children: [ - IconButton( - onPressed: onPressed, - tooltip: count == 1 ? '1 question' : '$count questions', - icon: const Icon(Icons.mail_outline, size: 22), - style: IconButton.styleFrom( - backgroundColor: AppColors.accent.withValues(alpha: 0.15), - foregroundColor: AppColors.accent, - padding: const EdgeInsets.all(8), - minimumSize: const Size(36, 36), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - if (count >= 1) - Positioned( - top: 4, - right: 0, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - decoration: BoxDecoration( - color: AppColors.accent, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.surface, width: 1.5), - ), - constraints: const BoxConstraints(minWidth: 18, minHeight: 18), - child: Text( - '$count', - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.background, - fontSize: 11, - fontWeight: FontWeight.w700, - height: 1.1, - ), - ), - ), - ), - ], - ); - } -} - void _showGuessScoreStatsDialog(BuildContext context, GuessScoreSummary summary) { final String totalText = _formatScoreNumber(summary.total); final String percentText = _formatScoreNumber(summary.percentCorrect); diff --git a/lib/screens/spelling_list_screen.dart b/lib/screens/spelling_list_screen.dart new file mode 100644 index 0000000..422f740 --- /dev/null +++ b/lib/screens/spelling_list_screen.dart @@ -0,0 +1,335 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; + +import '../data/remote/spelling_words_remote_store.dart'; +import '../theme/app_theme.dart'; + +/// Lists and manages canonical spelling words in the shared catalog. +class SpellingListScreen extends StatefulWidget { + const SpellingListScreen({super.key}); + + @override + State createState() => _SpellingListScreenState(); +} + +class _SpellingListScreenState extends State { + final SpellingWordsRemoteStore _api = SpellingWordsRemoteStore(); + + List _words = []; + bool _loading = true; + String? _error; + bool _busy = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final List words = await _api.fetchWords(); + if (!mounted) { + return; + } + setState(() { + _words = words; + _loading = false; + }); + } catch (_) { + if (!mounted) { + return; + } + setState(() { + _loading = false; + _error = 'Could not load spelling words.'; + }); + } + } + + Future _runBusy(Future Function() action) async { + if (_busy) { + return; + } + setState(() => _busy = true); + try { + await action(); + } finally { + if (mounted) { + setState(() => _busy = false); + } + } + } + + Future _onNew() async { + final String? word = await showDialog( + context: context, + builder: (BuildContext context) => const _NewWordDialog(), + ); + if (word == null || word.trim().isEmpty) { + return; + } + + await _runBusy(() async { + try { + final List words = await _api.addWord(word); + if (!mounted) { + return; + } + setState(() => _words = words); + _showSnack('Added "${word.trim().toLowerCase()}"'); + } on SpellingWordsApiException catch (e) { + if (e.statusCode == 409) { + _showSnack('That word is already in the list.'); + } else { + _showSnack('Could not add word.'); + } + } catch (_) { + _showSnack('Could not add word.'); + } + }); + } + + Future _onUpload() async { + final FilePickerResult? result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['txt'], + withData: true, + ); + if (result == null || result.files.isEmpty) { + return; + } + + final PlatformFile file = result.files.first; + final String? text = _readFileText(file); + if (text == null || text.trim().isEmpty) { + _showSnack('The file is empty or could not be read.'); + return; + } + + await _runBusy(() async { + try { + final ({int added, int skipped}) stats = await _api.importText(text); + await _load(); + if (!mounted) { + return; + } + _showSnack('Upload complete: ${stats.added} added, ${stats.skipped} skipped'); + } catch (_) { + _showSnack('Upload failed.'); + } + }); + } + + Future _onDownload() async { + await _runBusy(() async { + try { + final String text = await _api.exportText(); + final Uint8List bytes = Uint8List.fromList(utf8.encode(text)); + final String? path = await FilePicker.platform.saveFile( + dialogTitle: 'Save spelling words', + fileName: 'spelling-words.txt', + type: FileType.custom, + allowedExtensions: ['txt'], + bytes: bytes, + ); + if (!mounted) { + return; + } + if (path == null) { + return; + } + _showSnack('Saved spelling word list'); + } catch (_) { + _showSnack('Download failed.'); + } + }); + } + + String? _readFileText(PlatformFile file) { + final List? bytes = file.bytes; + if (bytes != null) { + return utf8.decode(bytes); + } + return null; + } + + void _showSnack(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Spelling words'), + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.icon( + key: const Key('spelling-words-new'), + onPressed: _busy ? null : _onNew, + icon: const Icon(Icons.add, size: 18), + label: const Text('New'), + ), + OutlinedButton.icon( + key: const Key('spelling-words-upload'), + onPressed: _busy ? null : _onUpload, + icon: const Icon(Icons.upload_file, size: 18), + label: const Text('Upload'), + ), + OutlinedButton.icon( + key: const Key('spelling-words-download'), + onPressed: _busy ? null : _onDownload, + icon: const Icon(Icons.download, size: 18), + label: const Text('Download'), + ), + if (_busy) + const Padding( + padding: EdgeInsets.all(8), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'One word per line in upload/download files. Download includes canonical words only (no quiz history).', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.textSecondary, + ), + ), + ), + const SizedBox(height: 8), + Expanded(child: _buildBody()), + ], + ), + ); + } + + Widget _buildBody() { + if (_loading) { + return const Center( + child: CircularProgressIndicator(color: AppColors.accent), + ); + } + if (_error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_error!, style: const TextStyle(color: Colors.redAccent)), + const SizedBox(height: 12), + FilledButton(onPressed: _load, child: const Text('Retry')), + ], + ), + ); + } + if (_words.isEmpty) { + return const Center( + child: Text( + 'No spelling words yet. Add one or upload a text file.', + style: TextStyle(color: AppColors.textSecondary), + ), + ); + } + + return RefreshIndicator( + onRefresh: _load, + color: AppColors.accent, + child: ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + itemCount: _words.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (BuildContext context, int index) { + final String word = _words[index]; + return ListTile( + key: Key('spelling-word-$word'), + title: Text( + word, + style: const TextStyle( + color: AppColors.textPrimary, + fontWeight: FontWeight.w500, + ), + ), + ); + }, + ), + ); + } +} + +class _NewWordDialog extends StatefulWidget { + const _NewWordDialog(); + + @override + State<_NewWordDialog> createState() => _NewWordDialogState(); +} + +class _NewWordDialogState extends State<_NewWordDialog> { + final TextEditingController _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('New spelling word'), + content: TextField( + key: const Key('spelling-new-word-field'), + controller: _controller, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Enter a word', + border: OutlineInputBorder(), + ), + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submit(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + key: const Key('spelling-new-word-add'), + onPressed: _submit, + child: const Text('Add'), + ), + ], + ); + } + + void _submit() { + final String word = _controller.text.trim(); + if (word.isEmpty) { + return; + } + Navigator.of(context).pop(word); + } +} diff --git a/lib/services/questions_api_service.dart b/lib/services/questions_api_service.dart index 2b55c0b..5eb8b42 100644 --- a/lib/services/questions_api_service.dart +++ b/lib/services/questions_api_service.dart @@ -86,17 +86,25 @@ class QuestionsApiService { Future submitAnswer({ required String questionId, - num answer = 0, + num? answer, + String? answerText, }) async { final String? token = await AuthService.instance.getIdToken(); if (token == null) { return null; } + final Map requestBody = {}; + if (answerText != null) { + requestBody['answerText'] = answerText; + } else { + requestBody['answer'] = answer ?? 0; + } + final http.Response response = await _client.post( Uri.parse('$apiBaseUrl/v1/me/questions/$questionId/answer'), headers: _authHeaders(token), - body: jsonEncode({'answer': answer}), + body: jsonEncode(requestBody), ); if (response.statusCode != 200) { debugPrint( @@ -122,6 +130,36 @@ class QuestionsApiService { ); } + /// Runs weighted word selection and creates spelling progress links at tile load. + Future prepareSpellingQuestion({ + required String questionId, + }) async { + final String? token = await AuthService.instance.getIdToken(); + if (token == null) { + return null; + } + + final http.Response response = await _client.post( + Uri.parse('$apiBaseUrl/v1/me/questions/$questionId/spelling/prepare'), + headers: _authHeaders(token), + ); + if (response.statusCode != 200) { + debugPrint( + 'prepareSpellingQuestion failed: ${response.statusCode} ${response.body}', + ); + return null; + } + + final Map body = + jsonDecode(response.body) as Map; + final Map? questionJson = + body['question'] as Map?; + if (questionJson == null) { + return null; + } + return IncomingQuestion.fromJson(questionJson); + } + Future deferQuestion({required String questionId}) async { final String? token = await AuthService.instance.getIdToken(); if (token == null) { diff --git a/lib/services/questions_hub_service.dart b/lib/services/questions_hub_service.dart index d8f2bc0..995095b 100644 --- a/lib/services/questions_hub_service.dart +++ b/lib/services/questions_hub_service.dart @@ -8,7 +8,9 @@ import '../models/guess_score_reset_result.dart'; import '../models/guess_score_summary.dart'; import '../models/incoming_question.dart'; import '../models/question_defer_result.dart'; +import '../models/question_source_preference.dart'; import '../models/question_submit_result.dart'; +import '../repositories/question_source_config_repository.dart'; import 'auth_service.dart'; import 'questions_api_service.dart'; @@ -127,6 +129,9 @@ class QuestionsHubService { } void _applyIncoming(IncomingQuestion question) { + if (!_acceptsQuestionSource(question)) { + return; + } final int count = question.unansweredCount < 1 ? 1 : question.unansweredCount; pendingQuestion.value = question; @@ -148,27 +153,83 @@ class QuestionsHubService { Future openQuestionPanel() async { questionActionBusy.value = true; try { + // Align client filters with server-side source preferences before fetch. + await QuestionSourceConfigRepository.instance.loadFromRemote(); final List fetched = await _api.fetchUnanswered(); - if (fetched.isNotEmpty) { - questionQueue.value = fetched; - pendingQuestion.value = fetched.first; - pendingQuestionCount.value = fetched.length; + // GET /questions already returns only server-enabled sources. + final List active = fetched; + + questionPanelOpen.value = true; + + if (active.isNotEmpty) { + questionQueue.value = active; + pendingQuestion.value = active.first; + pendingQuestionCount.value = active.length; hasPendingQuestion.value = true; - } else if (pendingQuestion.value != null) { + } else if (pendingQuestion.value != null && + _acceptsQuestionSource(pendingQuestion.value!)) { questionQueue.value = [pendingQuestion.value!]; + pendingQuestionCount.value = 1; + hasPendingQuestion.value = true; } else { + pendingQuestion.value = null; questionQueue.value = []; + hasPendingQuestion.value = _shouldKeepPanelOpenWithoutQuestion(); } - questionPanelOpen.value = hasPendingQuestion.value; } finally { questionActionBusy.value = false; } } + bool _shouldKeepPanelOpenWithoutQuestion() { + final List sources = + QuestionSourceConfigRepository.instance.current; + return QuestionSourceConfigRepository.isSpellingOnlyEnabled(sources) || + QuestionSourceConfigRepository.isMarketOnlyEnabled(sources); + } + + bool _acceptsQuestionSource(IncomingQuestion question) { + final List sources = + QuestionSourceConfigRepository.instance.current; + if (QuestionSourceConfigRepository.isSpellingOnlyEnabled(sources)) { + return question.isSpelling; + } + if (QuestionSourceConfigRepository.isMarketOnlyEnabled(sources)) { + return question.isMarketHistory; + } + if (!QuestionSourceConfigRepository.isMarketHistoryEnabled(sources)) { + return question.sourceId != 'market_history'; + } + if (!QuestionSourceConfigRepository.isSpellingEnabled(sources)) { + return !question.isSpelling; + } + return true; + } + + List _filterForEnabledSources( + List questions, + ) { + return questions.where(_acceptsQuestionSource).toList(growable: false); + } + void closeQuestionPanel() { questionPanelOpen.value = false; } + /// Replaces a queue row after tile-load spelling prepare succeeds. + void applyPreparedQuestion(IncomingQuestion prepared) { + final List queue = + List.from(questionQueue.value); + final int index = queue.indexWhere((IncomingQuestion q) => q.id == prepared.id); + if (index >= 0) { + queue[index] = prepared; + questionQueue.value = queue; + } + if (pendingQuestion.value?.id == prepared.id) { + pendingQuestion.value = prepared; + } + } + /// Swipe left: move current question to end of queue without answering. Future deferCurrentQuestion() async { final IncomingQuestion? question = currentQuestion; @@ -193,11 +254,17 @@ class QuestionsHubService { final List refreshed = await _api.fetchUnanswered(); if (refreshed.isEmpty) { - if (result.nextQuestion != null) { + if (result.nextQuestion != null && + _acceptsQuestionSource(result.nextQuestion!)) { _setActiveQuestion( result.nextQuestion!, unansweredCount: result.unansweredCount, ); + } else if (_shouldKeepPanelOpenWithoutQuestion()) { + pendingQuestion.value = null; + questionQueue.value = []; + hasPendingQuestion.value = true; + questionPanelOpen.value = true; } else { _clearPendingUi(); } @@ -225,6 +292,27 @@ class QuestionsHubService { } } + /// Submit a typed spelling answer. + Future submitCurrentSpellingAnswer(String answerText) async { + final IncomingQuestion? question = currentQuestion; + if (question == null || + questionActionBusy.value || + !question.usesSpellingWordTile) { + return; + } + + final String trimmed = answerText.trim(); + if (trimmed.isEmpty) { + return; + } + + await _submitAnswer( + answeredQuestionId: question.id, + question: question, + answerText: trimmed, + ); + } + /// Swipe right: submit slider value as the answer. Future submitCurrentAnswer({num answer = 0}) async { final IncomingQuestion? question = currentQuestion; @@ -232,20 +320,36 @@ class QuestionsHubService { return; } - final String answeredQuestionId = question.id; + await _submitAnswer( + answeredQuestionId: question.id, + question: question, + answer: answer, + ); + } + + Future _submitAnswer({ + required String answeredQuestionId, + required IncomingQuestion question, + num? answer, + String? answerText, + }) async { questionActionBusy.value = true; try { final QuestionSubmitResult? result = await _api.submitAnswer( questionId: answeredQuestionId, answer: answer, + answerText: answerText, ); if (result == null) { return; } - guessScoreSummary.value = result.score; + if (question.isMarketHistory) { + guessScoreSummary.value = result.score; + } - if (result.nextQuestion != null) { + if (result.nextQuestion != null && + _acceptsQuestionSource(result.nextQuestion!)) { _setActiveQuestion( result.nextQuestion!, unansweredCount: result.unansweredCount, @@ -254,7 +358,14 @@ class QuestionsHubService { } if (result.unansweredCount == 0) { - _clearPendingUi(); + if (_shouldKeepPanelOpenWithoutQuestion()) { + pendingQuestion.value = null; + questionQueue.value = []; + hasPendingQuestion.value = true; + questionPanelOpen.value = true; + } else { + _clearPendingUi(); + } return; } @@ -263,7 +374,14 @@ class QuestionsHubService { .where((IncomingQuestion q) => q.id != answeredQuestionId) .toList(); if (remaining.isEmpty) { - _clearPendingUi(); + if (_shouldKeepPanelOpenWithoutQuestion()) { + pendingQuestion.value = null; + questionQueue.value = []; + hasPendingQuestion.value = true; + questionPanelOpen.value = true; + } else { + _clearPendingUi(); + } return; } diff --git a/lib/services/spelling_text_to_speech.dart b/lib/services/spelling_text_to_speech.dart new file mode 100644 index 0000000..b9e03b3 --- /dev/null +++ b/lib/services/spelling_text_to_speech.dart @@ -0,0 +1,40 @@ +import 'package:flutter/foundation.dart'; + +import 'spelling_tts/spelling_tts_backend.dart'; +import 'spelling_tts/spelling_tts_flutter.dart' + if (dart.library.js_interop) 'spelling_tts/spelling_tts_web.dart'; + +/// English system text-to-speech for spelling challenges. +/// +/// On web, uses the browser [speechSynthesis] API directly (avoids +/// [flutter_tts] method-channel registration issues). On IO platforms, uses +/// [flutter_tts]. +class SpellingTextToSpeech { + SpellingTextToSpeech({SpellingTtsBackend? backend}) + : _backend = backend ?? createSpellingTtsBackend(); + + final SpellingTtsBackend _backend; + + /// Speaks [text] using the platform TTS engine (clears any in-progress utterance). + /// + /// [title] references the question id for the utterance (logged for diagnostics). + Future speak(String text, {String? title}) async { + final String trimmed = text.trim(); + if (trimmed.isEmpty) { + return; + } + try { + await _backend.speak(trimmed, title: title); + } catch (e, st) { + debugPrint('Spelling TTS failed: $e\n$st'); + } + } + + Future stop() async { + try { + await _backend.stop(); + } catch (_) { + // Ignore shutdown errors (e.g. in widget tests without engine). + } + } +} diff --git a/lib/services/spelling_tts/spelling_tts_backend.dart b/lib/services/spelling_tts/spelling_tts_backend.dart new file mode 100644 index 0000000..d90c581 --- /dev/null +++ b/lib/services/spelling_tts/spelling_tts_backend.dart @@ -0,0 +1,5 @@ +/// Platform TTS backend for spelling words. +abstract class SpellingTtsBackend { + Future speak(String text, {String? title}); + Future stop(); +} diff --git a/lib/services/spelling_tts/spelling_tts_flutter.dart b/lib/services/spelling_tts/spelling_tts_flutter.dart new file mode 100644 index 0000000..adf8d11 --- /dev/null +++ b/lib/services/spelling_tts/spelling_tts_flutter.dart @@ -0,0 +1,43 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_tts/flutter_tts.dart'; + +import 'spelling_tts_backend.dart'; + +SpellingTtsBackend createSpellingTtsBackend() => _FlutterTtsBackend(); + +class _FlutterTtsBackend implements SpellingTtsBackend { + final FlutterTts _tts = FlutterTts(); + bool _configured = false; + + Future _ensureConfigured() async { + if (_configured) { + return; + } + await _tts.setLanguage('en-US'); + await _tts.setSpeechRate(0.42); + await _tts.setPitch(1.0); + await _tts.awaitSpeakCompletion(true); + _configured = true; + } + + @override + Future speak(String text, {String? title}) async { + final String trimmed = text.trim(); + if (trimmed.isEmpty) { + return; + } + await _ensureConfigured(); + await _tts.stop(); + if (title != null && title.isNotEmpty) { + debugPrint('Spelling TTS utterance title: $title'); + } + await _tts.speak(trimmed); + } + + @override + Future stop() async { + try { + await _tts.stop(); + } catch (_) {} + } +} diff --git a/lib/services/spelling_tts/spelling_tts_web.dart b/lib/services/spelling_tts/spelling_tts_web.dart new file mode 100644 index 0000000..ef2b258 --- /dev/null +++ b/lib/services/spelling_tts/spelling_tts_web.dart @@ -0,0 +1,102 @@ +import 'dart:async'; +import 'dart:js_interop'; + +import 'package:flutter/foundation.dart'; + +import 'spelling_tts_backend.dart'; + +SpellingTtsBackend createSpellingTtsBackend() => _WebSpeechBackend(); + +@JS('speechSynthesis') +external SpeechSynthesis get _speechSynthesis; + +@JS() +extension type SpeechSynthesis._(JSObject _) implements JSObject { + external void speak(SpeechSynthesisUtterance utterance); + external void cancel(); +} + +@JS() +extension type SpeechSynthesisUtterance._(JSObject _) implements JSObject { + external SpeechSynthesisUtterance(); + + external String text; + external String lang; + external double rate; + external double pitch; + + @JS('onend') + external set onEnd(JSFunction? listener); + + @JS('onerror') + external set onError(JSFunction? listener); +} + +class _WebSpeechBackend implements SpellingTtsBackend { + Completer? _pending; + + @override + Future speak(String text, {String? title}) async { + final String trimmed = text.trim(); + if (trimmed.isEmpty) { + return; + } + + if (title != null && title.isNotEmpty) { + debugPrint('Spelling TTS utterance title: $title'); + } + + await stop(); + + final Completer done = Completer(); + _pending = done; + + final SpeechSynthesisUtterance utterance = SpeechSynthesisUtterance(); + utterance.text = trimmed; + utterance.lang = 'en-US'; + utterance.rate = 0.85; + utterance.pitch = 1.0; + utterance.onEnd = (() { + if (!done.isCompleted) { + done.complete(); + } + }).toJS; + utterance.onError = ((JSAny event) { + debugPrint('Spelling web TTS error: $event'); + if (!done.isCompleted) { + done.complete(); + } + }).toJS; + + try { + _speechSynthesis.speak(utterance); + await done.future.timeout( + const Duration(seconds: 30), + onTimeout: () { + debugPrint('Spelling web TTS timed out'); + }, + ); + } catch (e, st) { + debugPrint('Spelling web TTS failed: $e\n$st'); + if (!done.isCompleted) { + done.complete(); + } + } finally { + if (identical(_pending, done)) { + _pending = null; + } + } + } + + @override + Future stop() async { + final Completer? pending = _pending; + _pending = null; + try { + _speechSynthesis.cancel(); + } catch (e, st) { + debugPrint('Spelling web TTS stop failed: $e\n$st'); + } + pending?.complete(); + } +} diff --git a/lib/utils/spelling_spoken_word.dart b/lib/utils/spelling_spoken_word.dart new file mode 100644 index 0000000..e181182 --- /dev/null +++ b/lib/utils/spelling_spoken_word.dart @@ -0,0 +1,24 @@ +/// Extracts the displayed spelling word from API question text. +/// +/// Matches prompts like: Is 'colour' spelled correctly? +String? spellingWordToSpeak(String questionText) { + final RegExp singleQuoted = RegExp( + r"Is\s+'([^']+)'\s+spelled", + caseSensitive: false, + ); + final Match? single = singleQuoted.firstMatch(questionText); + if (single != null) { + return single.group(1)?.trim(); + } + + final RegExp doubleQuoted = RegExp( + r'Is\s+"([^"]+)"\s+spelled', + caseSensitive: false, + ); + final Match? double = doubleQuoted.firstMatch(questionText); + if (double != null) { + return double.group(1)?.trim(); + } + + return null; +} diff --git a/lib/widgets/hub_toolbar_icon_button.dart b/lib/widgets/hub_toolbar_icon_button.dart new file mode 100644 index 0000000..135cae2 --- /dev/null +++ b/lib/widgets/hub_toolbar_icon_button.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + +/// Toolbar icon button shared by the signed-in home app bar and admin actions. +class HubToolbarIconButton extends StatelessWidget { + const HubToolbarIconButton({ + super.key, + required this.icon, + required this.onPressed, + required this.tooltip, + this.iconColor = AppColors.textPrimary, + this.badgeCount, + }); + + final IconData icon; + final VoidCallback? onPressed; + final String tooltip; + final Color iconColor; + final int? badgeCount; + + static const double slotWidth = 36; + + static ButtonStyle pillStyle({Color? foregroundColor}) { + final Color color = foregroundColor ?? AppColors.textPrimary; + return IconButton.styleFrom( + backgroundColor: Colors.transparent, + foregroundColor: color, + disabledForegroundColor: color.withValues(alpha: 0.38), + iconSize: 22, + padding: const EdgeInsets.all(8), + minimumSize: const Size(slotWidth, slotWidth), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + } + + @override + Widget build(BuildContext context) { + final Widget button = IconButton( + onPressed: onPressed, + tooltip: tooltip, + style: pillStyle(foregroundColor: iconColor), + icon: Icon(icon), + ); + + final int count = badgeCount ?? 0; + if (count < 1) { + return button; + } + + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + button, + Positioned( + top: 4, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: AppColors.accent, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.surface, width: 1.5), + ), + constraints: const BoxConstraints(minWidth: 18, minHeight: 18), + child: Text( + '$count', + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.background, + fontSize: 11, + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/market_question_placeholder.dart b/lib/widgets/market_question_placeholder.dart new file mode 100644 index 0000000..e2b1d51 --- /dev/null +++ b/lib/widgets/market_question_placeholder.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + +/// Shown when market is the only enabled source but no market question is ready. +class MarketQuestionPlaceholder extends StatelessWidget { + const MarketQuestionPlaceholder({ + super.key, + this.onRetry, + this.busy = false, + }); + + final VoidCallback? onRetry; + final bool busy; + + @override + Widget build(BuildContext context) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.show_chart, + size: 56, + color: AppColors.accent.withValues(alpha: 0.85), + ), + const SizedBox(height: 16), + Text( + 'No market question ready', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Spelling is turned off. A new market question will appear when ' + 'prospective data is available—try again in a moment.', + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + if (onRetry != null) ...[ + const SizedBox(height: 20), + FilledButton.icon( + key: const Key('market-placeholder-retry'), + onPressed: busy ? null : onRetry, + icon: busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh, size: 18), + label: const Text('Try again'), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/profile_session.dart b/lib/widgets/profile_session.dart index c3581e0..b4f2ef7 100644 --- a/lib/widgets/profile_session.dart +++ b/lib/widgets/profile_session.dart @@ -6,6 +6,7 @@ import '../models/app_user.dart'; import '../models/sync_result.dart'; import '../models/user_profile.dart'; import '../repositories/user_profile_repository.dart'; +import '../repositories/question_source_config_repository.dart'; import '../services/questions_hub_service.dart'; import '../theme/app_theme.dart'; @@ -37,7 +38,11 @@ class _ProfileSessionState extends State { void initState() { super.initState(); _sessionReady = UserProfileRepository.instance.startSession(widget.user) - ..then((_) => QuestionsHubService.instance.onLogin()); + ..then((_) async { + await QuestionSourceConfigRepository.instance + .startSession(widget.user.uid); + await QuestionsHubService.instance.onLogin(); + }); _syncStatusSubscription = UserProfileRepository.instance.syncStatusStream.listen(( ProfileSyncStatus status, @@ -53,6 +58,7 @@ class _ProfileSessionState extends State { _syncStatusSubscription?.cancel(); QuestionsHubService.instance.clearGuessScoreCache(); unawaited(QuestionsHubService.instance.disconnect()); + unawaited(QuestionSourceConfigRepository.instance.endSession()); UserProfileRepository.instance.endSession(); super.dispose(); } diff --git a/lib/widgets/question_presentation_router.dart b/lib/widgets/question_presentation_router.dart new file mode 100644 index 0000000..d3e3858 --- /dev/null +++ b/lib/widgets/question_presentation_router.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import '../models/incoming_question.dart'; +import 'spelling_question_tile.dart'; +import 'swipe_question_tile.dart'; + +/// Routes an [IncomingQuestion] to the correct presentation widget. +class QuestionPresentationRouter extends StatelessWidget { + const QuestionPresentationRouter({ + super.key, + required this.question, + required this.onAnswer, + required this.onDefer, + this.onAnswerText, + this.onPrepared, + this.busy = false, + }); + + final IncomingQuestion question; + final Future Function(num answer) onAnswer; + final Future Function(String answerText)? onAnswerText; + final ValueChanged? onPrepared; + final Future Function() onDefer; + final bool busy; + + @override + Widget build(BuildContext context) { + if (question.usesSpellingWordTile) { + return SpellingQuestionTile( + key: ValueKey(question.id), + questionId: question.id, + questionText: question.text, + spokenWord: question.spokenWord, + audioTitle: question.audioTitle, + needsPrepare: question.needsPrepare, + busy: busy, + onSubmitAnswer: onAnswerText ?? (_) async {}, + onPrepared: onPrepared, + onDefer: onDefer, + ); + } + + return SwipeQuestionTile( + key: ValueKey(question.id), + questionId: question.id, + busy: busy, + onSwipeRight: onAnswer, + onSwipeLeft: onDefer, + ); + } +} diff --git a/lib/widgets/question_source_settings_sheet.dart b/lib/widgets/question_source_settings_sheet.dart new file mode 100644 index 0000000..ee796a3 --- /dev/null +++ b/lib/widgets/question_source_settings_sheet.dart @@ -0,0 +1,337 @@ +import 'package:flutter/material.dart'; + +import '../models/question_source_preference.dart'; +import '../repositories/question_source_config_repository.dart'; +import '../routes/app_routes.dart'; +import '../screens/spelling_list_screen.dart'; +import '../theme/app_theme.dart'; + +/// Configure which question API sources are active and their relative mix. +class QuestionSourceSettingsSheet extends StatefulWidget { + const QuestionSourceSettingsSheet({ + super.key, + required this.initialPreferences, + }); + + final List initialPreferences; + + static Future show(BuildContext context) { + return showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + key: const Key('question-source-settings-dialog'), + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: QuestionSourceSettingsSheet( + initialPreferences: + QuestionSourceConfigRepository.instance.current, + ), + ), + ); + }, + ); + } + + @override + State createState() => + _QuestionSourceSettingsSheetState(); +} + +class _QuestionSourceSettingsSheetState extends State { + late List _draft; + String? _validationMessage; + bool _saving = false; + + @override + void initState() { + super.initState(); + _draft = QuestionSourceConfigRepository.mergeWithDefaults( + widget.initialPreferences, + ).map((QuestionSourcePreference source) => source.copyWith()).toList(); + } + + void _updateSource(int index, QuestionSourcePreference next) { + setState(() { + _draft[index] = next; + _validationMessage = null; + }); + } + + void _openSpellingWordList(BuildContext context) { + Navigator.of(context).pushNamed(AppRoutes.spellingWordList); + } + + Future _save() async { + final bool hasEnabled = + _draft.any((QuestionSourcePreference source) => source.enabled); + if (!hasEnabled) { + setState(() { + _validationMessage = 'Enable at least one question source.'; + }); + return; + } + + setState(() { + _saving = true; + _validationMessage = null; + }); + + try { + final List normalized = + QuestionSourceConfigRepository.mergeWithDefaults(_draft); + await QuestionSourceConfigRepository.instance.saveAll(normalized); + if (mounted) { + Navigator.of(context).pop(); + } + } catch (_) { + if (!mounted) { + return; + } + setState(() { + _saving = false; + _validationMessage = 'Could not save question sources.'; + }); + } + } + + @override + Widget build(BuildContext context) { + final Map mix = + QuestionSourceConfigRepository.effectiveMix(_draft); + final int enabledCount = + _draft.where((QuestionSourcePreference s) => s.enabled).length; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Icon(Icons.settings, color: AppColors.accent, size: 22), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'Question sources', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + IconButton( + key: const Key('question-source-settings-close'), + tooltip: 'Close', + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + Text( + 'Choose which pipelines may deliver questions and set their relative mix.', + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 12), + Flexible( + child: SingleChildScrollView( + child: Column( + children: [ + for (int index = 0; index < _draft.length; index++) + _QuestionSourceRow( + key: Key('question-source-row-${_draft[index].id}'), + source: _draft[index], + effectivePercent: mix[_draft[index].id], + onChanged: (QuestionSourcePreference next) => + _updateSource(index, next), + onEditSpelling: _draft[index].id == 'spelling' + ? () => _openSpellingWordList(context) + : null, + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Text( + enabledCount == 0 + ? 'No sources enabled.' + : 'Effective mix: ${mix.entries.map((MapEntry entry) { + final QuestionSourcePreference source = _draft.firstWhere( + (QuestionSourcePreference s) => s.id == entry.key, + ); + return '${source.label} ${entry.value}%'; + }).join(', ')}', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppColors.accent, + fontWeight: FontWeight.w600, + ), + ), + if (_validationMessage != null) ...[ + const SizedBox(height: 8), + Text( + _validationMessage!, + style: const TextStyle(color: Colors.redAccent), + ), + ], + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton( + key: const Key('question-source-settings-save'), + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), + ), + ], + ), + ], + ), + ); + } +} + +class _QuestionSourceRow extends StatelessWidget { + const _QuestionSourceRow({ + super.key, + required this.source, + required this.effectivePercent, + required this.onChanged, + this.onEditSpelling, + }); + + final QuestionSourcePreference source; + final int? effectivePercent; + final ValueChanged onChanged; + final VoidCallback? onEditSpelling; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: DecoratedBox( + decoration: BoxDecoration( + color: AppColors.surfaceElevated, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.accent.withValues(alpha: 0.15)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + source.label, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + if (source.comingSoon) ...[ + const SizedBox(width: 8), + DecoratedBox( + decoration: BoxDecoration( + color: AppColors.accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + child: Text( + 'Coming soon', + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith( + color: AppColors.accent, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + source.description, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + if (onEditSpelling != null) + IconButton( + key: const Key('question-source-edit-spelling'), + tooltip: 'Edit spelling word list', + onPressed: onEditSpelling, + icon: const Icon(Icons.edit, size: 20), + ), + Switch( + key: Key('question-source-enabled-${source.id}'), + value: source.enabled, + onChanged: source.comingSoon + ? null + : (bool enabled) => + onChanged(source.copyWith(enabled: enabled)), + ), + ], + ), + if (source.enabled && !source.comingSoon) ...[ + Row( + children: [ + const Text('Weight'), + const Spacer(), + Text( + effectivePercent == null + ? '${source.ratio}' + : '${source.ratio} (${effectivePercent}%)', + style: const TextStyle( + color: AppColors.accent, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + Slider( + key: Key('question-source-ratio-${source.id}'), + value: source.ratio.toDouble(), + min: 0, + max: 100, + divisions: 20, + label: '${source.ratio}', + onChanged: (double value) => + onChanged(source.copyWith(ratio: value.round())), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/spelling_question_placeholder.dart b/lib/widgets/spelling_question_placeholder.dart new file mode 100644 index 0000000..80e7690 --- /dev/null +++ b/lib/widgets/spelling_question_placeholder.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + +/// Shown when spelling is the only enabled source but no spelling question is ready. +class SpellingQuestionPlaceholder extends StatelessWidget { + const SpellingQuestionPlaceholder({ + super.key, + this.onRetry, + this.busy = false, + }); + + final VoidCallback? onRetry; + final bool busy; + + @override + Widget build(BuildContext context) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.spellcheck, + size: 56, + color: AppColors.accent.withValues(alpha: 0.85), + ), + const SizedBox(height: 16), + Text( + 'No spelling question ready', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Market questions are turned off. Add words in spelling settings ' + 'or try again to load a spelling quiz.', + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + if (onRetry != null) ...[ + const SizedBox(height: 20), + FilledButton.icon( + key: const Key('spelling-placeholder-retry'), + onPressed: busy ? null : onRetry, + icon: busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh, size: 18), + label: const Text('Try again'), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/spelling_question_tile.dart b/lib/widgets/spelling_question_tile.dart new file mode 100644 index 0000000..9c7f306 --- /dev/null +++ b/lib/widgets/spelling_question_tile.dart @@ -0,0 +1,230 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../models/incoming_question.dart'; +import '../services/questions_api_service.dart'; +import '../services/spelling_text_to_speech.dart'; +import '../theme/app_theme.dart'; +import '../utils/spelling_spoken_word.dart'; + +/// Spelling quiz: hear the word, type your spelling, and submit. +class SpellingQuestionTile extends StatefulWidget { + const SpellingQuestionTile({ + super.key, + required this.questionId, + required this.questionText, + required this.onSubmitAnswer, + required this.onDefer, + this.busy = false, + this.spokenWord, + this.audioTitle, + this.needsPrepare = false, + this.onPrepared, + this.prepareQuestion, + }); + + final String questionId; + final String questionText; + final Future Function(String answerText) onSubmitAnswer; + final Future Function() onDefer; + final bool busy; + + /// Word read aloud; filled by [prepareQuestion] when omitted. + final String? spokenWord; + + /// Question id used as the TTS utterance title. + final String? audioTitle; + + /// When true, calls the prepare API to select a word at tile load. + final bool needsPrepare; + + /// Called when prepare returns an updated question payload. + final ValueChanged? onPrepared; + + /// Visible for widget tests (defaults to API prepare). + final Future Function(String questionId)? prepareQuestion; + + @override + State createState() => _SpellingQuestionTileState(); +} + +class _SpellingQuestionTileState extends State { + final SpellingTextToSpeech _speech = SpellingTextToSpeech(); + final QuestionsApiService _api = QuestionsApiService(); + final TextEditingController _answerController = TextEditingController(); + + String? _spokenWord; + String? _audioTitle; + bool _preparing = false; + + @override + void initState() { + super.initState(); + _spokenWord = widget.spokenWord; + _audioTitle = widget.audioTitle ?? widget.questionId; + WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_prepareAndSpeak())); + } + + @override + void didUpdateWidget(SpellingQuestionTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.questionId != widget.questionId) { + _answerController.clear(); + _spokenWord = widget.spokenWord; + _audioTitle = widget.audioTitle ?? widget.questionId; + WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_prepareAndSpeak())); + } + } + + @override + void dispose() { + _answerController.dispose(); + unawaited(_speech.stop()); + super.dispose(); + } + + Future _prepareAndSpeak() async { + if (_preparing) { + return; + } + + if (!widget.needsPrepare) { + await _speakWord(); + return; + } + + _preparing = true; + setState(() {}); + try { + final IncomingQuestion? prepared = widget.prepareQuestion != null + ? await widget.prepareQuestion!(widget.questionId) + : await _api.prepareSpellingQuestion(questionId: widget.questionId); + if (!mounted) { + return; + } + if (prepared != null) { + setState(() { + _spokenWord = prepared.spokenWord ?? _spokenWord; + _audioTitle = prepared.audioTitle ?? prepared.id; + }); + widget.onPrepared?.call(prepared); + } + await _speakWord(); + } finally { + if (mounted) { + setState(() => _preparing = false); + } + } + } + + String? get _wordToSpeak => + _spokenWord ?? spellingWordToSpeak(widget.questionText); + + bool get _wordReady { + final String? word = _wordToSpeak; + return word != null && word.isNotEmpty; + } + + Future _speakWord() async { + final String? word = _wordToSpeak; + if (word == null || word.isEmpty) { + return; + } + await _speech.speak( + word, + title: _audioTitle ?? widget.questionId, + ); + } + + Future _submitAnswer() async { + final String answer = _answerController.text.trim(); + if (answer.isEmpty || widget.busy || _preparing || !_wordReady) { + return; + } + await widget.onSubmitAnswer(answer); + } + + @override + Widget build(BuildContext context) { + final bool canInteract = _wordReady && !widget.busy && !_preparing; + final bool canSpeak = _wordReady; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_preparing) + const Padding( + padding: EdgeInsets.only(bottom: 24), + child: Center( + child: SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + Center( + child: IconButton.filled( + key: const Key('spelling-speak-word'), + onPressed: canSpeak && !widget.busy + ? () => unawaited(_speakWord()) + : null, + tooltip: 'Hear the word', + iconSize: 36, + style: IconButton.styleFrom( + backgroundColor: AppColors.surfaceElevated, + foregroundColor: canSpeak ? AppColors.accent : AppColors.textSecondary, + disabledForegroundColor: AppColors.textSecondary, + minimumSize: const Size(72, 72), + ), + icon: const Icon(Icons.volume_up_rounded), + ), + ), + const SizedBox(height: 28), + TextField( + key: const Key('spelling-answer-field'), + controller: _answerController, + enabled: canInteract, + textCapitalization: TextCapitalization.none, + autocorrect: false, + enableSuggestions: false, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: AppColors.textPrimary, + ), + decoration: InputDecoration( + hintText: canInteract ? 'Type the spelling' : 'Loading word…', + hintStyle: const TextStyle(color: AppColors.textSecondary), + filled: true, + fillColor: AppColors.surfaceElevated, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + onSubmitted: canInteract ? (_) => unawaited(_submitAnswer()) : null, + ), + const SizedBox(height: 16), + FilledButton( + key: const Key('spelling-submit-answer'), + onPressed: canInteract ? () => unawaited(_submitAnswer()) : null, + child: const Text('Submit'), + ), + const SizedBox(height: 8), + TextButton( + key: const Key('spelling-skip-question'), + onPressed: widget.busy || _preparing ? null : widget.onDefer, + child: const Text('Skip for now'), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/swipe_question_tile.dart b/lib/widgets/swipe_question_tile.dart index d5a2afc..98ae7f2 100644 --- a/lib/widgets/swipe_question_tile.dart +++ b/lib/widgets/swipe_question_tile.dart @@ -35,6 +35,7 @@ class SwipeQuestionTile extends StatefulWidget { required this.onSwipeLeft, this.busy = false, this.swipeAxisToleranceDegrees = 25, + this.showGuidGlyph = true, }); final String questionId; @@ -45,6 +46,9 @@ class SwipeQuestionTile extends StatefulWidget { /// Max deviation from pure horizontal (0°/180°) or vertical (±90°) to lock axis. final double swipeAxisToleranceDegrees; + /// When false, hides the GUID glyph (used by spelling presentation). + final bool showGuidGlyph; + @override State createState() => _SwipeQuestionTileState(); } @@ -723,22 +727,23 @@ class _SwipeQuestionTileState extends State ), ), ), - Positioned( - top: 8, - bottom: 8, - left: _centerSideInset, - right: _centerSideInset, - child: Center( - child: Transform.translate( - offset: Offset(0, -_snappedVerticalOffset), - child: QuestionGuidGlyph( - guid: widget.questionId, - size: _glyphSize, - displayValue: _snappedSliderValue, + if (widget.showGuidGlyph) + Positioned( + top: 8, + bottom: 8, + left: _centerSideInset, + right: _centerSideInset, + child: Center( + child: Transform.translate( + offset: Offset(0, -_snappedVerticalOffset), + child: QuestionGuidGlyph( + guid: widget.questionId, + size: _glyphSize, + displayValue: _snappedSliderValue, + ), ), ), ), - ), Positioned( left: 0, top: 10, diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 0a7ffc0..fff0edd 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,8 +6,10 @@ import FlutterMacOS import Foundation import connectivity_plus +import file_picker import firebase_auth import firebase_core +import flutter_tts import google_sign_in_ios import shared_preferences_foundation import sqlite3_flutter_libs @@ -15,8 +17,10 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 3eb23d2..339d9d9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -185,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -273,6 +281,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" firebase_auth: dependency: "direct main" description: @@ -342,11 +358,27 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_tts: + dependency: "direct main" + description: + name: flutter_tts + sha256: ce5eb209b40e95f2f4a1397116c87ab2fcdff32257d04ed7a764e75894c03775 + url: "https://pub.dev" + source: hosted + version: "4.2.5" flutter_web_plugins: dependency: transitive description: flutter @@ -1109,6 +1141,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 04c8554..f27b41c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,8 @@ dependencies: connectivity_plus: ^6.1.4 signalr_netcore: ^1.4.4 logging: ^1.3.0 + file_picker: ^8.3.7 + flutter_tts: ^4.2.2 dev_dependencies: flutter_test: diff --git a/scripts/build-production-web.sh b/scripts/build-production-web.sh index c4eac00..25bd29c 100755 --- a/scripts/build-production-web.sh +++ b/scripts/build-production-web.sh @@ -63,9 +63,14 @@ if [ "${SKIP_PUB_GET:-0}" = "1" ]; then _flutter_pub_flag=(--no-pub) echo "SKIP_PUB_GET=1 — not running flutter pub get" fi -flutter build web --release "${_flutter_pub_flag[@]}" \ - --dart-define=API_BASE_URL="$API_BASE_URL" \ - --dart-define=AUTH_VERBOSE_LOG=true +# --no-tree-shake-icons: keep the full MaterialIcons font so its bytes are +# build-invariant. Tree-shaking re-subsets MaterialIcons-Regular.otf every build +# (different glyphs, same filename), and stale browser caches of that file make +# newly-referenced icons (e.g. settings, admin_panel_settings) render blank. +flutter build web --release "${_flutter_pub_flag[@]}" #\ + # --no-tree-shake-icons \ + # --dart-define=API_BASE_URL="$API_BASE_URL" \ + # --dart-define=AUTH_VERBOSE_LOG=true if [ -x "$ROOT/scripts/copy_flutter_js_source_map.sh" ]; then "$ROOT/scripts/copy_flutter_js_source_map.sh" "$ROOT/build/web" || true diff --git a/scripts/stamp-web-cache-bust.sh b/scripts/stamp-web-cache-bust.sh index 262bbf7..9a318e0 100755 --- a/scripts/stamp-web-cache-bust.sh +++ b/scripts/stamp-web-cache-bust.sh @@ -25,5 +25,20 @@ if [ -f "$BOOT" ]; then sed -i "s|\"mainJsPath\":\"main.dart.js\"|\"mainJsPath\":\"main.dart.js?v=${STAMP}\"|g" "$BOOT" fi +# Icon font: in production the .otf is cached for a day, and historically the +# tree-shaken MaterialIcons subset changed between builds under the SAME filename +# — so stale caches made newly-used glyphs (settings, admin_panel_settings) +# render blank. Rename the font per build and rewrite FontManifest.json so a new +# URL is always fetched. Pairs with --no-tree-shake-icons (full, stable font). +FONT_DIR="$WEB/assets/fonts" +FONT_MANIFEST="$WEB/assets/FontManifest.json" +SRC_FONT="$FONT_DIR/MaterialIcons-Regular.otf" +if [ -f "$SRC_FONT" ] && [ -f "$FONT_MANIFEST" ]; then + NEW_FONT="MaterialIcons-Regular.${STAMP}.otf" + cp -f "$SRC_FONT" "$FONT_DIR/$NEW_FONT" + sed -i "s|fonts/MaterialIcons-Regular[^\"]*\.otf|fonts/${NEW_FONT}|g" "$FONT_MANIFEST" + echo "Stamped MaterialIcons font URL: fonts/$NEW_FONT" +fi + printf '%s\n' "$STAMP" >"$WEB/BUILD_STAMP.txt" echo "Stamped: $INDEX and flutter_bootstrap.js (if present)" diff --git a/server/README.md b/server/README.md index d34cd29..f54d771 100644 --- a/server/README.md +++ b/server/README.md @@ -43,33 +43,34 @@ dart pub get dart test ``` -Integration tests apply migrations `001`–`009` on `cyberhybridhub_test` and truncate -trading tables between cases. Optional override: `TEST_DATABASE_URL`. +Integration tests apply migrations `001`–`009` on `cyberhybridhub_test` and +truncate trading tables between cases. Optional override: `TEST_DATABASE_URL`. ## Endpoints -| Method | Path | Auth | -|--------|------|------| -| `GET` | `/v1/me/profile` | `Authorization: Bearer ` | -| `PUT` | `/v1/me/profile` | same | -| `POST` | `/v1/me/incoming-question` | same — pushes a question to the client via SignalR | -| `POST` | `/v1/me/questions/bootstrap` | create one random prospective question on login (fallback to oldest unanswered) | -| `GET` | `/v1/me/questions` | list unanswered questions (queue order) | -| `GET` | `/v1/me/questions/score` | current cumulative prospective-question score | -| `POST` | `/v1/me/questions/score/reset` | reset guess score and answer statistics to zero | -| `POST` | `/v1/me/questions/{id}/answer` | submit answer (`{"answer": 0}` default) | -| `POST` | `/v1/me/questions/{id}/defer` | move question to end of queue | +| Method | Path | Auth | +| ------ | ------------------------------ | ------------------------------------------------------------------------------- | +| `GET` | `/v1/me/profile` | `Authorization: Bearer ` | +| `PUT` | `/v1/me/profile` | same | +| `POST` | `/v1/me/incoming-question` | same — pushes a question to the client via SignalR | +| `POST` | `/v1/me/questions/bootstrap` | create one random prospective question on login (fallback to oldest unanswered) | +| `GET` | `/v1/me/questions` | list unanswered questions (queue order) | +| `GET` | `/v1/me/questions/score` | current cumulative prospective-question score | +| `POST` | `/v1/me/questions/score/reset` | reset guess score and answer statistics to zero | +| `POST` | `/v1/me/questions/{id}/answer` | submit answer (`{"answer": 0}` default) | +| `POST` | `/v1/me/questions/{id}/defer` | move question to end of queue | ## SignalR — incoming questions Hub URL: `http://localhost:3000/hubs/questions` -The Flutter app calls `POST /v1/me/questions/bootstrap` once at login. The API ensures a -`users` row exists, picks a random row from `market_history_prospective_questions`, and -creates a user question linked by `metadata.prospective_question_id` (falls back to -oldest unanswered when no prospective rows exist). After sign-in it connects to SignalR -and listens for `ReceiveQuestion`. On each new WebSocket connection the API only delivers -an existing unanswered question — it does not create new rows. +The Flutter app calls `POST /v1/me/questions/bootstrap` once at login. The API +ensures a `users` row exists, picks a random row from +`market_history_prospective_questions`, and creates a user question linked by +`metadata.prospective_question_id` (falls back to oldest unanswered when no +prospective rows exist). After sign-in it connects to SignalR and listens for +`ReceiveQuestion`. On each new WebSocket connection the API only delivers an +existing unanswered question — it does not create new rows. Client payload (correct answer is not sent): @@ -83,25 +84,28 @@ Client payload (correct answer is not sent): } ``` -`unansweredCount` is the number of unanswered rows for that user (shown in the app when greater than 1). +`unansweredCount` is the number of unanswered rows for that user (shown in the +app when greater than 1). -`questions` table: `id` (UUID), `assigned_user_id`, `question_text`, `user_response` -(nullable), `correct_answer`, `created_at`, `modified_at`. +`questions` table: `id` (UUID), `assigned_user_id`, `question_text`, +`user_response` (nullable), `correct_answer`, `created_at`, `modified_at`. -`market_history_prospective_answers` table snapshots answered prospective-question context: -`question_id`, `prospective_question_id`, `symbol`, `older_slot_start`, -`newer_slot_start`, `expected_percent_increase_price`, and `user_slider_value`. +`market_history_prospective_answers` table snapshots answered +prospective-question context: `question_id`, `prospective_question_id`, +`symbol`, `older_slot_start`, `newer_slot_start`, +`expected_percent_increase_price`, and `user_slider_value`. Guess score (`user_trading_state.context.guess_score`, keyed by Firebase UID): - Persists across devices and logins for the same `firebase_uid`. - `GET /v1/me/questions/score` and login bootstrap `score` repair counters/total - from `market_history_prospective_answers` when stored JSON is missing or stale. + from `market_history_prospective_answers` when stored JSON is missing or + stale. Prospective guess progression (`user_trading_state.context.guess_score`): -- `slot_start` — older session-half edge for the active pair; advances when every - top-50% volume symbol in `(slot_start → next slot)` has been answered. +- `slot_start` — older session-half edge for the active pair; advances when + every top-50% volume symbol in `(slot_start → next slot)` has been answered. - Reset sets `slot_start` to the earliest slot in the rolling history window and clears that user's `market_history_prospective_answers` and `market_history_prospective_assignments` rows. @@ -109,14 +113,15 @@ Prospective guess progression (`user_trading_state.context.guess_score`): one row per `(user, older_slot_start, newer_slot_start)` written when the question is created (`pending` until answered). Survives logins; unique constraint blocks a second asset for the same slot pair before answer. -- Question pick uses bar audit data for that slot pair (not a global random row). +- Question pick uses bar audit data for that slot pair (not a global random + row). Prospective answer scoring (`user_trading_state.context.guess_score`): -| `PROSPECTIVE_ANSWER_CLOSENESS_ENABLED` | Correct sign | Wrong sign | -|----------------------------------------|--------------|------------| -| `false` (default) | `+1` | `-1` | -| `true` | `+1` plus up to `+1` closeness (full bonus within ±1 of expected %, else fractional) | `-2` | +| `PROSPECTIVE_ANSWER_CLOSENESS_ENABLED` | Correct sign | Wrong sign | +| -------------------------------------- | ------------------------------------------------------------------------------------ | ---------- | +| `false` (default) | `+1` | `-1` | +| `true` | `+1` plus up to `+1` closeness (full bonus within ±1 of expected %, else fractional) | `-2` | ## Background question pipeline @@ -124,11 +129,11 @@ A background worker runs inside the API process (enabled by default). On each interval it walks registered users, fetches data from public web APIs, and enqueues pipeline questions when the user's queue is not full. -| Env var | Default | Purpose | -|---------|---------|---------| -| `QUESTION_WORKER_ENABLED` | `true` | Set to `false` to disable the worker | -| `QUESTION_WORKER_INTERVAL_SECONDS` | `60` | Seconds between maintenance cycles | -| `QUESTION_PIPELINE_TEST_MODE` | `false` | Use random -10..10 starter-style questions instead of API copy | +| Env var | Default | Purpose | +| -------------------------------------- | ------- | ------------------------------------------------------------------------------------------- | +| `QUESTION_WORKER_ENABLED` | `true` | Set to `false` to disable the worker | +| `QUESTION_WORKER_INTERVAL_SECONDS` | `60` | Seconds between maintenance cycles | +| `QUESTION_PIPELINE_TEST_MODE` | `false` | Use random -10..10 starter-style questions instead of API copy | | `PROSPECTIVE_ANSWER_CLOSENESS_ENABLED` | `false` | Enable closeness bonus scoring (`+1`/`-2` with magnitude bonus) instead of simple `+1`/`-1` | **External APIs used** @@ -139,8 +144,8 @@ enqueues pipeline questions when the user's queue is not full. **Branching flow** 1. **Track choice** — user swipes toward +10 (weather) or -10 (geography). -2. **Geography** — yes/no population threshold, then capital confirmation; - wrong population guess triggers a recovery question. +2. **Geography** — yes/no population threshold, then capital confirmation; wrong + population guess triggers a recovery question. 3. **Weather** — yes/no warm/cool for a random European city, then a follow-up to continue weather or switch to geography. @@ -163,66 +168,80 @@ curl -s -X POST http://localhost:3000/v1/me/incoming-question \ ## Market history -Alpaca **`1Min`** bars aggregated into two **US regular-session** half-days per trading day -(morning **9:30–12:45 ET**, afternoon **12:45–16:00 ET**, ~195 minutes each). -Stored as `metric=bar`, `timeframe=sessionHalf`. Rolling window: `MARKET_HISTORY_WINDOW_DAYS` (default **5 US trading days**, two session halves each; weekends/holidays skipped). +Alpaca **`1Min`** bars aggregated into two **US regular-session** half-days per +trading day (morning **9:30–12:45 ET**, afternoon **12:45–16:00 ET**, ~195 +minutes each). Stored as `metric=bar`, `timeframe=sessionHalf`. Rolling window: +`MARKET_HISTORY_WINDOW_DAYS` (default **5 US trading days**, two session halves +each; weekends/holidays skipped). -**Backfill** (`kind=backfill`): fetches each **ended** slot still missing in DB; skips the open slot. -Throttled to `MARKET_HISTORY_API_REQUESTS_PER_MINUTE` (default 200/min). On HTTP 429: wait 1 minute, retry once; if still limited, save partial rows and resume next tick. -Runs when `hasPendingSlots` is true (worker tick). One bars request per slot × symbol batch. -Before each worker or admin pipeline, orphaned `market_data_sync_runs` rows with -`finished_at IS NULL` are closed (stale rows first, then any remaining orphans) so a -crashed prior sync cannot block new work. -**Universe** / **cleanup** keep hour-based cadence. `guess_weekly_move` reads these bars. +**Backfill** (`kind=backfill`): fetches each **ended** slot still missing in DB; +skips the open slot. Throttled to `MARKET_HISTORY_API_REQUESTS_PER_MINUTE` +(default 200/min). On HTTP 429: wait 1 minute, retry once; if still limited, +save partial rows and resume next tick. Runs when `hasPendingSlots` is true +(worker tick). One bars request per slot × symbol batch. Before each worker or +admin pipeline, orphaned `market_data_sync_runs` rows with `finished_at IS NULL` +are closed (stale rows first, then any remaining orphans) so a crashed prior +sync cannot block new work. **Universe** / **cleanup** keep hour-based cadence. +`guess_weekly_move` reads these bars. -**Worker pipeline** (each tick when sync enabled): `universe` → `backfill` → `cleanup` → -prospective-question refresh (silent; not logged to `market_data_sync_runs`). +**Worker pipeline** (each tick when sync enabled): `universe` → `backfill` → +`cleanup` → prospective-question refresh (silent; not logged to +`market_data_sync_runs`). -**Prospective questions** (`market_history_prospective_questions`, migration `012`): -- **Identity:** `UNIQUE (symbol, older_slot_start, newer_slot_start)` — one row per - asset per canonical slot pair (slot starts snapped to session-half boundaries). -- **Refresh:** top 50% by mean dollar volume for the latest completed pair; `INSERT … - ON CONFLICT DO UPDATE`; prune symbols that dropped out of the top half for that pair; - `pg_advisory_xact_lock` so overlapping ticks cannot duplicate rows. -- **Answer:** `correct_answer` = percent change in average OHLC price (older → newer). -- **Cleanup:** `cleanup` deletes rows with `older_slot_start` before the retention - cutoff (`MARKET_HISTORY_RETENTION_DAYS`, same as `market_data_snapshots`). Refresh - also purges expired rows before upserting. +**Prospective questions** (`market_history_prospective_questions`, migration +`012`): + +- **Identity:** `UNIQUE (symbol, older_slot_start, newer_slot_start)` — one row + per asset per canonical slot pair (slot starts snapped to session-half + boundaries). +- **Refresh:** top 50% by mean dollar volume for the latest completed pair; + `INSERT … + ON CONFLICT DO UPDATE`; prune symbols that dropped out of the top + half for that pair; `pg_advisory_xact_lock` so overlapping ticks cannot + duplicate rows. +- **Answer:** `correct_answer` = percent change in average OHLC price (older → + newer). +- **Cleanup:** `cleanup` deletes rows with `older_slot_start` before the + retention cutoff (`MARKET_HISTORY_RETENTION_DAYS`, same as + `market_data_snapshots`). Refresh also purges expired rows before upserting. Requires `TRADING_ENABLED=true` when `MARKET_HISTORY_SYNC_ENABLED=true`. -**Migration `008`:** legacy `1Day` / `4Hour` history cleanup and `slots_synced` on sync runs. +**Migration `008`:** legacy `1Day` / `4Hour` history cleanup and `slots_synced` +on sync runs. -**Migration `010`:** deletes `4Hour` bars, adds `sessionHalf` timeframe + partial index. +**Migration `010`:** deletes `4Hour` bars, adds `sessionHalf` timeframe + +partial index. -**Migration `009`:** adds `market_data_sync_runs.backfill_items` JSONB — per-slot UTC start + symbol list for each backfill run. +**Migration `009`:** adds `market_data_sync_runs.backfill_items` JSONB — +per-slot UTC start + symbol list for each backfill run. -| Env var | Default | Purpose | -|---------|---------|---------| -| `MARKET_HISTORY_SYNC_ENABLED` | `false` | Worker: universe → backfill → cleanup | -| `MARKET_HISTORY_WINDOW_DAYS` | `5` | Oldest slot start to retain / backfill | -| `MARKET_HISTORY_RETENTION_DAYS` | `5` | Delete `as_of` older than this | -| `MARKET_HISTORY_ARCHIVE_ENABLED` | `false` | Archive before delete | -| `MARKET_UNIVERSE_REFRESH_HOURS` | `24` | Min hours between universe syncs | -| `MARKET_HISTORY_SYNC_HOURS` | `24` | Unused for backfill (slot-gated); fallback if no slot gate | -| `MARKET_HISTORY_CLEANUP_HOURS` | `24` | Min hours between cleanups | -| `MARKET_HISTORY_SYNC_HOUR_UTC` | *(unset)* | UTC hour floor; same-day cap applies to universe/cleanup only | -| `HISTORY_SYNC_BATCH_SIZE` | `50` | Symbols per bars request | -| `HISTORY_SYNC_MAX_SYMBOLS` | `2000` | Max symbols per backfill run | -| `MARKET_HISTORY_API_REQUESTS_PER_MINUTE` | `200` | Max Alpaca HTTP calls per rolling minute during backfill | -| `MARKET_HISTORY_SYNC_STALE_MINUTES` | `30` | Abort in-progress sync rows older than this before a new pipeline | -| `MIN_BARS_FOR_GUESS` | `5` | Min 4-hour bars for guess eligibility | -| `GUESS_COOLDOWN_HOURS` | `24` | Per-symbol guess cooldown | +| Env var | Default | Purpose | +| ---------------------------------------- | --------- | ----------------------------------------------------------------- | +| `MARKET_HISTORY_SYNC_ENABLED` | `false` | Worker: universe → backfill → cleanup | +| `MARKET_HISTORY_WINDOW_DAYS` | `5` | Oldest slot start to retain / backfill | +| `MARKET_HISTORY_RETENTION_DAYS` | `5` | Delete `as_of` older than this | +| `MARKET_HISTORY_ARCHIVE_ENABLED` | `false` | Archive before delete | +| `MARKET_UNIVERSE_REFRESH_HOURS` | `24` | Min hours between universe syncs | +| `MARKET_HISTORY_SYNC_HOURS` | `24` | Unused for backfill (slot-gated); fallback if no slot gate | +| `MARKET_HISTORY_CLEANUP_HOURS` | `24` | Min hours between cleanups | +| `MARKET_HISTORY_SYNC_HOUR_UTC` | _(unset)_ | UTC hour floor; same-day cap applies to universe/cleanup only | +| `HISTORY_SYNC_BATCH_SIZE` | `50` | Symbols per bars request | +| `HISTORY_SYNC_MAX_SYMBOLS` | `2000` | Max symbols per backfill run | +| `MARKET_HISTORY_API_REQUESTS_PER_MINUTE` | `200` | Max Alpaca HTTP calls per rolling minute during backfill | +| `MARKET_HISTORY_SYNC_STALE_MINUTES` | `30` | Abort in-progress sync rows older than this before a new pipeline | +| `MIN_BARS_FOR_GUESS` | `5` | Min 4-hour bars for guess eligibility | +| `GUESS_COOLDOWN_HOURS` | `24` | Per-symbol guess cooldown | ## Admin portal (market history log) Read-only audit log and on-demand triggers for universe sync, backfill, and retention cleanup. Mounted when `ADMIN_PORTAL_ENABLED=true`. -| Env var | Default | Purpose | -|---------|---------|---------| -| `ADMIN_PORTAL_ENABLED` | `false` | Mount `/v1/admin/market-history/*` and `/v1/admin/market-data/*` | -| `ADMIN_FIREBASE_UIDS` | *(empty)* | Comma-separated Firebase UIDs allowed to call admin routes | +| Env var | Default | Purpose | +| ---------------------- | --------- | ---------------------------------------------------------------- | +| `ADMIN_PORTAL_ENABLED` | `false` | Mount `/v1/admin/market-history/*` and `/v1/admin/market-data/*` | +| `ADMIN_FIREBASE_UIDS` | _(empty)_ | Comma-separated Firebase UIDs allowed to call admin routes | Requires `TRADING_ENABLED=true`, Alpaca credentials, and `QUESTION_PIPELINE_TEST_MODE=false` for on-demand resync/cleanup when @@ -230,16 +249,22 @@ Requires `TRADING_ENABLED=true`, Alpaca credentials, and week-coverage calendar remain available read-only; resync/cleanup return `503`. Scheduled worker sync also requires `MARKET_HISTORY_SYNC_ENABLED=true`. -| Method | Path | Auth | -|--------|------|------| -| `GET` | `/v1/admin/market-history/sync-runs` | Admin Firebase UID | -| `POST` | `/v1/admin/market-data/resync` | Admin Firebase UID | +| Method | Path | Auth | +| ------ | --------------------------------------------------- | ------------------ | +| `GET` | `/v1/admin/market-history/sync-runs` | Admin Firebase UID | +| `POST` | `/v1/admin/market-data/resync` | Admin Firebase UID | | `POST` | `/v1/admin/market-data/cleanup?archive=true\|false` | Admin Firebase UID | -`sync-runs` includes optional `config` when the server has market-history env loaded: +`sync-runs` includes optional `config` when the server has market-history env +loaded: ```json -{ "archiveEnabled": true, "windowDays": 5, "retentionDays": 5, "syncEnabled": true } +{ + "archiveEnabled": true, + "windowDays": 5, + "retentionDays": 5, + "syncEnabled": true +} ``` Flutter uses `config.archiveEnabled` to show the archive checkbox in the cleanup diff --git a/server/bin/server.dart b/server/bin/server.dart index 38cc7e7..fe4d911 100644 --- a/server/bin/server.dart +++ b/server/bin/server.dart @@ -13,11 +13,16 @@ import '../lib/firebase_auth.dart'; import '../lib/handlers/incoming_question_handler.dart'; import '../lib/handlers/market_history_admin_handler.dart'; import '../lib/handlers/profile_handler.dart'; +import '../lib/handlers/question_source_handler.dart'; import '../lib/handlers/questions_handler.dart'; import '../lib/handlers/questions_hub_handler.dart'; +import '../lib/handlers/spelling_words_handler.dart'; import '../lib/handlers/trading_dev_handler.dart'; import '../lib/pipeline/question_pipeline.dart'; +import '../lib/question_dispatcher.dart'; import '../lib/question_service.dart'; +import '../lib/spelling/spelling_items_db.dart'; +import '../lib/question_source_preferences_db.dart'; import '../lib/questions_db.dart'; import '../lib/trading/prospective_answer_scoring.dart'; import '../lib/trading/guardrails.dart'; @@ -61,9 +66,14 @@ Future main() async { final FirebaseAuthVerifier auth = FirebaseAuthVerifier(env.firebaseWebApiKey); final QuestionsDb questionsDb = QuestionsDb(db.connection); + final QuestionSourcePreferencesDb questionSourcePreferencesDb = + QuestionSourcePreferencesDb(db.connection); + final SpellingItemsDb spellingItemsDb = SpellingItemsDb(db.connection); + await spellingItemsDb.ensureCatalogSeeded(); final QuestionService questionService = QuestionService( questionsDb: questionsDb, hubConnections: questionsHubConnections, + questionSourcePreferencesDb: questionSourcePreferencesDb, ); // Trading wiring (gated by TRADING_ENABLED). The trading pipeline plugs @@ -225,8 +235,17 @@ Future main() async { questionsDb: questionsDb, questionService: questionService, tradingPipeline: tradingPipeline, + questionSourcePreferencesDb: questionSourcePreferencesDb, + spellingItemsDb: spellingItemsDb, testMode: env.questionPipelineTestMode, ); + final QuestionDispatcher questionDispatcher = QuestionDispatcher( + questionsDb: questionsDb, + questionService: questionService, + questionPipeline: questionPipeline, + preferencesDb: questionSourcePreferencesDb, + ); + questionPipeline.attachQuestionDispatcher(questionDispatcher); QuestionBackgroundWorker? backgroundWorker; if (env.questionWorkerEnabled) { backgroundWorker = QuestionBackgroundWorker( @@ -238,6 +257,14 @@ Future main() async { backgroundWorker.start(); } final Handler profile = profileHandler(db: db, auth: auth); + final Handler questionSources = questionSourceHandler( + db: questionSourcePreferencesDb, + auth: auth, + ); + final Handler spellingWords = spellingWordsHandler( + auth: auth, + spellingItemsDb: spellingItemsDb, + ); final Handler questionsHub = questionsHubHandler( auth: auth, questionService: questionService, @@ -250,6 +277,7 @@ Future main() async { auth: auth, questionsDb: questionsDb, questionService: questionService, + questionDispatcher: questionDispatcher, questionPipeline: questionPipeline, ); final Handler? tradingDev = tradingDevActions == null @@ -289,6 +317,12 @@ Future main() async { if (path.startsWith(questionsBasePath)) { return questions(request); } + if (path == questionSourcesBasePath) { + return questionSources(request); + } + if (path.startsWith(spellingWordsBasePath)) { + return spellingWords(request); + } return profile(request); }); diff --git a/server/lib/handlers/question_source_handler.dart b/server/lib/handlers/question_source_handler.dart new file mode 100644 index 0000000..f9dbb32 --- /dev/null +++ b/server/lib/handlers/question_source_handler.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:shelf/shelf.dart'; + +import '../cors_headers.dart'; +import '../firebase_auth.dart'; +import '../question_source_preferences.dart'; +import '../question_source_preferences_db.dart'; + +const String questionSourcesBasePath = '/v1/me/question-sources'; + +Handler questionSourceHandler({ + required QuestionSourcePreferencesDb db, + required FirebaseAuthVerifier auth, +}) { + return (Request request) async { + if (request.method == 'OPTIONS') { + return Response.ok( + '', + headers: apiCorsHeaders(methods: 'GET, PUT, OPTIONS'), + ); + } + + if (request.requestedUri.path != questionSourcesBasePath) { + return _jsonResponse(404, {'error': 'Not found'}); + } + + final String? firebaseUid = await auth.verifyBearerToken( + request.headers['Authorization'] ?? request.headers['authorization'], + ); + if (firebaseUid == null) { + return _jsonResponse(401, {'error': 'Unauthorized'}); + } + + try { + if (request.method == 'GET') { + final List sources = + await db.getResolved(firebaseUid); + return _jsonResponse( + 200, + QuestionSourcePreferences.toApiPayload(sources: sources), + ); + } + + if (request.method == 'PUT') { + final String body = await request.readAsString(); + final Map json = + jsonDecode(body) as Map; + final List> stored = + QuestionSourcePreferences.parsePutBody(json); + final List saved = + await db.save(firebaseUid, stored); + return _jsonResponse( + 200, + QuestionSourcePreferences.toApiPayload( + sources: saved, + updatedAt: DateTime.now().toUtc(), + ), + ); + } + + return _jsonResponse(405, {'error': 'Method not allowed'}); + } on QuestionSourceValidationException catch (e) { + return _jsonResponse(400, {'error': e.message}); + } catch (e, st) { + stderr.writeln('Question source handler error: $e\n$st'); + return _jsonResponse(500, {'error': 'Internal error'}); + } + }; +} + +Response _jsonResponse(int status, Map body) { + return Response( + status, + body: jsonEncode(body), + headers: { + ...apiCorsHeaders(methods: 'GET, PUT, OPTIONS'), + 'Content-Type': 'application/json', + }, + ); +} diff --git a/server/lib/handlers/questions_handler.dart b/server/lib/handlers/questions_handler.dart index db4f1f3..104ed27 100644 --- a/server/lib/handlers/questions_handler.dart +++ b/server/lib/handlers/questions_handler.dart @@ -7,6 +7,7 @@ import 'package:shelf_router/shelf_router.dart'; import '../cors_headers.dart'; import '../firebase_auth.dart'; import '../pipeline/question_pipeline.dart'; +import '../question_dispatcher.dart'; import '../question_service.dart'; import '../questions_db.dart'; @@ -16,6 +17,7 @@ Handler questionsHandler({ required FirebaseAuthVerifier auth, required QuestionsDb questionsDb, required QuestionService questionService, + required QuestionDispatcher questionDispatcher, QuestionPipeline? questionPipeline, }) { final Router router = Router(); @@ -26,8 +28,9 @@ Handler questionsHandler({ return _jsonResponse(401, {'error': 'Unauthorized'}); } try { + await questionService.bootstrapOnLogin(firebaseUid); final Map? question = - await questionService.bootstrapOnLogin(firebaseUid); + await questionDispatcher.dispatchNextQuestion(firebaseUid); final int unansweredCount = await questionsDb.countUnansweredQuestions(firebaseUid); final Map score = @@ -49,16 +52,8 @@ Handler questionsHandler({ return _jsonResponse(401, {'error': 'Unauthorized'}); } try { - final List> rows = - await questionsDb.listUnansweredQuestions(firebaseUid); - final List> questions = rows - .map( - (Map row) => questionsDb.toClientPayload( - row, - unansweredCount: rows.length, - ), - ) - .toList(); + final List> questions = + await questionDispatcher.listPanelQuestions(firebaseUid); return _jsonResponse(200, { 'questions': questions, 'unansweredCount': questions.length, @@ -95,7 +90,7 @@ Handler questionsHandler({ final Map score = await questionsDb.resetGuessScoreSummary(firebaseUid); final Map? question = - await questionService.ensureProspectiveQuestionQueued(firebaseUid); + await questionDispatcher.dispatchNextQuestion(firebaseUid); final int unansweredCount = await questionsDb.countUnansweredQuestions(firebaseUid); return _jsonResponse(200, { @@ -121,12 +116,14 @@ Handler questionsHandler({ final Map json = body.isEmpty ? {} : jsonDecode(body) as Map; - final num answer = (json['answer'] as num?) ?? 0; + final String? answerText = json['answerText'] as String?; + final num? answer = json['answer'] as num?; final Map? updated = await questionsDb.submitAnswer( questionId: id, assignedUserId: firebaseUid, userResponse: answer, + userResponseText: answerText, ); if (updated == null) { return _jsonResponse(404, {'error': 'Not found'}); @@ -135,36 +132,30 @@ Handler questionsHandler({ await questionPipeline.onAnswerSubmitted( firebaseUid: firebaseUid, answeredQuestion: updated, - userResponse: answer, + userResponse: updated['userResponse'] as num? ?? answer ?? 0, ); } var unansweredCount = await questionsDb.countUnansweredQuestions(firebaseUid); - Map? nextQuestion; - if (unansweredCount == 0) { - nextQuestion = - await questionService.ensureProspectiveQuestionQueued(firebaseUid); - if (nextQuestion != null) { - unansweredCount = - (nextQuestion['unansweredCount'] as num?)?.toInt() ?? - await questionsDb.countUnansweredQuestions(firebaseUid); - } - } else { - final Map? nextRow = - await questionsDb.findUnansweredQuestion(firebaseUid); - if (nextRow != null) { - nextQuestion = questionsDb.toClientPayload( - nextRow, - unansweredCount: unansweredCount, - ); - } + final Map? nextQuestion = + await questionDispatcher.nextQuestionAfterAnswer( + firebaseUid: firebaseUid, + answeredQuestion: updated, + ); + if (nextQuestion != null) { + unansweredCount = + (nextQuestion['unansweredCount'] as num?)?.toInt() ?? + unansweredCount; } - final Map score = - await questionsDb.getGuessScoreSummary(firebaseUid); + final bool isMarketAnswer = + QuestionsDb.resolveSourceId(updated) == 'market_history'; + final Map? score = isMarketAnswer + ? await questionsDb.getGuessScoreSummary(firebaseUid) + : null; return _jsonResponse(200, { 'question': updated, 'unansweredCount': unansweredCount, - 'score': score, + if (score != null) 'score': score, if (nextQuestion != null) 'nextQuestion': nextQuestion, }); } catch (e, st) { @@ -174,6 +165,33 @@ Handler questionsHandler({ }, ); + router.post( + '$questionsBasePath//spelling/prepare', + (Request request, String id) async { + final String? firebaseUid = await _verify(auth, request); + if (firebaseUid == null) { + return _jsonResponse(401, {'error': 'Unauthorized'}); + } + if (questionPipeline == null) { + return _jsonResponse(503, {'error': 'Unavailable'}); + } + try { + final Map? question = + await questionPipeline.prepareSpellingQuestion( + firebaseUid: firebaseUid, + questionId: id, + ); + if (question == null) { + return _jsonResponse(404, {'error': 'Not found'}); + } + return _jsonResponse(200, {'question': question}); + } catch (e, st) { + stderr.writeln('Prepare spelling question error: $e\n$st'); + return _jsonResponse(500, {'error': 'Internal error'}); + } + }, + ); + router.post( '$questionsBasePath//defer', (Request request, String id) async { diff --git a/server/lib/handlers/spelling_words_handler.dart b/server/lib/handlers/spelling_words_handler.dart new file mode 100644 index 0000000..f8b4121 --- /dev/null +++ b/server/lib/handlers/spelling_words_handler.dart @@ -0,0 +1,137 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; + +import '../cors_headers.dart'; +import '../firebase_auth.dart'; +import '../spelling/spelling_items_db.dart'; + +const String spellingWordsBasePath = '/v1/me/spelling-words'; + +Handler spellingWordsHandler({ + required FirebaseAuthVerifier auth, + required SpellingItemsDb spellingItemsDb, +}) { + final Router router = Router(); + + router.get(spellingWordsBasePath, (Request request) async { + final String? firebaseUid = await _verify(auth, request); + if (firebaseUid == null) { + return _jsonResponse(401, {'error': 'Unauthorized'}); + } + try { + final List words = await spellingItemsDb.listCanonicalWords(); + return _jsonResponse(200, {'words': words}); + } catch (e, st) { + stderr.writeln('List spelling words error: $e\n$st'); + return _jsonResponse(500, {'error': 'Internal error'}); + } + }); + + router.post('$spellingWordsBasePath/import', (Request request) async { + final String? firebaseUid = await _verify(auth, request); + if (firebaseUid == null) { + return _jsonResponse(401, {'error': 'Unauthorized'}); + } + try { + final String body = await request.readAsString(); + final List lines = body.split(RegExp(r'\r?\n')); + final ({int added, int skipped}) result = + await spellingItemsDb.importCanonicalWords(lines); + return _jsonResponse(200, { + 'added': result.added, + 'skipped': result.skipped, + }); + } catch (e, st) { + stderr.writeln('Import spelling words error: $e\n$st'); + return _jsonResponse(500, {'error': 'Internal error'}); + } + }); + + router.get('$spellingWordsBasePath/export', (Request request) async { + final String? firebaseUid = await _verify(auth, request); + if (firebaseUid == null) { + return Response(401, body: 'Unauthorized', headers: _textCorsHeaders()); + } + try { + final List words = await spellingItemsDb.listCanonicalWords(); + return Response.ok( + words.join('\n'), + headers: { + ..._textCorsHeaders(), + 'Content-Type': 'text/plain; charset=utf-8', + }, + ); + } catch (e, st) { + stderr.writeln('Export spelling words error: $e\n$st'); + return Response(500, body: 'Internal error', headers: _textCorsHeaders()); + } + }); + + router.post(spellingWordsBasePath, (Request request) async { + final String? firebaseUid = await _verify(auth, request); + if (firebaseUid == null) { + return _jsonResponse(401, {'error': 'Unauthorized'}); + } + try { + final String body = await request.readAsString(); + final Map json = body.isEmpty + ? {} + : jsonDecode(body) as Map; + final String? word = json['word'] as String?; + if (word == null || word.trim().isEmpty) { + return _jsonResponse(400, {'error': 'word is required'}); + } + final bool added = await spellingItemsDb.addCanonicalWord(word); + if (!added) { + return _jsonResponse(409, {'error': 'Word already exists'}); + } + final List words = await spellingItemsDb.listCanonicalWords(); + return _jsonResponse(201, { + 'word': word.trim().toLowerCase(), + 'words': words, + }); + } catch (e, st) { + stderr.writeln('Add spelling word error: $e\n$st'); + return _jsonResponse(500, {'error': 'Internal error'}); + } + }); + + return (Request request) async { + if (request.method == 'OPTIONS' && + request.requestedUri.path.startsWith(spellingWordsBasePath)) { + return Response.ok('', headers: _jsonCorsHeaders()); + } + + final Response response = await router.call(request); + if (response.statusCode != 404) { + return response; + } + return _jsonResponse(404, {'error': 'Not found'}); + }; +} + +Future _verify(FirebaseAuthVerifier auth, Request request) { + return auth.verifyBearerToken( + request.headers['Authorization'] ?? request.headers['authorization'], + ); +} + +Map _jsonCorsHeaders() => + apiCorsHeaders(methods: 'GET, POST, OPTIONS'); + +Map _textCorsHeaders() => + apiCorsHeaders(methods: 'GET, OPTIONS'); + +Response _jsonResponse(int status, Map body) { + return Response( + status, + body: jsonEncode(body), + headers: { + ..._jsonCorsHeaders(), + 'Content-Type': 'application/json', + }, + ); +} diff --git a/server/lib/pipeline/question_pipeline.dart b/server/lib/pipeline/question_pipeline.dart index b7a937c..c6cc736 100644 --- a/server/lib/pipeline/question_pipeline.dart +++ b/server/lib/pipeline/question_pipeline.dart @@ -2,8 +2,17 @@ import 'dart:async'; import 'dart:io'; import 'dart:math'; +import '../question_dispatcher.dart'; import '../question_service.dart'; +import '../question_source_preferences.dart'; +import '../question_source_preferences_db.dart'; import '../questions_db.dart'; +import '../spelling/spelling_answer_policy.dart'; +import '../spelling/spelling_item_record.dart'; +import '../spelling/spelling_item_selector.dart'; +import '../spelling/spelling_items_db.dart'; +import '../spelling/spelling_question_links_db.dart'; +import '../spelling/user_spelling_progress_db.dart'; import '../trading/trading_pipeline.dart'; import 'branch_decision.dart'; import 'external_data_fetcher.dart'; @@ -22,6 +31,7 @@ abstract final class PipelineKeys { static const String geography = 'geography'; static const String weather = 'weather'; static const String trading = 'trading'; + static const String spelling = 'spelling'; } /// Steps within each pipeline branch. @@ -32,6 +42,8 @@ abstract final class PipelineSteps { static const String capitalRecovery = 'capital_recovery'; static const String temperatureQuiz = 'temperature_quiz'; static const String temperatureFollowUp = 'temperature_follow_up'; + static const String spellingWordQuiz = 'spelling_word_quiz'; + static const String spellingFollowUp = 'spelling_follow_up'; static const String idle = 'idle'; } @@ -60,20 +72,49 @@ class QuestionPipeline { required QuestionService questionService, ExternalDataFetcher? fetcher, TradingPipeline? tradingPipeline, + QuestionSourcePreferencesDb? questionSourcePreferencesDb, + SpellingItemsDb? spellingItemsDb, + SpellingItemSelector? spellingItemSelector, + UserSpellingProgressDb? spellingProgressDb, + SpellingQuestionLinksDb? spellingQuestionLinksDb, this.maxQueuedQuestions = 3, this.testMode = false, }) : _questionsDb = questionsDb, _questionService = questionService, _fetcher = fetcher ?? ExternalDataFetcher(), - _tradingPipeline = tradingPipeline; + _tradingPipeline = tradingPipeline, + _questionSourcePreferencesDb = questionSourcePreferencesDb, + _spellingItemsDb = + spellingItemsDb ?? SpellingItemsDb(questionsDb.connection), + _spellingProgressDb = + spellingProgressDb ?? UserSpellingProgressDb(questionsDb.connection), + _spellingQuestionLinksDb = spellingQuestionLinksDb ?? + SpellingQuestionLinksDb(questionsDb.connection), + _spellingSelector = spellingItemSelector ?? + SpellingItemSelector( + itemsDb: + spellingItemsDb ?? SpellingItemsDb(questionsDb.connection), + ); final QuestionsDb _questionsDb; final QuestionService _questionService; final ExternalDataFetcher _fetcher; final TradingPipeline? _tradingPipeline; + final QuestionSourcePreferencesDb? _questionSourcePreferencesDb; + final SpellingItemsDb _spellingItemsDb; + final SpellingItemSelector _spellingSelector; + final UserSpellingProgressDb _spellingProgressDb; + final SpellingQuestionLinksDb _spellingQuestionLinksDb; final int maxQueuedQuestions; final bool testMode; + QuestionDispatcher? _questionDispatcher; + + /// Wired after construction in [bin/server.dart] to avoid a circular dependency. + void attachQuestionDispatcher(QuestionDispatcher dispatcher) { + _questionDispatcher = dispatcher; + } + /// Periodic tick: start pipelines for idle users and top up shallow queues. Future runMaintenanceCycle() async { final List userIds = await _questionsDb.listAllUserFirebaseUids(); @@ -87,7 +128,12 @@ class QuestionPipeline { } Future _maintainUser(String firebaseUid) async { - final int queued = await _questionsDb.countUnansweredQuestions(firebaseUid); + final QuestionDispatcher? dispatcher = _questionDispatcher; + if (dispatcher != null) { + await dispatcher.topUpQueue(firebaseUid); + } + + var queued = await _questionsDb.countUnansweredQuestions(firebaseUid); if (queued >= maxQueuedQuestions) { return; } @@ -95,9 +141,6 @@ class QuestionPipeline { final Map? state = await _questionsDb.getPipelineState(firebaseUid); if (state == null) { - if (queued == 0) { - await _startRootPipeline(firebaseUid); - } return; } @@ -114,6 +157,7 @@ class QuestionPipeline { required String sourceTag, required String pipelineKey, required String pipelineStep, + Map? metadata, }) async { await _questionService.createAndDeliverQuestion( assignedUserId: firebaseUid, @@ -124,6 +168,7 @@ class QuestionPipeline { sourceTag: sourceTag, pipelineKey: pipelineKey, pipelineStep: pipelineStep, + metadata: metadata, ); } @@ -141,10 +186,28 @@ class QuestionPipeline { } Future _canEnqueue(String firebaseUid) async { - final int queued = await _questionsDb.countUnansweredQuestions(firebaseUid); + final int queued = await _enabledUnansweredCount(firebaseUid); return queued < maxQueuedQuestions; } + Future _enabledUnansweredCount(String firebaseUid) async { + final QuestionSourcePreferencesDb? preferencesDb = + _questionSourcePreferencesDb; + if (preferencesDb == null) { + return _questionsDb.countUnansweredQuestions(firebaseUid); + } + final List prefs = + await preferencesDb.getResolved(firebaseUid); + final Set allowed = prefs + .where( + (ResolvedQuestionSourcePreference s) => + s.enabled && !s.comingSoon, + ) + .map((ResolvedQuestionSourcePreference s) => s.id) + .toSet(); + return _questionsDb.countUnansweredForAllowedSources(firebaseUid, allowed); + } + /// Called after a question is answered; may enqueue the next branched question. Future onAnswerSubmitted({ required String firebaseUid, @@ -199,9 +262,217 @@ class QuestionPipeline { ); case PipelineKeys.trading: break; + case PipelineKeys.spelling: + await _handleSpellingAnswer( + firebaseUid: firebaseUid, + step: pipelineStep, + userResponse: userResponse, + correctAnswer: correctAnswer, + context: context, + answeredQuestion: answeredQuestion, + ); } } + /// Selects the next word, links progress, and returns the prepared client payload. + /// + /// Called when the spelling tile loads (word selection runs here, not at enqueue). + Future?> prepareSpellingQuestion({ + required String firebaseUid, + required String questionId, + }) async { + if (!await _isSpellingEnabled(firebaseUid)) { + stderr.writeln( + 'prepareSpelling: spelling source disabled for $firebaseUid', + ); + return null; + } + + Map? question = await _questionsDb.findQuestionById( + questionId: questionId, + assignedUserId: firebaseUid, + ); + if (question == null) { + stderr.writeln( + 'prepareSpelling: question not found id=$questionId uid=$firebaseUid', + ); + return null; + } + if (QuestionsDb.resolveSourceId(question) != 'spelling') { + stderr.writeln( + 'prepareSpelling: not a spelling question id=$questionId', + ); + return null; + } + if (question['userResponse'] != null) { + stderr.writeln( + 'prepareSpelling: already answered id=$questionId', + ); + return null; + } + + final String? step = question['pipelineStep'] as String?; + final String? sourceTag = question['sourceTag'] as String?; + if (step == PipelineSteps.spellingFollowUp) { + stderr.writeln( + 'prepareSpelling: follow-up question is not preparable id=$questionId', + ); + return null; + } + final bool preparable = step == PipelineSteps.spellingWordQuiz || + sourceTag == 'spelling:word_quiz'; + if (!preparable) { + stderr.writeln( + 'prepareSpelling: unsupported step=$step sourceTag=$sourceTag id=$questionId', + ); + return null; + } + + final Map metadata = Map.from( + question['metadata'] as Map? ?? {}, + ); + + final String? existingWord = metadata['displayed_word'] as String?; + if (existingWord != null && existingWord.isNotEmpty) { + return _finalizePreparedSpellingQuestion( + firebaseUid: firebaseUid, + questionId: questionId, + question: question, + metadata: metadata, + ); + } + + final bool needsPick = + metadata['spelling_pending'] == true || + metadata['spelling_item_id'] == null; + + SpellingItemRecord? item; + if (needsPick) { + await _spellingItemsDb.ensureCatalogSeeded(); + item = await _spellingSelector.pickForUser(firebaseUid); + if (item == null) { + stderr.writeln( + 'prepareSpelling: no spelling items available for $firebaseUid', + ); + return null; + } + + final Map itemMetadata = item.toQuestionMetadata(); + final String questionText = testMode + ? (question['text'] as String? ?? PipelineTestQuestions.text) + : 'Spell the word you hear.'; + + question = await _questionsDb.updateSpellingQuizQuestion( + questionId: questionId, + assignedUserId: firebaseUid, + questionText: questionText, + correctAnswer: item.correctAnswer, + metadata: itemMetadata, + ); + if (question == null) { + stderr.writeln( + 'prepareSpelling: update failed id=$questionId step=$step tag=$sourceTag', + ); + return null; + } + + await _questionsDb.upsertPipelineState( + assignedUserId: firebaseUid, + pipelineKey: PipelineKeys.spelling, + step: PipelineSteps.spellingWordQuiz, + context: item.toPipelineContext(), + ); + } else { + item = _itemFromMetadata(metadata, question); + if (item == null) { + stderr.writeln( + 'prepareSpelling: incomplete metadata id=$questionId', + ); + return null; + } + } + + return _finalizePreparedSpellingQuestion( + firebaseUid: firebaseUid, + questionId: questionId, + question: question, + metadata: metadata, + item: item, + ); + } + + SpellingItemRecord? _itemFromMetadata( + Map metadata, + Map question, + ) { + final String? itemId = metadata['spelling_item_id'] as String?; + final String? wordId = metadata['spelling_word_id'] as String?; + final String? canonical = metadata['canonical_word'] as String?; + final String? displayed = metadata['displayed_word'] as String?; + final Object? isCorrect = metadata['is_correctly_spelled']; + if (itemId == null || + itemId.isEmpty || + wordId == null || + canonical == null || + displayed == null || + isCorrect is! bool) { + return null; + } + return SpellingItemRecord( + id: itemId, + spellingWordId: wordId, + canonicalWord: canonical, + displayedWord: displayed, + isCorrectlySpelled: isCorrect, + correctAnswer: question['correctAnswer'] as num, + ); + } + + Future?> _finalizePreparedSpellingQuestion({ + required String firebaseUid, + required String questionId, + required Map question, + required Map metadata, + SpellingItemRecord? item, + }) async { + final SpellingItemRecord? resolved = + item ?? _itemFromMetadata(metadata, question); + if (resolved == null) { + return null; + } + + await _spellingProgressDb.ensureLink( + firebaseUid: firebaseUid, + spellingItemId: resolved.id, + ); + await _spellingQuestionLinksDb.linkQuestion( + questionId: questionId, + firebaseUid: firebaseUid, + spellingItemId: resolved.id, + ); + + final int unansweredCount = + await _questionsDb.countUnansweredQuestions(firebaseUid); + return _questionsDb.toClientPayload( + question, + unansweredCount: unansweredCount, + ); + } + + /// Ensures an unanswered spelling word quiz exists when the source is enabled. + Future maintainSpellingForUser(String firebaseUid) async { + if (!await _isSpellingEnabled(firebaseUid)) { + return; + } + if (await _questionsDb.hasUnansweredSpellingWordQuiz(firebaseUid)) { + return; + } + if (!await _canEnqueue(firebaseUid)) { + return; + } + await _startSpellingPipeline(firebaseUid); + } + Future> _loadContext( String firebaseUid, String pipelineKey, @@ -452,6 +723,101 @@ class QuestionPipeline { } } + /// Reserves a spelling quiz slot; word selection runs at tile load via [prepareSpellingQuestion]. + Future _startSpellingPipeline(String firebaseUid) async { + if (!await _canEnqueue(firebaseUid)) { + return; + } + if (!await _isSpellingEnabled(firebaseUid)) { + return; + } + + await _questionsDb.upsertPipelineState( + assignedUserId: firebaseUid, + pipelineKey: PipelineKeys.spelling, + step: PipelineSteps.spellingWordQuiz, + context: {}, + ); + await _deliverPipelineQuestion( + firebaseUid: firebaseUid, + questionText: testMode + ? PipelineTestQuestions.text + : 'Spelling question loading…', + correctAnswer: 0, + sourceTag: 'spelling:word_quiz', + pipelineKey: PipelineKeys.spelling, + pipelineStep: PipelineSteps.spellingWordQuiz, + metadata: {'spelling_pending': true}, + ); + } + + Future _handleSpellingAnswer({ + required String firebaseUid, + required String step, + required num userResponse, + required num correctAnswer, + required Map context, + required Map answeredQuestion, + }) async { + final String canonicalWord = + context['canonicalWord'] as String? ?? 'the word'; + + switch (step) { + case PipelineSteps.spellingWordQuiz: + final String typed = + (answeredQuestion['userResponseText'] as String?)?.trim() ?? ''; + final BranchOutcome outcome = + SpellingAnswerPolicy.matches(typed, canonicalWord) + ? BranchOutcome.match + : BranchOutcome.miss; + final String? spellingItemId = context['spellingItemId'] as String?; + if (spellingItemId != null) { + await _spellingProgressDb.recordQuizOutcome( + firebaseUid: firebaseUid, + spellingItemId: spellingItemId, + correct: outcome == BranchOutcome.match, + ); + } + final String followUp = switch (outcome) { + BranchOutcome.match => + "Correct — '$canonicalWord' is the proper spelling. " + 'Enter +1 for another word, -1 to pause.', + _ => + "The proper spelling is '$canonicalWord'. " + 'Enter +1 to try another word, -1 to pause.', + }; + await _questionsDb.upsertPipelineState( + assignedUserId: firebaseUid, + pipelineKey: PipelineKeys.spelling, + step: PipelineSteps.spellingFollowUp, + context: context, + ); + await _deliverPipelineQuestion( + firebaseUid: firebaseUid, + questionText: followUp, + correctAnswer: 1, + sourceTag: 'spelling:follow_up', + pipelineKey: PipelineKeys.spelling, + pipelineStep: PipelineSteps.spellingFollowUp, + ); + case PipelineSteps.spellingFollowUp: + if (userResponse > 0) { + await _startSpellingPipeline(firebaseUid); + } else { + await _setIdle(firebaseUid, PipelineKeys.spelling); + } + } + } + + Future _isSpellingEnabled(String firebaseUid) async { + final QuestionSourcePreferencesDb? preferencesDb = + _questionSourcePreferencesDb; + if (preferencesDb == null) { + return true; + } + return preferencesDb.isSourceEnabled(firebaseUid, 'spelling'); + } + Future _advanceFromIdle( String firebaseUid, Map state, @@ -462,8 +828,10 @@ class QuestionPipeline { await _startGeographyPipeline(firebaseUid); case PipelineKeys.weather: await _startWeatherPipeline(firebaseUid); + case PipelineKeys.spelling: + await _startSpellingPipeline(firebaseUid); default: - await _startRootPipeline(firebaseUid); + await _questionDispatcher?.topUpQueue(firebaseUid); } } diff --git a/server/lib/pipeline/spelling_word_list.dart b/server/lib/pipeline/spelling_word_list.dart new file mode 100644 index 0000000..6bb8084 --- /dev/null +++ b/server/lib/pipeline/spelling_word_list.dart @@ -0,0 +1,68 @@ +import 'dart:math'; + +/// One spelling challenge: a displayed word and whether it is spelled correctly. +class SpellingChallenge { + const SpellingChallenge({ + required this.displayedWord, + required this.isCorrectlySpelled, + required this.canonicalWord, + }); + + final String displayedWord; + final bool isCorrectlySpelled; + final String canonicalWord; +} + +/// Curated commonly misspelled words for spelling quizzes. +abstract final class SpellingWordList { + static const List<({String correct, String misspelled})> pairs = + <({String correct, String misspelled})>[ + (correct: 'accommodate', misspelled: 'acommodate'), + (correct: 'separate', misspelled: 'seperate'), + (correct: 'definitely', misspelled: 'definately'), + (correct: 'occurrence', misspelled: 'occurence'), + (correct: 'embarrass', misspelled: 'embarass'), + (correct: 'maintenance', misspelled: 'maintainance'), + (correct: 'recommend', misspelled: 'reccomend'), + (correct: 'privilege', misspelled: 'priviledge'), + (correct: 'necessary', misspelled: 'neccessary'), + (correct: 'rhythm', misspelled: 'rythm'), + ]; + + /// Known misspelling from the curated list, if any. + static String? misspellingFor(String canonicalWord) { + final String normalized = canonicalWord.trim().toLowerCase(); + for (final ({String correct, String misspelled}) pair in pairs) { + if (pair.correct == normalized) { + return pair.misspelled; + } + } + return null; + } + + /// Fallback typo when [misspellingFor] has no curated entry. + static String generatedMisspelling(String canonicalWord) { + return misspellingFor(canonicalWord) ?? _naiveMisspelling(canonicalWord); + } + + static String _naiveMisspelling(String word) { + final String lower = word.trim().toLowerCase(); + if (lower.length <= 2) { + return '${lower}x'; + } + final int dropAt = lower.length ~/ 2; + return lower.substring(0, dropAt) + lower.substring(dropAt + 1); + } + + static SpellingChallenge randomChallenge([Random? random]) { + final Random rng = random ?? Random(); + final ({String correct, String misspelled}) pair = + pairs[rng.nextInt(pairs.length)]; + final bool showCorrect = rng.nextBool(); + return SpellingChallenge( + displayedWord: showCorrect ? pair.correct : pair.misspelled, + isCorrectlySpelled: showCorrect, + canonicalWord: pair.correct, + ); + } +} diff --git a/server/lib/question_dispatcher.dart b/server/lib/question_dispatcher.dart new file mode 100644 index 0000000..7eb2cca --- /dev/null +++ b/server/lib/question_dispatcher.dart @@ -0,0 +1,288 @@ +import 'pipeline/question_pipeline.dart'; +import 'question_service.dart'; +import 'question_source_mix.dart'; +import 'question_source_preferences.dart'; +import 'question_source_preferences_db.dart'; +import 'questions_db.dart'; + +/// Ratio-aware entry point for topping up a user's unanswered question queue. +class QuestionDispatcher { + QuestionDispatcher({ + required QuestionsDb questionsDb, + required QuestionService questionService, + required QuestionPipeline questionPipeline, + required QuestionSourcePreferencesDb preferencesDb, + this.maxQueuedQuestions = 3, + }) : _questionsDb = questionsDb, + _questionService = questionService, + _questionPipeline = questionPipeline, + _preferencesDb = preferencesDb; + + final QuestionsDb _questionsDb; + final QuestionService _questionService; + final QuestionPipeline _questionPipeline; + final QuestionSourcePreferencesDb _preferencesDb; + final int maxQueuedQuestions; + + /// Ensures at least one enabled-source question exists; returns mix-ordered head. + Future?> dispatchNextQuestion(String firebaseUid) async { + await _questionsDb.ensureUserExists(firebaseUid); + + var rows = await _listEnabledUnansweredOrdered(firebaseUid); + if (rows.isEmpty) { + if (!await _enqueueOneFromMix(firebaseUid)) { + await _ensureSpellingQuestionWhenNeeded(firebaseUid); + } + rows = await _listEnabledUnansweredOrdered(firebaseUid); + } + if (rows.isEmpty) { + await _ensureSpellingQuestionWhenNeeded(firebaseUid); + rows = await _listEnabledUnansweredOrdered(firebaseUid); + } + + if (rows.isEmpty) { + return null; + } + return _toClientPayload(rows.first, rows.length); + } + + /// Ensures an enabled-source question exists before the question panel opens. + Future preparePanelQueue(String firebaseUid) async { + await _questionsDb.ensureUserExists(firebaseUid); + var rows = await _listEnabledUnansweredOrdered(firebaseUid); + if (rows.isEmpty) { + await _enqueueOneFromMix(firebaseUid); + rows = await _listEnabledUnansweredOrdered(firebaseUid); + } + if (rows.isEmpty) { + await _ensureSpellingQuestionWhenNeeded(firebaseUid); + } + } + + /// Unanswered questions for enabled sources only, ordered by configured mix. + Future>> listPanelQuestions( + String firebaseUid, + ) async { + await preparePanelQueue(firebaseUid); + var rows = await _listEnabledUnansweredOrdered(firebaseUid); + if (rows.isEmpty) { + await _enqueueOneFromMix(firebaseUid); + rows = await _listEnabledUnansweredOrdered(firebaseUid); + } + if (rows.isEmpty) { + await _ensureSpellingQuestionWhenNeeded(firebaseUid); + rows = await _listEnabledUnansweredOrdered(firebaseUid); + } + return rows + .map( + (Map row) => + _toClientPayload(row, rows.length), + ) + .toList(growable: false); + } + + /// Next unanswered enabled-source question after an answer. + Future?> nextQuestionAfterAnswer({ + required String firebaseUid, + required Map answeredQuestion, + }) async { + var rows = await _listEnabledUnansweredOrdered(firebaseUid); + if (rows.isEmpty) { + return dispatchNextQuestion(firebaseUid); + } + + final List prefs = + await _preferencesDb.getResolved(firebaseUid); + final Map mix = QuestionSourceMix.effectiveMixFromResolved(prefs); + final Map delivered = + await _questionsDb.countDeliveredBySourceId(firebaseUid); + final Set allowed = _enabledSourceIds(prefs); + final Map? row = await _questionsDb.findHeadUnansweredForMix( + firebaseUid, + effectiveMixPercent: mix, + deliveredCounts: delivered, + afterAnsweringSourceId: + QuestionsDb.resolveSourceId(answeredQuestion), + allowedSourceIds: allowed, + ); + if (row == null) { + return dispatchNextQuestion(firebaseUid); + } + return _toClientPayload(row, rows.length); + } + + /// Fills the queue up to [maxQueuedQuestions] using mix-sequential source picks. + Future topUpQueue(String firebaseUid) async { + await _questionsDb.ensureUserExists(firebaseUid); + + while (true) { + final int enabledQueued = + (await _listEnabledUnansweredOrdered(firebaseUid)).length; + if (enabledQueued >= maxQueuedQuestions) { + return; + } + if (!await _enqueueOneFromMix(firebaseUid)) { + return; + } + } + } + + Future _enqueueOneFromMix(String firebaseUid) async { + final List prefs = + await _preferencesDb.getResolved(firebaseUid); + final Map mix = QuestionSourceMix.effectiveMixFromResolved(prefs); + if (mix.isEmpty) { + return false; + } + + final Map delivered = + await _questionsDb.countDeliveredBySourceId(firebaseUid); + final String primary = QuestionSourceMix.pickNextSourceId( + effectiveMixPercent: mix, + deliveredCounts: delivered, + ); + final List order = _sourceTryOrderForEnqueue( + prefs: prefs, + primarySourceId: primary, + ); + + final int? marketBatchCap = _marketBatchCapForMix(prefs); + + for (final String sourceId in order) { + final bool added = await _produceFromSource( + firebaseUid, + sourceId, + marketBatchCap: marketBatchCap, + ); + if (added) { + return true; + } + } + return false; + } + + Future>> _listEnabledUnansweredOrdered( + String firebaseUid, + ) async { + final List prefs = + await _preferencesDb.getResolved(firebaseUid); + final Map mix = QuestionSourceMix.effectiveMixFromResolved(prefs); + final Set allowed = _enabledSourceIds(prefs); + if (allowed.isEmpty) { + return >[]; + } + return _questionsDb.listUnansweredOrderedForMix( + firebaseUid, + effectiveMixPercent: mix, + allowedSourceIds: allowed, + ); + } + + Set _enabledSourceIds( + List prefs, + ) { + return prefs + .where( + (ResolvedQuestionSourcePreference s) => + s.enabled && !s.comingSoon, + ) + .map((ResolvedQuestionSourcePreference s) => s.id) + .toSet(); + } + + List _sourceTryOrderForEnqueue({ + required List prefs, + required String primarySourceId, + }) { + final List enabled = prefs + .where( + (ResolvedQuestionSourcePreference s) => + s.enabled && !s.comingSoon, + ) + .toList(growable: false); + final List rest = enabled + .map((ResolvedQuestionSourcePreference s) => s.id) + .where((String id) => id != primarySourceId) + .toList() + ..sort( + ( + String a, + String b, + ) { + final int ratioA = enabled + .firstWhere((ResolvedQuestionSourcePreference s) => s.id == a) + .ratio; + final int ratioB = enabled + .firstWhere((ResolvedQuestionSourcePreference s) => s.id == b) + .ratio; + return ratioB.compareTo(ratioA); + }, + ); + return [primarySourceId, ...rest]; + } + + /// Last-resort spelling enqueue when mix dispatch did not produce a row. + Future _ensureSpellingQuestionWhenNeeded(String firebaseUid) async { + final List prefs = + await _preferencesDb.getResolved(firebaseUid); + if (!QuestionSourcePreferences.isEnabled(prefs, 'spelling')) { + return; + } + await _questionPipeline.maintainSpellingForUser(firebaseUid); + } + + Future _produceFromSource( + String firebaseUid, + String sourceId, { + int? marketBatchCap, + }) async { + if (!await _isSourceEnabled(firebaseUid, sourceId)) { + return false; + } + final int before = + (await _listEnabledUnansweredOrdered(firebaseUid)).length; + switch (sourceId) { + case 'market_history': + await _questionService.tryEnqueueMarketQuestions( + firebaseUid, + maxCreates: marketBatchCap, + ); + case 'spelling': + await _questionPipeline.maintainSpellingForUser(firebaseUid); + default: + return false; + } + final int after = + (await _listEnabledUnansweredOrdered(firebaseUid)).length; + return after > before; + } + + Future _isSourceEnabled(String firebaseUid, String sourceId) async { + final List prefs = + await _preferencesDb.getResolved(firebaseUid); + return QuestionSourcePreferences.isEnabled(prefs, sourceId); + } + + int? _marketBatchCapForMix(List prefs) { + if (!QuestionSourcePreferences.isEnabled(prefs, 'market_history') || + !QuestionSourcePreferences.isEnabled(prefs, 'spelling')) { + return null; + } + final Map mix = QuestionSourcePreferences.effectiveMix(prefs); + final int spellingShare = mix['spelling'] ?? 0; + if (spellingShare <= 0) { + return null; + } + return 1; + } + + Map _toClientPayload( + Map row, + int unansweredCount, + ) { + return _questionsDb.toClientPayload( + row, + unansweredCount: unansweredCount, + ); + } +} diff --git a/server/lib/question_service.dart b/server/lib/question_service.dart index d7cd6e9..5ce3fce 100644 --- a/server/lib/question_service.dart +++ b/server/lib/question_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'question_source_preferences_db.dart'; import 'questions_db.dart'; import 'signalr/questions_hub_connections.dart'; import 'trading/prospective_guess_selection.dart'; @@ -12,21 +13,21 @@ class QuestionService { QuestionService({ required QuestionsDb questionsDb, required QuestionsHubConnections hubConnections, + QuestionSourcePreferencesDb? questionSourcePreferencesDb, }) : _questionsDb = questionsDb, - _hubConnections = hubConnections; + _hubConnections = hubConnections, + _questionSourcePreferencesDb = questionSourcePreferencesDb; final QuestionsDb _questionsDb; final QuestionsHubConnections _hubConnections; + final QuestionSourcePreferencesDb? _questionSourcePreferencesDb; ProspectiveGuessAssignmentsDb get _assignmentsDb => ProspectiveGuessAssignmentsDb(_questionsDb.connection); - /// Called at login: ensures one unanswered prospective question when possible. - Future?> bootstrapOnLogin( - String firebaseUid, - ) async { + /// Called at login: ensures the user row exists (queue top-up uses [QuestionDispatcher]). + Future bootstrapOnLogin(String firebaseUid) async { await _questionsDb.ensureUserExists(firebaseUid); - return ensureProspectiveQuestionQueued(firebaseUid); } /// Ensures the active slot's top-half batch exists, then returns the FIFO head. @@ -54,11 +55,36 @@ class QuestionService { return null; } + /// Enqueues market questions when enabled; returns whether the queue grew. + Future tryEnqueueMarketQuestions( + String firebaseUid, { + DateTime? now, + int? maxCreates, + }) async { + if (!await _isMarketHistoryEnabled(firebaseUid)) { + return false; + } + final int before = + await _questionsDb.countUnansweredQuestions(firebaseUid); + await _ensureCurrentSlotBatch( + firebaseUid, + now: now, + maxCreates: maxCreates, + ); + final int after = await _questionsDb.countUnansweredQuestions(firebaseUid); + return after > before; + } + /// Creates every top-half question for the user's active slot pair. Future _ensureCurrentSlotBatch( String firebaseUid, { DateTime? now, + int? maxCreates, }) async { + if (!await _isMarketHistoryEnabled(firebaseUid)) { + return; + } + final ProspectiveGuessSelection selection = ProspectiveGuessSelection( connection: _questionsDb.connection, ); @@ -72,8 +98,12 @@ class QuestionService { final DateTime baseOrder = DateTime.now().toUtc(); var createdAny = false; + var createdCount = 0; for (var index = 0; index < batch.unassignedAssets.length; index++) { + if (maxCreates != null && createdCount >= maxCreates) { + return; + } final QuestionAuditAsset asset = batch.unassignedAssets[index]; if (await _assignmentsDb.hasAssignmentForSymbolSlotPair( firebaseUid: firebaseUid, @@ -123,6 +153,7 @@ class QuestionService { continue; } createdAny = true; + createdCount++; } if (createdAny) { @@ -131,6 +162,15 @@ class QuestionService { } } + Future _isMarketHistoryEnabled(String firebaseUid) async { + final QuestionSourcePreferencesDb? preferencesDb = + _questionSourcePreferencesDb; + if (preferencesDb == null) { + return true; + } + return preferencesDb.isSourceEnabled(firebaseUid, 'market_history'); + } + /// Inserts a question and pushes it to connected SignalR clients. Future> createAndDeliverQuestion({ required String assignedUserId, diff --git a/server/lib/question_source_mix.dart b/server/lib/question_source_mix.dart new file mode 100644 index 0000000..a1cc66f --- /dev/null +++ b/server/lib/question_source_mix.dart @@ -0,0 +1,70 @@ +import 'question_source_preferences.dart'; +import 'questions_db.dart'; + +/// Picks the next question source to match configured mix percentages over time. +abstract final class QuestionSourceMix { + /// Source id with the largest deficit vs configured effective mix (sequential balance). + static String pickNextSourceId({ + required Map effectiveMixPercent, + required Map deliveredCounts, + }) { + if (effectiveMixPercent.isEmpty) { + return 'market_history'; + } + + final int totalDelivered = + deliveredCounts.values.fold(0, (int a, int b) => a + b); + if (totalDelivered == 0) { + return effectiveMixPercent.entries + .reduce( + ( + MapEntry a, + MapEntry b, + ) => + a.value >= b.value ? a : b, + ) + .key; + } + + String? pick; + var maxDeficit = double.negativeInfinity; + for (final MapEntry entry in effectiveMixPercent.entries) { + final double target = entry.value / 100.0; + final double actual = + (deliveredCounts[entry.key] ?? 0) / totalDelivered; + final double deficit = target - actual; + if (deficit > maxDeficit) { + maxDeficit = deficit; + pick = entry.key; + } + } + return pick ?? effectiveMixPercent.keys.first; + } + + static Map effectiveMixFromResolved( + List prefs, + ) { + return QuestionSourcePreferences.effectiveMix(prefs); + } + + /// Orders unanswered rows so [preferredSourceId] is shown first (stable FIFO within source). + static List> orderUnansweredByPreferredSource( + List> rows, { + required String preferredSourceId, + }) { + if (rows.length <= 1) { + return rows; + } + + final List> preferred = >[]; + final List> rest = >[]; + for (final Map row in rows) { + if (QuestionsDb.resolveSourceId(row) == preferredSourceId) { + preferred.add(row); + } else { + rest.add(row); + } + } + return >[...preferred, ...rest]; + } +} diff --git a/server/lib/question_source_preferences.dart b/server/lib/question_source_preferences.dart new file mode 100644 index 0000000..dd35da9 --- /dev/null +++ b/server/lib/question_source_preferences.dart @@ -0,0 +1,215 @@ +/// Canonical question source definitions and merge/validation helpers. +class QuestionSourcePreferenceDefinition { + const QuestionSourcePreferenceDefinition({ + required this.id, + required this.label, + required this.description, + this.defaultEnabled = true, + this.defaultRatio = 50, + this.comingSoon = false, + }); + + final String id; + final String label; + final String description; + final bool defaultEnabled; + final int defaultRatio; + final bool comingSoon; +} + +class ResolvedQuestionSourcePreference { + const ResolvedQuestionSourcePreference({ + required this.id, + required this.label, + required this.description, + required this.enabled, + required this.ratio, + required this.comingSoon, + }); + + final String id; + final String label; + final String description; + final bool enabled; + final int ratio; + final bool comingSoon; + + Map toJson() { + return { + 'id': id, + 'label': label, + 'description': description, + 'enabled': enabled, + 'ratio': ratio, + 'comingSoon': comingSoon, + }; + } + + Map toStoredJson() { + return { + 'id': id, + 'enabled': enabled, + 'ratio': ratio, + }; + } +} + +class QuestionSourceValidationException implements Exception { + QuestionSourceValidationException(this.message); + + final String message; + + @override + String toString() => message; +} + +abstract final class QuestionSourcePreferences { + static const List definitions = + [ + QuestionSourcePreferenceDefinition( + id: 'market_history', + label: 'Market question', + description: 'Prospective price-move guessing game', + ), + QuestionSourcePreferenceDefinition( + id: 'spelling', + label: 'Spelling', + description: 'Spell-check quiz from curated word lists', + ), + ]; + + static List mergeStored( + List>? stored, + ) { + final Map> byId = + >{ + if (stored != null) + for (final Map row in stored) row['id']! as String: row, + }; + + return definitions + .map((QuestionSourcePreferenceDefinition definition) { + final Map? saved = byId[definition.id]; + final bool enabled = definition.comingSoon + ? false + : saved?['enabled'] as bool? ?? definition.defaultEnabled; + final int ratio = + (saved?['ratio'] as num?)?.round().clamp(0, 100) ?? + definition.defaultRatio; + return ResolvedQuestionSourcePreference( + id: definition.id, + label: definition.label, + description: definition.description, + enabled: enabled, + ratio: ratio, + comingSoon: definition.comingSoon, + ); + }) + .toList(growable: false); + } + + static List> parsePutBody(Map body) { + final Object? rawSources = body['sources']; + if (rawSources is! List) { + throw QuestionSourceValidationException('Expected sources array.'); + } + + final Map known = + { + for (final QuestionSourcePreferenceDefinition definition in definitions) + definition.id: definition, + }; + + final List> parsed = >[]; + for (final Object? entry in rawSources) { + if (entry is! Map) { + throw QuestionSourceValidationException('Invalid source entry.'); + } + final String? id = entry['id'] as String?; + if (id == null || !known.containsKey(id)) { + throw QuestionSourceValidationException('Unknown source id: $id'); + } + final QuestionSourcePreferenceDefinition definition = known[id]!; + final bool enabled = definition.comingSoon + ? false + : entry['enabled'] as bool? ?? definition.defaultEnabled; + if (definition.comingSoon && entry['enabled'] == true) { + throw QuestionSourceValidationException( + 'Source $id is not available yet.', + ); + } + final int ratio = + (entry['ratio'] as num?)?.round().clamp(0, 100) ?? + definition.defaultRatio; + parsed.add({ + 'id': id, + 'enabled': enabled, + 'ratio': ratio, + }); + } + + if (parsed.length != definitions.length) { + throw QuestionSourceValidationException( + 'Provide every known question source.', + ); + } + + final bool hasEnabled = parsed.any((Map row) { + final QuestionSourcePreferenceDefinition definition = + known[row['id']! as String]!; + return row['enabled'] == true && !definition.comingSoon; + }); + if (!hasEnabled) { + throw QuestionSourceValidationException( + 'Enable at least one question source.', + ); + } + + return parsed; + } + + /// Effective share (0–100) for each enabled source; empty when none enabled. + static Map effectiveMix( + List sources, + ) { + final Iterable enabled = sources.where( + (ResolvedQuestionSourcePreference s) => s.enabled && !s.comingSoon, + ); + final int totalWeight = enabled.fold( + 0, + (int sum, ResolvedQuestionSourcePreference s) => sum + s.ratio, + ); + if (totalWeight <= 0) { + return {}; + } + + return { + for (final ResolvedQuestionSourcePreference source in enabled) + source.id: ((source.ratio / totalWeight) * 100).round(), + }; + } + + static bool isEnabled( + List resolved, + String sourceId, + ) { + for (final ResolvedQuestionSourcePreference source in resolved) { + if (source.id == sourceId) { + return source.enabled && !source.comingSoon; + } + } + return false; + } + + static Map toApiPayload({ + required List sources, + DateTime? updatedAt, + }) { + return { + 'sources': sources + .map((ResolvedQuestionSourcePreference s) => s.toJson()) + .toList(), + if (updatedAt != null) 'updatedAt': updatedAt.toUtc().toIso8601String(), + }; + } +} diff --git a/server/lib/question_source_preferences_db.dart b/server/lib/question_source_preferences_db.dart new file mode 100644 index 0000000..e746a08 --- /dev/null +++ b/server/lib/question_source_preferences_db.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; + +import 'package:postgres/postgres.dart'; + +import 'question_source_preferences.dart'; + +class QuestionSourcePreferencesDb { + QuestionSourcePreferencesDb(this._connection); + + final Connection _connection; + + Future> getResolved( + String firebaseUid, + ) async { + final List>? stored = await _readStored(firebaseUid); + return QuestionSourcePreferences.mergeStored(stored); + } + + Future isSourceEnabled(String firebaseUid, String sourceId) async { + final List resolved = + await getResolved(firebaseUid); + return QuestionSourcePreferences.isEnabled(resolved, sourceId); + } + + Future> save( + String firebaseUid, + List> stored, + ) async { + final DateTime updatedAt = DateTime.now().toUtc(); + await _connection.execute( + Sql.named( + ''' + INSERT INTO users (firebase_uid, question_source_preferences, updated_at) + VALUES (@uid, @preferences::jsonb, @updated_at) + ON CONFLICT (firebase_uid) DO UPDATE SET + question_source_preferences = EXCLUDED.question_source_preferences, + updated_at = EXCLUDED.updated_at + ''', + ), + parameters: { + 'uid': firebaseUid, + 'preferences': jsonEncode(stored), + 'updated_at': updatedAt, + }, + ); + return QuestionSourcePreferences.mergeStored(stored); + } + + Future>?> _readStored(String firebaseUid) async { + final Result result = await _connection.execute( + Sql.named( + ''' + SELECT question_source_preferences + FROM users + WHERE firebase_uid = @uid + ''', + ), + parameters: {'uid': firebaseUid}, + ); + if (result.isEmpty) { + return null; + } + final Object? value = result.first[0]; + if (value == null) { + return null; + } + if (value is List) { + return value.cast>(); + } + if (value is String) { + final Object? decoded = jsonDecode(value); + if (decoded is List) { + return decoded.cast>(); + } + } + return null; + } +} diff --git a/server/lib/questions_db.dart b/server/lib/questions_db.dart index 8c701f1..66ebf34 100644 --- a/server/lib/questions_db.dart +++ b/server/lib/questions_db.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:postgres/postgres.dart'; import 'package:uuid/uuid.dart'; +import 'question_source_mix.dart'; import 'trading/market_history_config.dart'; import 'trading/prospective_answer_scoring.dart'; import 'trading/guess_score_store.dart'; @@ -36,7 +37,7 @@ class QuestionsDb { static const String _questionsSelectColumns = ''' q.id, q.assigned_user_id, q.question_text, q.user_response, q.correct_answer, q.created_at, q.modified_at, q.source_tag, q.pipeline_key, q.pipeline_step, - q.metadata'''; + q.metadata, q.user_response_text'''; static const String _unansweredQuestionsJoin = ''' FROM questions q @@ -151,26 +152,47 @@ class QuestionsDb { Future?> submitAnswer({ required String questionId, required String assignedUserId, - required num userResponse, + num? userResponse, + String? userResponseText, }) async { + if (userResponse == null && userResponseText == null) { + throw ArgumentError('userResponse or userResponseText is required'); + } + + num effectiveResponse = userResponse ?? 0; + String? textToStore = userResponseText?.trim(); + + if (textToStore != null) { + final Map? existing = await findQuestionById( + questionId: questionId, + assignedUserId: assignedUserId, + ); + if (existing == null || resolveSourceId(existing) != 'spelling') { + return null; + } + } + final DateTime now = DateTime.now().toUtc(); final Result result = await _connection.execute( Sql.named( ''' UPDATE questions - SET user_response = @user_response, modified_at = @modified_at + SET user_response = @user_response, + user_response_text = @user_response_text, + modified_at = @modified_at WHERE id = @id::uuid AND assigned_user_id = @uid AND user_response IS NULL RETURNING id, assigned_user_id, question_text, user_response, correct_answer, created_at, modified_at, source_tag, pipeline_key, pipeline_step, - metadata + metadata, user_response_text ''', ), parameters: { 'id': questionId, 'uid': assignedUserId, - 'user_response': userResponse, + 'user_response': effectiveResponse, + 'user_response_text': textToStore, 'modified_at': now, }, ); @@ -178,15 +200,122 @@ class QuestionsDb { return null; } final Map updated = _rowFromResult(result.first); - await _recordProspectiveAnswer(updated); - await ProspectiveGuessAssignmentsDb(_connection).markAnsweredByQuestionId( - questionId: questionId, - answeredAt: now, - ); - await _gradeProspectiveAnswerIfNeeded(updated); + if (resolveSourceId(updated) == 'market_history') { + await _recordProspectiveAnswer(updated); + await ProspectiveGuessAssignmentsDb(_connection).markAnsweredByQuestionId( + questionId: questionId, + answeredAt: now, + ); + await _gradeProspectiveAnswerIfNeeded(updated); + } return updated; } + /// Answered questions grouped by client [resolveSourceId]. + Future> countDeliveredBySourceId(String assignedUserId) async { + final Result result = await _connection.execute( + Sql.named( + ''' + SELECT source_tag, pipeline_key + FROM questions + WHERE assigned_user_id = @uid AND user_response IS NOT NULL + ''', + ), + parameters: {'uid': assignedUserId}, + ); + + final Map counts = {}; + for (final ResultRow row in result) { + final String sourceId = resolveSourceId({ + 'sourceTag': row[0], + 'pipelineKey': row[1], + }); + counts[sourceId] = (counts[sourceId] ?? 0) + 1; + } + return counts; + } + + /// Mix-ordered head of the unanswered queue for display after an answer or panel open. + Future?> findHeadUnansweredForMix( + String assignedUserId, { + required Map effectiveMixPercent, + Map? deliveredCounts, + String? afterAnsweringSourceId, + Set? allowedSourceIds, + }) async { + List> rows = + await listUnansweredQuestions(assignedUserId); + if (allowedSourceIds != null) { + rows = filterByAllowedSources(rows, allowedSourceIds); + } + if (rows.isEmpty) { + return null; + } + + if (afterAnsweringSourceId == 'spelling') { + for (final Map row in rows) { + if (resolveSourceId(row) == 'spelling') { + return row; + } + } + } + + final Map delivered = + deliveredCounts ?? await countDeliveredBySourceId(assignedUserId); + final String preferred = QuestionSourceMix.pickNextSourceId( + effectiveMixPercent: effectiveMixPercent, + deliveredCounts: delivered, + ); + final List> ordered = + QuestionSourceMix.orderUnansweredByPreferredSource( + rows, + preferredSourceId: preferred, + ); + return ordered.first; + } + + /// Full unanswered list ordered for panel display (preferred mix source first). + Future>> listUnansweredOrderedForMix( + String assignedUserId, { + required Map effectiveMixPercent, + Set? allowedSourceIds, + }) async { + List> rows = + await listUnansweredQuestions(assignedUserId); + if (allowedSourceIds != null) { + rows = filterByAllowedSources(rows, allowedSourceIds); + } + if (rows.isEmpty) { + return rows; + } + final Map delivered = + await countDeliveredBySourceId(assignedUserId); + final String preferred = QuestionSourceMix.pickNextSourceId( + effectiveMixPercent: effectiveMixPercent, + deliveredCounts: delivered, + ); + return QuestionSourceMix.orderUnansweredByPreferredSource( + rows, + preferredSourceId: preferred, + ); + } + + /// Keeps only rows whose [resolveSourceId] is in [allowedSourceIds]. + static List> filterByAllowedSources( + List> rows, + Set allowedSourceIds, + ) { + if (allowedSourceIds.isEmpty) { + return >[]; + } + return rows + .where( + (Map row) => + allowedSourceIds.contains(resolveSourceId(row)), + ) + .toList(growable: false); + } + Future?> findQuestionById({ required String questionId, required String assignedUserId, @@ -196,7 +325,7 @@ class QuestionsDb { ''' SELECT id, assigned_user_id, question_text, user_response, correct_answer, created_at, modified_at, source_tag, pipeline_key, pipeline_step, - metadata + metadata, user_response_text FROM questions WHERE id = @id::uuid AND assigned_user_id = @uid ''', @@ -370,6 +499,41 @@ class QuestionsDb { } /// Count of unanswered questions assigned to [assignedUserId]. + /// Unanswered [spelling_word_quiz] row for [assignedUserId], if any. + Future hasUnansweredSpellingWordQuiz(String assignedUserId) async { + final Result result = await _connection.execute( + Sql.named( + ''' + SELECT 1 + FROM questions + WHERE assigned_user_id = @uid + AND user_response IS NULL + AND pipeline_step = 'spelling_word_quiz' + AND ( + source_tag LIKE 'spelling:%' + OR pipeline_key = 'spelling' + ) + LIMIT 1 + ''', + ), + parameters: {'uid': assignedUserId}, + ); + return result.isNotEmpty; + } + + /// Counts unanswered rows whose [resolveSourceId] is in [allowedSourceIds]. + Future countUnansweredForAllowedSources( + String assignedUserId, + Set allowedSourceIds, + ) async { + if (allowedSourceIds.isEmpty) { + return 0; + } + final List> rows = + await listUnansweredQuestions(assignedUserId); + return filterByAllowedSources(rows, allowedSourceIds).length; + } + Future countUnansweredQuestions(String assignedUserId) async { final Result result = await _connection.execute( Sql.named( @@ -488,20 +652,114 @@ class QuestionsDb { ); } + /// Stable client source id derived from pipeline metadata. + static String resolveSourceId(Map question) { + final String? sourceTag = question['sourceTag'] as String?; + if (sourceTag != null && sourceTag.startsWith('spelling:')) { + return 'spelling'; + } + if (sourceTag != null && sourceTag.startsWith('market_history:')) { + return 'market_history'; + } + final String? pipelineKey = question['pipelineKey'] as String?; + if (pipelineKey == 'spelling') { + return 'spelling'; + } + return 'market_history'; + } + + /// UI presentation key for [resolveSourceId]. + static String resolvePresentation(Map question) { + final String? step = question['pipelineStep'] as String?; + final String? sourceTag = question['sourceTag'] as String?; + if (step == 'spelling_follow_up' || sourceTag == 'spelling:follow_up') { + return 'market_glyph_slider'; + } + if (resolveSourceId(question) == 'spelling') { + return 'spelling_word'; + } + return 'market_glyph_slider'; + } + /// Payload sent to the Flutter client over SignalR (excludes correct answer). Map toClientPayload( Map question, { required int unansweredCount, }) { + final Map metadata = Map.from( + question['metadata'] as Map? ?? {}, + ); + final String? spokenWord = metadata['displayed_word'] as String?; + final bool isSpelling = resolveSourceId(question) == 'spelling'; + final bool needsPrepare = isSpelling && + (spokenWord == null || spokenWord.isEmpty) && + metadata['spelling_pending'] == true; + final bool isPreparedSpelling = + isSpelling && + spokenWord != null && + spokenWord.isNotEmpty && + !needsPrepare; + return { 'id': question['id'], 'assignedUserId': question['assignedUserId'], 'text': question['text'], 'sentAt': question['createdAt'], 'unansweredCount': unansweredCount, + 'sourceId': resolveSourceId(question), + 'presentation': resolvePresentation(question), + if (needsPrepare) 'needsPrepare': true, + if (isPreparedSpelling) ...{ + 'spokenWord': spokenWord, + 'audioTitle': question['id'], + }, }; } + /// Fills in a reserved spelling quiz after word selection at tile load. + Future?> updateSpellingQuizQuestion({ + required String questionId, + required String assignedUserId, + required String questionText, + required num correctAnswer, + required Map metadata, + }) async { + final DateTime now = DateTime.now().toUtc(); + final Result result = await _connection.execute( + Sql.named( + ''' + UPDATE questions + SET question_text = @text, + correct_answer = @answer, + metadata = @metadata::jsonb, + modified_at = @now + WHERE id = @id::uuid + AND assigned_user_id = @uid + AND user_response IS NULL + AND ( + pipeline_step = 'spelling_word_quiz' + OR source_tag = 'spelling:word_quiz' + ) + RETURNING id, assigned_user_id, question_text, user_response, correct_answer, + created_at, modified_at, source_tag, pipeline_key, pipeline_step, + metadata, user_response_text + ''', + ), + parameters: { + 'id': questionId, + 'uid': assignedUserId, + 'text': questionText, + 'answer': correctAnswer, + 'metadata': jsonEncode(metadata), + 'now': now, + }, + ); + if (result.isEmpty) { + return null; + } + return _rowFromResult(result.first); + } + Map _rowFromResult(ResultRow row) { final Object idValue = row[0]!; final String id = idValue is String ? idValue : idValue.toString(); @@ -519,6 +777,7 @@ class QuestionsDb { pipelineKey: row.length > 8 ? row[8] as String? : null, pipelineStep: row.length > 9 ? row[9] as String? : null, metadata: _readJsonMap(row.length > 10 ? row[10] : null), + userResponseText: row.length > 11 ? row[11] as String? : null, ); } @@ -663,12 +922,14 @@ class QuestionsDb { String? pipelineKey, String? pipelineStep, Map? metadata, + String? userResponseText, }) { return { 'id': id, 'assignedUserId': assignedUserId, 'text': questionText, 'userResponse': userResponse, + if (userResponseText != null) 'userResponseText': userResponseText, 'correctAnswer': correctAnswer, 'createdAt': createdAt.toIso8601String(), 'modifiedAt': modifiedAt.toIso8601String(), diff --git a/server/lib/spelling/spelling_answer_policy.dart b/server/lib/spelling/spelling_answer_policy.dart new file mode 100644 index 0000000..8daa28a --- /dev/null +++ b/server/lib/spelling/spelling_answer_policy.dart @@ -0,0 +1,6 @@ +/// Grading rules for typed spelling answers. +abstract final class SpellingAnswerPolicy { + static bool matches(String typed, String canonical) { + return typed.trim().toLowerCase() == canonical.trim().toLowerCase(); + } +} diff --git a/server/lib/spelling/spelling_item_record.dart b/server/lib/spelling/spelling_item_record.dart new file mode 100644 index 0000000..02220a4 --- /dev/null +++ b/server/lib/spelling/spelling_item_record.dart @@ -0,0 +1,42 @@ +/// One row from [spelling_items] joined with optional user progress. +class SpellingItemRecord { + const SpellingItemRecord({ + required this.id, + required this.spellingWordId, + required this.canonicalWord, + required this.displayedWord, + required this.isCorrectlySpelled, + required this.correctAnswer, + this.familiarityScore = 0, + this.attemptCount = 0, + }); + + final String id; + final String spellingWordId; + final String canonicalWord; + final String displayedWord; + final bool isCorrectlySpelled; + final num correctAnswer; + final int familiarityScore; + final int attemptCount; + + Map toQuestionMetadata() { + return { + 'spelling_item_id': id, + 'spelling_word_id': spellingWordId, + 'canonical_word': canonicalWord, + 'displayed_word': displayedWord, + 'is_correctly_spelled': isCorrectlySpelled, + }; + } + + Map toPipelineContext() { + return { + 'spellingItemId': id, + 'spellingWordId': spellingWordId, + 'canonicalWord': canonicalWord, + 'displayedWord': displayedWord, + 'isCorrectlySpelled': isCorrectlySpelled, + }; + } +} diff --git a/server/lib/spelling/spelling_item_selector.dart b/server/lib/spelling/spelling_item_selector.dart new file mode 100644 index 0000000..10e3ad4 --- /dev/null +++ b/server/lib/spelling/spelling_item_selector.dart @@ -0,0 +1,56 @@ +import 'dart:math'; + +import 'spelling_item_record.dart'; +import 'spelling_items_db.dart'; +import 'spelling_progress_policy.dart'; + +/// Picks a shared spelling item for a user using familiarity-weighted randomness. +class SpellingItemSelector { + SpellingItemSelector({ + SpellingItemsDb? itemsDb, + Random? random, + }) : _itemsDb = itemsDb, + _random = random ?? Random(); + + final SpellingItemsDb? _itemsDb; + final Random _random; + + Future pickForUser(String firebaseUid) async { + final SpellingItemsDb? itemsDb = _itemsDb; + if (itemsDb == null) { + throw StateError('SpellingItemSelector requires itemsDb for pickForUser'); + } + final List items = + await itemsDb.listActiveWithProgress(firebaseUid); + if (items.isEmpty) { + return null; + } + return _weightedPick(items); + } + + /// Visible for unit tests. + SpellingItemRecord? pickFromCandidates(List items) { + if (items.isEmpty) { + return null; + } + return _weightedPick(items); + } + + SpellingItemRecord _weightedPick(List items) { + final List weights = items + .map( + (SpellingItemRecord item) => + SpellingProgressPolicy.weightForScore(item.familiarityScore), + ) + .toList(growable: false); + final double total = weights.fold(0, (double a, double b) => a + b); + var roll = _random.nextDouble() * total; + for (var i = 0; i < items.length; i++) { + roll -= weights[i]; + if (roll < 0) { + return items[i]; + } + } + return items.last; + } +} diff --git a/server/lib/spelling/spelling_items_db.dart b/server/lib/spelling/spelling_items_db.dart new file mode 100644 index 0000000..0deb741 --- /dev/null +++ b/server/lib/spelling/spelling_items_db.dart @@ -0,0 +1,250 @@ +import 'package:postgres/postgres.dart'; + +import '../pipeline/spelling_word_list.dart'; +import 'spelling_item_record.dart'; + +/// Shared spelling catalog ([spelling_words], [spelling_items]). +class SpellingItemsDb { + SpellingItemsDb(this._connection); + + final Connection _connection; + + /// Inserts curriculum from [SpellingWordList] when the catalog is empty. + Future ensureCatalogSeeded() async { + final Result count = await _connection.execute( + 'SELECT COUNT(*)::int FROM spelling_words', + ); + if ((count.first[0]! as num).toInt() > 0) { + return; + } + + for (final ({String correct, String misspelled}) pair + in SpellingWordList.pairs) { + final Result wordRow = await _connection.execute( + Sql.named( + ''' + INSERT INTO spelling_words (canonical_word) + VALUES (@canonical) + ON CONFLICT (canonical_word) DO UPDATE SET canonical_word = EXCLUDED.canonical_word + RETURNING id + ''', + ), + parameters: {'canonical': pair.correct}, + ); + final String wordId = wordRow.first[0]!.toString(); + + await _insertItem( + spellingWordId: wordId, + displayedWord: pair.correct, + isCorrectlySpelled: true, + correctAnswer: 10, + ); + await _insertItem( + spellingWordId: wordId, + displayedWord: pair.misspelled, + isCorrectlySpelled: false, + correctAnswer: -10, + ); + } + } + + Future _insertItem({ + required String spellingWordId, + required String displayedWord, + required bool isCorrectlySpelled, + required num correctAnswer, + }) async { + await _connection.execute( + Sql.named( + ''' + INSERT INTO spelling_items ( + spelling_word_id, displayed_word, is_correctly_spelled, correct_answer + ) VALUES ( + @word_id::uuid, @displayed, @is_correct, @answer + ) + ON CONFLICT (spelling_word_id, displayed_word) DO NOTHING + ''', + ), + parameters: { + 'word_id': spellingWordId, + 'displayed': displayedWord, + 'is_correct': isCorrectlySpelled, + 'answer': correctAnswer, + }, + ); + } + + /// Active catalog rows with per-user familiarity (0 when unseen). + Future> listActiveWithProgress( + String firebaseUid, + ) async { + final Result result = await _connection.execute( + Sql.named( + ''' + SELECT + si.id, + si.spelling_word_id, + sw.canonical_word, + si.displayed_word, + si.is_correctly_spelled, + si.correct_answer, + COALESCE(usp.familiarity_score, 0), + COALESCE(usp.attempt_count, 0) + FROM spelling_items si + INNER JOIN spelling_words sw ON sw.id = si.spelling_word_id + LEFT JOIN user_spelling_progress usp + ON usp.spelling_item_id = si.id + AND usp.firebase_uid = @uid + WHERE si.active = true AND sw.active = true + ORDER BY sw.canonical_word, si.displayed_word + ''', + ), + parameters: {'uid': firebaseUid}, + ); + + return result + .map( + (ResultRow row) => SpellingItemRecord( + id: row[0]!.toString(), + spellingWordId: row[1]!.toString(), + canonicalWord: row[2]! as String, + displayedWord: row[3]! as String, + isCorrectlySpelled: row[4]! as bool, + correctAnswer: _readNumeric(row[5]), + familiarityScore: (row[6]! as num).toInt(), + attemptCount: (row[7]! as num).toInt(), + ), + ) + .toList(growable: false); + } + + /// Canonical words in the shared catalog (no user history). + Future> listCanonicalWords() async { + final Result result = await _connection.execute( + ''' + SELECT canonical_word + FROM spelling_words + WHERE active = true + ORDER BY canonical_word ASC + ''', + ); + return result + .map((ResultRow row) => row[0]! as String) + .toList(growable: false); + } + + /// Adds a word and quiz items when absent; returns false if already present. + Future addCanonicalWord(String rawWord) async { + final String canonical = _normalizeCanonical(rawWord); + if (canonical.isEmpty) { + return false; + } + + final Result existing = await _connection.execute( + Sql.named( + ''' + SELECT id FROM spelling_words + WHERE canonical_word = @canonical + ''', + ), + parameters: {'canonical': canonical}, + ); + if (existing.isNotEmpty) { + return false; + } + + final Result wordRow = await _connection.execute( + Sql.named( + ''' + INSERT INTO spelling_words (canonical_word) + VALUES (@canonical) + RETURNING id + ''', + ), + parameters: {'canonical': canonical}, + ); + final String wordId = wordRow.first[0]!.toString(); + final String misspelled = SpellingWordList.generatedMisspelling(canonical); + await _insertItem( + spellingWordId: wordId, + displayedWord: canonical, + isCorrectlySpelled: true, + correctAnswer: 10, + ); + if (misspelled != canonical) { + await _insertItem( + spellingWordId: wordId, + displayedWord: misspelled, + isCorrectlySpelled: false, + correctAnswer: -10, + ); + } + return true; + } + + /// One line per word; skips blanks and duplicates. + Future<({int added, int skipped})> importCanonicalWords( + Iterable lines, + ) async { + var added = 0; + var skipped = 0; + for (final String line in lines) { + final String canonical = _normalizeCanonical(line); + if (canonical.isEmpty) { + continue; + } + if (await addCanonicalWord(canonical)) { + added++; + } else { + skipped++; + } + } + return (added: added, skipped: skipped); + } + + static String _normalizeCanonical(String raw) => + raw.trim().toLowerCase(); + + Future findById(String spellingItemId) async { + final Result result = await _connection.execute( + Sql.named( + ''' + SELECT + si.id, + si.spelling_word_id, + sw.canonical_word, + si.displayed_word, + si.is_correctly_spelled, + si.correct_answer + FROM spelling_items si + INNER JOIN spelling_words sw ON sw.id = si.spelling_word_id + WHERE si.id = @id::uuid + LIMIT 1 + ''', + ), + parameters: {'id': spellingItemId}, + ); + if (result.isEmpty) { + return null; + } + final ResultRow row = result.first; + return SpellingItemRecord( + id: row[0]!.toString(), + spellingWordId: row[1]!.toString(), + canonicalWord: row[2]! as String, + displayedWord: row[3]! as String, + isCorrectlySpelled: row[4]! as bool, + correctAnswer: _readNumeric(row[5]), + ); + } + + static num _readNumeric(Object? value) { + if (value is num) { + return value; + } + if (value is String) { + return num.parse(value); + } + throw ArgumentError('Expected numeric value, got $value'); + } +} diff --git a/server/lib/spelling/spelling_progress_policy.dart b/server/lib/spelling/spelling_progress_policy.dart new file mode 100644 index 0000000..449277b --- /dev/null +++ b/server/lib/spelling/spelling_progress_policy.dart @@ -0,0 +1,38 @@ +import 'dart:math'; + +/// Tunables for spelling familiarity scoring and weighted selection. +abstract final class SpellingProgressPolicy { + /// Added to [familiarity_score] when the user answers the quiz correctly. + static const int correctDelta = 1; + + /// Subtracted from [familiarity_score] when the user answers incorrectly. + static const int incorrectDelta = 1; + + /// Base weight before familiarity adjustment. + static const double baseWeight = 1.0; + + /// Weight reduction per point of positive familiarity (mastered words). + static const double penaltyPerPoint = 0.15; + + /// Minimum selection weight — never zero chance. + static const double minWeight = 0.05; + + /// Selection weight for a catalog item given the user's familiarity score. + static double weightForScore(int familiarityScore) { + return max( + minWeight, + baseWeight - familiarityScore * penaltyPerPoint, + ); + } + + /// Applies a graded quiz outcome to the stored familiarity score. + static int scoreAfterAttempt({ + required int currentScore, + required bool correct, + }) { + if (correct) { + return currentScore + correctDelta; + } + return currentScore - incorrectDelta; + } +} diff --git a/server/lib/spelling/spelling_question_links_db.dart b/server/lib/spelling/spelling_question_links_db.dart new file mode 100644 index 0000000..f060533 --- /dev/null +++ b/server/lib/spelling/spelling_question_links_db.dart @@ -0,0 +1,48 @@ +import 'package:postgres/postgres.dart'; + +/// Per-question link between a user, quiz row, and shared spelling item. +class SpellingQuestionLinksDb { + SpellingQuestionLinksDb(this._connection); + + final Connection _connection; + + /// Creates a link when the spelling tile loads; idempotent per [questionId]. + Future linkQuestion({ + required String questionId, + required String firebaseUid, + required String spellingItemId, + }) async { + await _connection.execute( + Sql.named( + ''' + INSERT INTO spelling_question_links ( + question_id, firebase_uid, spelling_item_id + ) VALUES ( + @question_id::uuid, @uid, @item_id::uuid + ) + ON CONFLICT (question_id) DO NOTHING + ''', + ), + parameters: { + 'question_id': questionId, + 'uid': firebaseUid, + 'item_id': spellingItemId, + }, + ); + } + + Future hasLink(String questionId) async { + final Result result = await _connection.execute( + Sql.named( + ''' + SELECT 1 + FROM spelling_question_links + WHERE question_id = @question_id::uuid + LIMIT 1 + ''', + ), + parameters: {'question_id': questionId}, + ); + return result.isNotEmpty; + } +} diff --git a/server/lib/spelling/user_spelling_progress_db.dart b/server/lib/spelling/user_spelling_progress_db.dart new file mode 100644 index 0000000..4a75443 --- /dev/null +++ b/server/lib/spelling/user_spelling_progress_db.dart @@ -0,0 +1,114 @@ +import 'package:postgres/postgres.dart'; + +import 'spelling_progress_policy.dart'; + +/// Per-user familiarity with shared [spelling_items]. +class UserSpellingProgressDb { + UserSpellingProgressDb(this._connection); + + final Connection _connection; + + /// Ensures a per-user link to [spellingItemId] with scores starting at zero. + Future ensureLink({ + required String firebaseUid, + required String spellingItemId, + }) async { + await _connection.execute( + Sql.named( + ''' + INSERT INTO user_spelling_progress ( + firebase_uid, + spelling_item_id, + familiarity_score, + attempt_count, + correct_count + ) VALUES ( + @uid, + @item_id::uuid, + 0, + 0, + 0 + ) + ON CONFLICT (firebase_uid, spelling_item_id) DO NOTHING + ''', + ), + parameters: { + 'uid': firebaseUid, + 'item_id': spellingItemId, + }, + ); + } + + Future recordQuizOutcome({ + required String firebaseUid, + required String spellingItemId, + required bool correct, + }) async { + final DateTime now = DateTime.now().toUtc(); + final String outcome = correct ? 'correct' : 'incorrect'; + final int delta = correct + ? SpellingProgressPolicy.correctDelta + : -SpellingProgressPolicy.incorrectDelta; + + await _connection.execute( + Sql.named( + ''' + INSERT INTO user_spelling_progress ( + firebase_uid, + spelling_item_id, + familiarity_score, + attempt_count, + correct_count, + last_attempted_at, + last_outcome + ) VALUES ( + @uid, + @item_id::uuid, + @delta, + 1, + @correct_inc, + @now, + @outcome + ) + ON CONFLICT (firebase_uid, spelling_item_id) DO UPDATE SET + familiarity_score = user_spelling_progress.familiarity_score + @delta, + attempt_count = user_spelling_progress.attempt_count + 1, + correct_count = user_spelling_progress.correct_count + @correct_inc, + last_attempted_at = @now, + last_outcome = @outcome + ''', + ), + parameters: { + 'uid': firebaseUid, + 'item_id': spellingItemId, + 'delta': delta, + 'correct_inc': correct ? 1 : 0, + 'now': now, + 'outcome': outcome, + }, + ); + } + + Future familiarityScore({ + required String firebaseUid, + required String spellingItemId, + }) async { + final Result result = await _connection.execute( + Sql.named( + ''' + SELECT familiarity_score + FROM user_spelling_progress + WHERE firebase_uid = @uid AND spelling_item_id = @item_id::uuid + ''', + ), + parameters: { + 'uid': firebaseUid, + 'item_id': spellingItemId, + }, + ); + if (result.isEmpty) { + return null; + } + return (result.first[0]! as num).toInt(); + } +} diff --git a/server/migrations/018_question_source_preferences.sql b/server/migrations/018_question_source_preferences.sql new file mode 100644 index 0000000..9b76552 --- /dev/null +++ b/server/migrations/018_question_source_preferences.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + ADD COLUMN IF NOT EXISTS question_source_preferences JSONB; diff --git a/server/migrations/019_spelling_catalog.sql b/server/migrations/019_spelling_catalog.sql new file mode 100644 index 0000000..8e617b2 --- /dev/null +++ b/server/migrations/019_spelling_catalog.sql @@ -0,0 +1,35 @@ +-- Shared spelling curriculum and per-user familiarity tracking. + +CREATE TABLE IF NOT EXISTS spelling_words ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + canonical_word TEXT NOT NULL UNIQUE, + active BOOLEAN NOT NULL DEFAULT true +); + +CREATE TABLE IF NOT EXISTS spelling_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + spelling_word_id UUID NOT NULL REFERENCES spelling_words (id) ON DELETE CASCADE, + displayed_word TEXT NOT NULL, + is_correctly_spelled BOOLEAN NOT NULL, + correct_answer NUMERIC NOT NULL, + active BOOLEAN NOT NULL DEFAULT true, + UNIQUE (spelling_word_id, displayed_word) +); + +CREATE INDEX IF NOT EXISTS spelling_items_active_idx + ON spelling_items (spelling_word_id) + WHERE active = true; + +CREATE TABLE IF NOT EXISTS user_spelling_progress ( + firebase_uid TEXT NOT NULL REFERENCES users (firebase_uid) ON DELETE CASCADE, + spelling_item_id UUID NOT NULL REFERENCES spelling_items (id) ON DELETE CASCADE, + familiarity_score INTEGER NOT NULL DEFAULT 0, + attempt_count INTEGER NOT NULL DEFAULT 0, + correct_count INTEGER NOT NULL DEFAULT 0, + last_attempted_at TIMESTAMPTZ, + last_outcome TEXT, + PRIMARY KEY (firebase_uid, spelling_item_id) +); + +CREATE INDEX IF NOT EXISTS user_spelling_progress_uid_idx + ON user_spelling_progress (firebase_uid); diff --git a/server/migrations/020_spelling_question_links.sql b/server/migrations/020_spelling_question_links.sql new file mode 100644 index 0000000..f5d1ade --- /dev/null +++ b/server/migrations/020_spelling_question_links.sql @@ -0,0 +1,14 @@ +-- Links a spelling quiz question row to the selected catalog item at tile load. + +CREATE TABLE IF NOT EXISTS spelling_question_links ( + question_id UUID PRIMARY KEY REFERENCES questions (id) ON DELETE CASCADE, + firebase_uid TEXT NOT NULL REFERENCES users (firebase_uid) ON DELETE CASCADE, + spelling_item_id UUID NOT NULL REFERENCES spelling_items (id) ON DELETE CASCADE, + linked_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS spelling_question_links_uid_idx + ON spelling_question_links (firebase_uid); + +CREATE INDEX IF NOT EXISTS spelling_question_links_item_idx + ON spelling_question_links (spelling_item_id); diff --git a/server/migrations/021_questions_user_response_text.sql b/server/migrations/021_questions_user_response_text.sql new file mode 100644 index 0000000..6d4b34c --- /dev/null +++ b/server/migrations/021_questions_user_response_text.sql @@ -0,0 +1,4 @@ +-- Text answers for spelling quizzes (typed spellings). + +ALTER TABLE questions + ADD COLUMN IF NOT EXISTS user_response_text TEXT; diff --git a/server/test/helpers/test_db.dart b/server/test/helpers/test_db.dart index 5ff1412..d5f0ea1 100644 --- a/server/test/helpers/test_db.dart +++ b/server/test/helpers/test_db.dart @@ -1,8 +1,12 @@ import 'dart:io'; import 'package:cyberhybridhub_server/db.dart'; +import 'package:cyberhybridhub_server/question_source_preferences_db.dart'; +import 'package:cyberhybridhub_server/pipeline/question_pipeline.dart'; +import 'package:cyberhybridhub_server/question_dispatcher.dart'; import 'package:cyberhybridhub_server/question_service.dart'; import 'package:cyberhybridhub_server/questions_db.dart'; +import 'package:cyberhybridhub_server/spelling/spelling_items_db.dart'; import 'package:cyberhybridhub_server/signalr/questions_hub_connections.dart'; import 'package:cyberhybridhub_server/trading/market_data_db.dart'; import 'package:cyberhybridhub_server/trading/trade_orders_db.dart'; @@ -11,7 +15,7 @@ import 'package:cyberhybridhub_server/trading/user_trading_state_db.dart'; import 'package:dotenv/dotenv.dart'; import 'package:postgres/postgres.dart'; -/// Integration test Postgres: [cyberhybridhub_test] with migrations 001–017. +/// Integration test Postgres: [cyberhybridhub_test] with migrations 001–018. class TestDb { TestDb._(this.db, this._connection, this.databaseUrl); @@ -52,6 +56,7 @@ class TestDb { } await profileDb.migrate(); + await SpellingItemsDb(profileDb.connection).ensureCatalogSeeded(); return TestDb._(profileDb, profileDb.connection, testUrl); } @@ -113,12 +118,38 @@ class TestDb { QuestionsDb get questionsDb => QuestionsDb(_connection); + QuestionSourcePreferencesDb get questionSourcePreferencesDb => + QuestionSourcePreferencesDb(_connection); + + SpellingItemsDb get spellingItemsDb => SpellingItemsDb(_connection); + /// QuestionService backed by a no-op hub (no live SignalR clients in tests). QuestionService questionService() => QuestionService( questionsDb: questionsDb, hubConnections: QuestionsHubConnections(), + questionSourcePreferencesDb: questionSourcePreferencesDb, ); + QuestionPipeline questionPipeline({bool testMode = true}) => QuestionPipeline( + questionsDb: questionsDb, + questionService: questionService(), + questionSourcePreferencesDb: questionSourcePreferencesDb, + spellingItemsDb: spellingItemsDb, + testMode: testMode, + ); + + QuestionDispatcher questionDispatcher({bool testMode = true}) { + final QuestionPipeline pipeline = questionPipeline(testMode: testMode); + final QuestionDispatcher dispatcher = QuestionDispatcher( + questionsDb: questionsDb, + questionService: questionService(), + questionPipeline: pipeline, + preferencesDb: questionSourcePreferencesDb, + ); + pipeline.attachQuestionDispatcher(dispatcher); + return dispatcher; + } + Future truncateTradingTables() async { await _connection.execute( ''' diff --git a/server/test/integration/market_history_prospective_questions_test.dart b/server/test/integration/market_history_prospective_questions_test.dart index b07de07..df63b53 100644 --- a/server/test/integration/market_history_prospective_questions_test.dart +++ b/server/test/integration/market_history_prospective_questions_test.dart @@ -1030,9 +1030,12 @@ void main() { ); } - final QuestionService service = testDb!.questionService(); - final Map? payload = await service.bootstrapOnLogin(uid); + await testDb!.questionService().bootstrapOnLogin(uid); + final Map? payload = + await testDb!.questionDispatcher().dispatchNextQuestion(uid); expect(payload, isNotNull); + expect(payload!['sourceId'], 'market_history'); + expect(payload['presentation'], 'market_glyph_slider'); final List> queued = await testDb!.questionsDb.listUnansweredQuestions(uid); diff --git a/server/test/integration/question_dispatcher_mix_test.dart b/server/test/integration/question_dispatcher_mix_test.dart new file mode 100644 index 0000000..86c1288 --- /dev/null +++ b/server/test/integration/question_dispatcher_mix_test.dart @@ -0,0 +1,125 @@ +@Tags(['integration', 'postgres']) +library; + +import 'package:cyberhybridhub_server/pipeline/question_pipeline.dart'; +import 'package:cyberhybridhub_server/question_dispatcher.dart'; +import 'package:postgres/postgres.dart'; +import 'package:test/test.dart'; + +import '../helpers/test_db.dart'; + +void main() { + TestDb? testDb; + + setUpAll(() async { + testDb = await TestDb.open(); + }); + + tearDown(() async { + if (testDb != null) { + await testDb!.truncateTradingTables(); + } + }); + + tearDownAll(() async { + await testDb?.close(); + }); + + test('disabled source is never dispatched', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'dispatcher-spelling-only'; + await testDb!.seedUser(uid); + await testDb!.questionSourcePreferencesDb.save( + uid, + >[ + {'id': 'market_history', 'enabled': false, 'ratio': 0}, + {'id': 'spelling', 'enabled': true, 'ratio': 100}, + ], + ); + + final QuestionDispatcher dispatcher = testDb!.questionDispatcher(); + final Map? payload = + await dispatcher.dispatchNextQuestion(uid); + + expect(payload, isNotNull); + expect(payload!['sourceId'], 'spelling'); + expect(payload['presentation'], 'spelling_word'); + }); + + test('70/30 mix yields spelling and respects fall-through over cycles', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'dispatcher-mix-uid'; + await testDb!.seedUser(uid); + await testDb!.questionSourcePreferencesDb.save( + uid, + >[ + {'id': 'market_history', 'enabled': true, 'ratio': 70}, + {'id': 'spelling', 'enabled': true, 'ratio': 30}, + ], + ); + + final QuestionDispatcher dispatcher = testDb!.questionDispatcher( + testMode: true, + ); + + final Set seenSources = {}; + for (var cycle = 0; cycle < 16 && seenSources.length < 2; cycle++) { + final Map? payload = + await dispatcher.dispatchNextQuestion(uid); + if (payload == null) { + continue; + } + seenSources.add(payload['sourceId'] as String); + + final Result rows = await testDb!.connection.execute( + Sql.named( + ''' + SELECT id, pipeline_key + FROM questions + WHERE assigned_user_id = @uid AND user_response IS NULL + ORDER BY created_at + LIMIT 1 + ''', + ), + parameters: {'uid': uid}, + ); + if (rows.isEmpty) { + break; + } + final String questionId = rows.first[0]!.toString(); + final String? pipelineKey = rows.first[1] as String?; + await testDb!.questionsDb.submitAnswer( + questionId: questionId, + assignedUserId: uid, + userResponse: 0, + ); + if (pipelineKey == PipelineKeys.spelling) { + await testDb!.connection.execute( + Sql.named( + ''' + UPDATE user_pipeline_state + SET step = @idle + WHERE assigned_user_id = @uid AND pipeline_key = @key + ''', + ), + parameters: { + 'uid': uid, + 'key': PipelineKeys.spelling, + 'idle': PipelineSteps.idle, + }, + ); + } + } + + expect(seenSources, contains('spelling')); + expect(seenSources.length, greaterThanOrEqualTo(1)); + }); +} diff --git a/server/test/integration/question_source_preferences_test.dart b/server/test/integration/question_source_preferences_test.dart new file mode 100644 index 0000000..72ef222 --- /dev/null +++ b/server/test/integration/question_source_preferences_test.dart @@ -0,0 +1,186 @@ +@Tags(['integration', 'postgres']) +library; + +import 'dart:convert'; + +import 'package:cyberhybridhub_server/firebase_auth.dart'; +import 'package:cyberhybridhub_server/handlers/question_source_handler.dart'; +import 'package:cyberhybridhub_server/question_source_preferences_db.dart'; +import 'package:cyberhybridhub_server/questions_db.dart'; +import 'package:postgres/postgres.dart'; +import 'package:shelf/shelf.dart'; +import 'package:test/test.dart'; + +import '../helpers/test_db.dart'; + +class _FakeAuthVerifier extends FirebaseAuthVerifier { + _FakeAuthVerifier() : super('test-key'); + + @override + Future verifyBearerToken(String? authorization) async { + if (authorization == null || !authorization.startsWith('Bearer ')) { + return null; + } + final String token = authorization.substring('Bearer '.length).trim(); + return token.isEmpty ? null : token; + } +} + +Future _get( + Handler handler, { + required String path, + String? bearer, +}) { + return Future.value( + handler( + Request( + 'GET', + Uri.parse('http://localhost$path'), + headers: { + if (bearer != null) 'Authorization': 'Bearer $bearer', + }, + ), + ), + ); +} + +Future _put( + Handler handler, { + required String path, + required Map body, + String? bearer, +}) { + return Future.value( + handler( + Request( + 'PUT', + Uri.parse('http://localhost$path'), + body: jsonEncode(body), + headers: { + if (bearer != null) 'Authorization': 'Bearer $bearer', + 'Content-Type': 'application/json', + }, + ), + ), + ); +} + +void main() { + TestDb? testDb; + late Handler handler; + late QuestionSourcePreferencesDb preferencesDb; + + setUpAll(() async { + testDb = await TestDb.open(); + if (testDb == null) { + return; + } + preferencesDb = testDb!.questionSourcePreferencesDb; + handler = questionSourceHandler( + db: preferencesDb, + auth: _FakeAuthVerifier(), + ); + }); + + tearDown(() async { + if (testDb != null) { + await testDb!.truncateTradingTables(); + } + }); + + tearDownAll(() async { + await testDb?.close(); + }); + + test('GET returns defaults for unknown user', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + final Response response = await _get( + handler, + path: questionSourcesBasePath, + bearer: 'prefs-user', + ); + + expect(response.statusCode, 200); + final Map body = + jsonDecode(await response.readAsString()) as Map; + final List sources = body['sources'] as List; + expect(sources.length, 2); + expect(sources.first['id'], 'market_history'); + }); + + test('PUT persists preferences and GET returns saved mix', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'prefs-save-user'; + await testDb!.seedUser(uid); + + final Response putResponse = await _put( + handler, + path: questionSourcesBasePath, + bearer: uid, + body: { + 'sources': >[ + {'id': 'market_history', 'enabled': true, 'ratio': 80}, + {'id': 'spelling', 'enabled': false, 'ratio': 20}, + ], + }, + ); + expect(putResponse.statusCode, 200); + + final Response getResponse = await _get( + handler, + path: questionSourcesBasePath, + bearer: uid, + ); + expect(getResponse.statusCode, 200); + final Map body = + jsonDecode(await getResponse.readAsString()) as Map; + final List sources = body['sources'] as List; + final Map market = sources.firstWhere( + (dynamic source) => source['id'] == 'market_history', + ) as Map; + expect(market['ratio'], 80); + }); + + test('ensureProspectiveQuestionQueued skips batch when market_history disabled', + () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'prefs-disabled-market-user'; + await testDb!.seedUser(uid); + await preferencesDb.save( + uid, + >[ + {'id': 'market_history', 'enabled': false, 'ratio': 50}, + {'id': 'spelling', 'enabled': false, 'ratio': 50}, + ], + ); + + final QuestionsDb questionsDb = testDb!.questionsDb; + final service = testDb!.questionService(); + await questionsDb.ensureUserExists(uid); + await service.ensureProspectiveQuestionQueued(uid); + + final Result rows = await testDb!.connection.execute( + Sql.named( + ''' + SELECT COUNT(*)::int + FROM questions + WHERE assigned_user_id = @uid + ''', + ), + parameters: {'uid': uid}, + ); + expect(rows.first[0], 0); + }); +} diff --git a/server/test/integration/spelling_catalog_test.dart b/server/test/integration/spelling_catalog_test.dart new file mode 100644 index 0000000..1b6a141 --- /dev/null +++ b/server/test/integration/spelling_catalog_test.dart @@ -0,0 +1,209 @@ +@Tags(['integration', 'postgres']) +library; + +import 'dart:convert'; +import 'dart:math'; + +import 'package:cyberhybridhub_server/pipeline/question_pipeline.dart'; +import 'package:cyberhybridhub_server/spelling/spelling_item_selector.dart'; +import 'package:cyberhybridhub_server/spelling/spelling_items_db.dart'; +import 'package:cyberhybridhub_server/spelling/user_spelling_progress_db.dart'; +import 'package:postgres/postgres.dart'; +import 'package:test/test.dart'; + +import '../helpers/test_db.dart'; + +void main() { + TestDb? testDb; + + setUpAll(() async { + testDb = await TestDb.open(); + }); + + tearDown(() async { + if (testDb != null) { + await testDb!.truncateTradingTables(); + } + }); + + tearDownAll(() async { + await testDb?.close(); + }); + + test('catalog seeds two items per curated word', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + final Result words = await testDb!.connection.execute( + 'SELECT COUNT(*)::int FROM spelling_words', + ); + final Result items = await testDb!.connection.execute( + 'SELECT COUNT(*)::int FROM spelling_items', + ); + expect((words.first[0]! as num).toInt(), 10); + expect((items.first[0]! as num).toInt(), 20); + }); + + test('wrong answer increases familiarity weight for next pick', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'spelling-weight-uid'; + await testDb!.seedUser(uid); + + final SpellingItemsDb itemsDb = testDb!.spellingItemsDb; + final UserSpellingProgressDb progressDb = + UserSpellingProgressDb(testDb!.connection); + final SpellingItemSelector selector = SpellingItemSelector( + itemsDb: itemsDb, + random: Random(1), + ); + + final picked = await selector.pickForUser(uid); + expect(picked, isNotNull); + + await progressDb.recordQuizOutcome( + firebaseUid: uid, + spellingItemId: picked!.id, + correct: false, + ); + + final int? score = await progressDb.familiarityScore( + firebaseUid: uid, + spellingItemId: picked.id, + ); + expect(score, -1); + + final QuestionPipeline pipeline = testDb!.questionPipeline(); + await pipeline.maintainSpellingForUser(uid); + + final Result reserved = await testDb!.connection.execute( + Sql.named( + ''' + SELECT id + FROM questions + WHERE assigned_user_id = @uid + ORDER BY created_at DESC + LIMIT 1 + ''', + ), + parameters: {'uid': uid}, + ); + await pipeline.prepareSpellingQuestion( + firebaseUid: uid, + questionId: reserved.first[0]!.toString(), + ); + + final Result rows = await testDb!.connection.execute( + Sql.named( + ''' + SELECT metadata + FROM questions + WHERE assigned_user_id = @uid + ORDER BY created_at DESC + LIMIT 1 + ''', + ), + parameters: {'uid': uid}, + ); + final Object? metadataRaw = rows.first[0]; + final Map metadata = metadataRaw is Map + ? metadataRaw + : Map.from( + jsonDecode(metadataRaw.toString()) as Map, + ); + expect(metadata['spelling_item_id'], isNotEmpty); + }); + + test('quiz answer updates user_spelling_progress', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'spelling-progress-uid'; + await testDb!.seedUser(uid); + + final QuestionPipeline pipeline = testDb!.questionPipeline(); + await pipeline.maintainSpellingForUser(uid); + + final Result reserved = await testDb!.connection.execute( + Sql.named( + ''' + SELECT id + FROM questions + WHERE assigned_user_id = @uid + ORDER BY created_at + LIMIT 1 + ''', + ), + parameters: {'uid': uid}, + ); + await pipeline.prepareSpellingQuestion( + firebaseUid: uid, + questionId: reserved.first[0]!.toString(), + ); + + final Result first = await testDb!.connection.execute( + Sql.named( + ''' + SELECT id, correct_answer, metadata + FROM questions + WHERE assigned_user_id = @uid + ORDER BY created_at + LIMIT 1 + ''', + ), + parameters: {'uid': uid}, + ); + final String questionId = first.first[0]!.toString(); + final Map metadata = _readMetadata(first.first[2]); + final String itemId = metadata['spelling_item_id']! as String; + final String canonicalWord = metadata['canonical_word']! as String; + + final Map? answered = await testDb!.questionsDb.submitAnswer( + questionId: questionId, + assignedUserId: uid, + userResponseText: canonicalWord, + ); + expect(answered, isNotNull); + + await pipeline.onAnswerSubmitted( + firebaseUid: uid, + answeredQuestion: answered!, + userResponse: answered['userResponse'] as num? ?? 0, + ); + + final Result progress = await testDb!.connection.execute( + Sql.named( + ''' + SELECT familiarity_score, attempt_count, correct_count, last_outcome + FROM user_spelling_progress + WHERE firebase_uid = @uid AND spelling_item_id = @item_id::uuid + ''', + ), + parameters: {'uid': uid, 'item_id': itemId}, + ); + expect(progress, hasLength(1)); + expect(progress.first[0], 1); + expect(progress.first[1], 1); + expect(progress.first[2], 1); + expect(progress.first[3], 'correct'); + }); +} + +Map _readMetadata(Object? raw) { + if (raw is Map) { + return raw; + } + if (raw is Map) { + return Map.from(raw); + } + return Map.from( + jsonDecode(raw.toString()) as Map, + ); +} diff --git a/server/test/integration/spelling_only_panel_test.dart b/server/test/integration/spelling_only_panel_test.dart new file mode 100644 index 0000000..1888802 --- /dev/null +++ b/server/test/integration/spelling_only_panel_test.dart @@ -0,0 +1,62 @@ +@Tags(['integration', 'postgres']) +library; + +import 'package:cyberhybridhub_server/question_dispatcher.dart'; +import 'package:cyberhybridhub_server/questions_db.dart'; +import 'package:test/test.dart'; + +import '../helpers/test_db.dart'; + +void main() { + TestDb? testDb; + + setUpAll(() async { + testDb = await TestDb.open(); + }); + + tearDown(() async { + if (testDb != null) { + await testDb!.truncateTradingTables(); + } + }); + + tearDownAll(() async { + await testDb?.close(); + }); + + test('listPanelQuestions excludes market when market_history disabled', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'spelling-only-panel-uid'; + await testDb!.seedUser(uid); + await testDb!.questionSourcePreferencesDb.save( + uid, + >[ + {'id': 'market_history', 'enabled': false, 'ratio': 0}, + {'id': 'spelling', 'enabled': true, 'ratio': 100}, + ], + ); + + await testDb!.questionsDb.createQuestion( + assignedUserId: uid, + questionText: 'Stale market question', + correctAnswer: 10, + sourceTag: 'market_history:prospective', + pipelineKey: 'trading', + ); + + final QuestionDispatcher dispatcher = testDb!.questionDispatcher(); + final List> panel = + await dispatcher.listPanelQuestions(uid); + + expect(panel, isNotEmpty); + for (final Map payload in panel) { + expect(payload['sourceId'], isNot('market_history')); + } + expect(panel.first['sourceId'], 'spelling'); + expect(panel.first['presentation'], 'spelling_word'); + }); +} diff --git a/server/test/integration/spelling_pipeline_test.dart b/server/test/integration/spelling_pipeline_test.dart new file mode 100644 index 0000000..089e6db --- /dev/null +++ b/server/test/integration/spelling_pipeline_test.dart @@ -0,0 +1,253 @@ +@Tags(['integration', 'postgres']) +library; + +import 'dart:convert'; + +import 'package:cyberhybridhub_server/pipeline/question_pipeline.dart'; +import 'package:postgres/postgres.dart'; +import 'package:test/test.dart'; + +import '../helpers/test_db.dart'; + +void main() { + TestDb? testDb; + + setUpAll(() async { + testDb = await TestDb.open(); + }); + + tearDown(() async { + if (testDb != null) { + await testDb!.truncateTradingTables(); + } + }); + + tearDownAll(() async { + await testDb?.close(); + }); + + Future _pipeline() async { + return QuestionPipeline( + questionsDb: testDb!.questionsDb, + questionService: testDb!.questionService(), + questionSourcePreferencesDb: testDb!.questionSourcePreferencesDb, + testMode: true, + ); + } + + test('maintainSpellingForUser creates spelling pipeline question', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'spelling-maintain-uid'; + await testDb!.seedUser(uid); + + final QuestionPipeline pipeline = await _pipeline(); + await pipeline.maintainSpellingForUser(uid); + + final Result rows = await testDb!.connection.execute( + Sql.named( + ''' + SELECT pipeline_key, pipeline_step, source_tag + FROM questions + WHERE assigned_user_id = @uid + ''', + ), + parameters: {'uid': uid}, + ); + expect(rows, hasLength(1)); + expect(rows.first[0], PipelineKeys.spelling); + expect(rows.first[1], PipelineSteps.spellingWordQuiz); + expect(rows.first[2], 'spelling:word_quiz'); + + final Result meta = await testDb!.connection.execute( + Sql.named( + ''' + SELECT metadata->>'spelling_item_id' + FROM questions + WHERE assigned_user_id = @uid + ''', + ), + parameters: {'uid': uid}, + ); + expect(meta.first[0], isNull); + }); + + test('prepareSpellingQuestion selects word and creates links at tile load', + () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'spelling-prepare-uid'; + await testDb!.seedUser(uid); + + final QuestionPipeline pipeline = await _pipeline(); + await pipeline.maintainSpellingForUser(uid); + + final Result reserved = await testDb!.connection.execute( + Sql.named( + ''' + SELECT id, metadata + FROM questions + WHERE assigned_user_id = @uid + ''', + ), + parameters: {'uid': uid}, + ); + final String questionId = reserved.first[0]!.toString(); + + final Map? prepared = await pipeline.prepareSpellingQuestion( + firebaseUid: uid, + questionId: questionId, + ); + expect(prepared, isNotNull); + expect(prepared!['spokenWord'], isNotEmpty); + expect(prepared['audioTitle'], questionId); + + final Result link = await testDb!.connection.execute( + Sql.named( + ''' + SELECT spelling_item_id + FROM spelling_question_links + WHERE question_id = @qid::uuid + ''', + ), + parameters: {'qid': questionId}, + ); + expect(link, hasLength(1)); + + final Result progress = await testDb!.connection.execute( + Sql.named( + ''' + SELECT familiarity_score, attempt_count + FROM user_spelling_progress + WHERE firebase_uid = @uid + AND spelling_item_id = @item_id::uuid + ''', + ), + parameters: { + 'uid': uid, + 'item_id': link.first[0]!.toString(), + }, + ); + expect(progress, hasLength(1)); + expect(progress.first[0], 0); + expect(progress.first[1], 0); + }); + + test('maintainSpellingForUser skips when spelling source disabled', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'spelling-disabled-uid'; + await testDb!.seedUser(uid); + await testDb!.questionSourcePreferencesDb.save( + uid, + >[ + {'id': 'market_history', 'enabled': true, 'ratio': 50}, + {'id': 'spelling', 'enabled': false, 'ratio': 50}, + ], + ); + + final QuestionPipeline pipeline = await _pipeline(); + await pipeline.maintainSpellingForUser(uid); + + final Result rows = await testDb!.connection.execute( + Sql.named( + ''' + SELECT COUNT(*)::int + FROM questions + WHERE assigned_user_id = @uid + ''', + ), + parameters: {'uid': uid}, + ); + expect(rows.first[0], 0); + }); + + test('onAnswerSubmitted branches to follow-up then another word', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'spelling-answer-uid'; + await testDb!.seedUser(uid); + + final QuestionPipeline pipeline = await _pipeline(); + await pipeline.maintainSpellingForUser(uid); + + final Result reserved = await testDb!.connection.execute( + Sql.named( + ''' + SELECT id + FROM questions + WHERE assigned_user_id = @uid + ORDER BY created_at + LIMIT 1 + ''', + ), + parameters: {'uid': uid}, + ); + final String reservedId = reserved.first[0]!.toString(); + await pipeline.prepareSpellingQuestion( + firebaseUid: uid, + questionId: reservedId, + ); + + final Result first = await testDb!.connection.execute( + Sql.named( + ''' + SELECT id, metadata + FROM questions + WHERE assigned_user_id = @uid + ORDER BY created_at + LIMIT 1 + ''', + ), + parameters: {'uid': uid}, + ); + final String questionId = first.first[0]!.toString(); + final Object? metadataRaw = first.first[1]; + final Map metadata = metadataRaw is Map + ? metadataRaw + : Map.from( + jsonDecode(metadataRaw.toString()) as Map, + ); + final String canonicalWord = metadata['canonical_word']! as String; + + final Map? answered = await testDb!.questionsDb.submitAnswer( + questionId: questionId, + assignedUserId: uid, + userResponseText: canonicalWord, + ); + expect(answered, isNotNull); + + await pipeline.onAnswerSubmitted( + firebaseUid: uid, + answeredQuestion: answered!, + userResponse: answered['userResponse'] as num? ?? 0, + ); + + final Result rows = await testDb!.connection.execute( + Sql.named( + ''' + SELECT pipeline_step, source_tag + FROM questions + WHERE assigned_user_id = @uid + ORDER BY created_at + ''', + ), + parameters: {'uid': uid}, + ); + expect(rows.length, greaterThanOrEqualTo(2)); + expect(rows.last[0], PipelineSteps.spellingFollowUp); + expect(rows.last[1], 'spelling:follow_up'); + }); +} diff --git a/server/test/integration/spelling_words_api_test.dart b/server/test/integration/spelling_words_api_test.dart new file mode 100644 index 0000000..6f66b13 --- /dev/null +++ b/server/test/integration/spelling_words_api_test.dart @@ -0,0 +1,64 @@ +@Tags(['integration', 'postgres']) +library; + +import 'package:cyberhybridhub_server/spelling/spelling_items_db.dart'; +import 'package:test/test.dart'; + +import '../helpers/test_db.dart'; + +void main() { + TestDb? testDb; + + setUpAll(() async { + testDb = await TestDb.open(); + }); + + tearDown(() async { + if (testDb != null) { + await testDb!.truncateTradingTables(); + await testDb!.connection.execute('DELETE FROM spelling_items'); + await testDb!.connection.execute('DELETE FROM spelling_words'); + await testDb!.spellingItemsDb.ensureCatalogSeeded(); + } + }); + + tearDownAll(() async { + await testDb?.close(); + }); + + test('addCanonicalWord and listCanonicalWords', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + final SpellingItemsDb db = testDb!.spellingItemsDb; + final bool added = await db.addCanonicalWord('customword'); + expect(added, isTrue); + + final bool duplicate = await db.addCanonicalWord('customword'); + expect(duplicate, isFalse); + + final List words = await db.listCanonicalWords(); + expect(words, contains('customword')); + }); + + test('importCanonicalWords adds and skips duplicates', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + final SpellingItemsDb db = testDb!.spellingItemsDb; + final ({int added, int skipped}) result = await db.importCanonicalWords( + ['alpha', 'beta', 'alpha', '', ' '], + ); + + expect(result.added, 2); + expect(result.skipped, 1); + + final List words = await db.listCanonicalWords(); + expect(words, contains('alpha')); + expect(words, contains('beta')); + }); +} diff --git a/server/test/pipeline/spelling_word_list_test.dart b/server/test/pipeline/spelling_word_list_test.dart new file mode 100644 index 0000000..7014069 --- /dev/null +++ b/server/test/pipeline/spelling_word_list_test.dart @@ -0,0 +1,17 @@ +import 'package:cyberhybridhub_server/pipeline/spelling_word_list.dart'; +import 'package:test/test.dart'; + +void main() { + test('randomChallenge returns a word from the curated list', () { + final SpellingChallenge challenge = + SpellingWordList.randomChallenge(); + + final bool known = SpellingWordList.pairs.any( + (({String correct, String misspelled}) pair) => + challenge.displayedWord == pair.correct || + challenge.displayedWord == pair.misspelled, + ); + expect(known, isTrue); + expect(challenge.canonicalWord, isNotEmpty); + }); +} diff --git a/server/test/question_dispatcher_test.dart b/server/test/question_dispatcher_test.dart new file mode 100644 index 0000000..90974d5 --- /dev/null +++ b/server/test/question_dispatcher_test.dart @@ -0,0 +1,121 @@ +import 'dart:math'; + +import 'package:cyberhybridhub_server/question_source_preferences.dart'; +import 'package:test/test.dart'; + +void main() { + group('QuestionSourcePreferences.effectiveMix', () { + test('matches client-style normalization for enabled sources', () { + final List prefs = + QuestionSourcePreferences.mergeStored(>[ + {'id': 'market_history', 'enabled': true, 'ratio': 70}, + {'id': 'spelling', 'enabled': true, 'ratio': 30}, + ]); + + final Map mix = QuestionSourcePreferences.effectiveMix(prefs); + + expect(mix['market_history'], 70); + expect(mix['spelling'], 30); + }); + + test('excludes disabled sources', () { + final List prefs = + QuestionSourcePreferences.mergeStored(>[ + {'id': 'market_history', 'enabled': true, 'ratio': 50}, + {'id': 'spelling', 'enabled': false, 'ratio': 50}, + ]); + + final Map mix = QuestionSourcePreferences.effectiveMix(prefs); + + expect(mix.keys, {'market_history'}); + expect(mix['market_history'], 100); + }); + }); + + group('_sourceTryOrder (via dispatcher selection)', () { + test('weighted random favors configured ratio over many rolls', () { + final List prefs = + QuestionSourcePreferences.mergeStored(>[ + {'id': 'market_history', 'enabled': true, 'ratio': 90}, + {'id': 'spelling', 'enabled': true, 'ratio': 10}, + ]); + + final Random random = Random(42); + var marketFirst = 0; + for (var i = 0; i < 200; i++) { + final List order = _sourceTryOrderForTest(prefs, random); + if (order.first == 'market_history') { + marketFirst++; + } + } + + expect(marketFirst, greaterThan(140)); + }); + + test('only enabled source is always first', () { + final List prefs = + QuestionSourcePreferences.mergeStored(>[ + {'id': 'market_history', 'enabled': false, 'ratio': 0}, + {'id': 'spelling', 'enabled': true, 'ratio': 100}, + ]); + + final List order = + _sourceTryOrderForTest(prefs, Random(1)); + + expect(order, ['spelling']); + }); + }); +} + +/// Mirrors [QuestionDispatcher._sourceTryOrder] for deterministic unit tests. +List _sourceTryOrderForTest( + List prefs, + Random random, +) { + final List enabled = prefs + .where( + (ResolvedQuestionSourcePreference s) => s.enabled && !s.comingSoon, + ) + .toList(growable: false); + if (enabled.isEmpty) { + return []; + } + + final int totalWeight = enabled.fold( + 0, + (int sum, ResolvedQuestionSourcePreference s) => sum + s.ratio, + ); + if (totalWeight <= 0) { + final List ids = + enabled.map((ResolvedQuestionSourcePreference s) => s.id).toList(); + ids.shuffle(random); + return ids; + } + + var roll = random.nextInt(totalWeight); + String? primary; + for (final ResolvedQuestionSourcePreference source in enabled) { + roll -= source.ratio; + if (roll < 0) { + primary = source.id; + break; + } + } + primary ??= enabled.first.id; + + final List rest = enabled + .where((ResolvedQuestionSourcePreference s) => s.id != primary) + .toList() + ..sort( + ( + ResolvedQuestionSourcePreference a, + ResolvedQuestionSourcePreference b, + ) => + b.ratio.compareTo(a.ratio), + ); + + return [ + primary, + ...rest.map((ResolvedQuestionSourcePreference s) => s.id), + ]; +} diff --git a/server/test/question_source_mix_test.dart b/server/test/question_source_mix_test.dart new file mode 100644 index 0000000..12e5c16 --- /dev/null +++ b/server/test/question_source_mix_test.dart @@ -0,0 +1,89 @@ +import 'package:cyberhybridhub_server/question_source_mix.dart'; +import 'package:cyberhybridhub_server/questions_db.dart'; +import 'package:test/test.dart'; + +void main() { + group('QuestionSourceMix.pickNextSourceId', () { + test('picks highest share when nothing delivered yet', () { + expect( + QuestionSourceMix.pickNextSourceId( + effectiveMixPercent: { + 'market_history': 70, + 'spelling': 30, + }, + deliveredCounts: {}, + ), + 'market_history', + ); + }); + + test('picks spelling when market is ahead of target mix', () { + expect( + QuestionSourceMix.pickNextSourceId( + effectiveMixPercent: { + 'market_history': 50, + 'spelling': 50, + }, + deliveredCounts: { + 'market_history': 4, + 'spelling': 0, + }, + ), + 'spelling', + ); + }); + + test('picks market when spelling is ahead of target mix', () { + expect( + QuestionSourceMix.pickNextSourceId( + effectiveMixPercent: { + 'market_history': 70, + 'spelling': 30, + }, + deliveredCounts: { + 'market_history': 1, + 'spelling': 3, + }, + ), + 'market_history', + ); + }); + }); + + group('QuestionSourceMix.orderUnansweredByPreferredSource', () { + test('puts preferred source first preserving FIFO within source', () { + final List> ordered = + QuestionSourceMix.orderUnansweredByPreferredSource( + >[ + { + 'id': 'm1', + 'sourceTag': 'market_history:prospective', + 'createdAt': '2026-01-01T00:00:00Z', + }, + { + 'id': 's1', + 'sourceTag': 'spelling:word_quiz', + 'pipelineKey': 'spelling', + 'createdAt': '2026-01-02T00:00:00Z', + }, + { + 'id': 'm2', + 'sourceTag': 'market_history:prospective', + 'createdAt': '2026-01-03T00:00:00Z', + }, + ], + preferredSourceId: 'spelling', + ); + + expect(ordered.map((Map r) => r['id']).toList(), [ + 's1', + 'm1', + 'm2', + ]); + expect( + QuestionsDb.resolveSourceId(ordered.first), + 'spelling', + ); + }); + }); +} diff --git a/server/test/question_source_preferences_test.dart b/server/test/question_source_preferences_test.dart new file mode 100644 index 0000000..e76960d --- /dev/null +++ b/server/test/question_source_preferences_test.dart @@ -0,0 +1,78 @@ +import 'package:cyberhybridhub_server/question_source_preferences.dart'; +import 'package:test/test.dart'; + +void main() { + test('mergeStored applies defaults for market and spelling sources', () { + final List merged = + QuestionSourcePreferences.mergeStored(null); + + expect(merged.map((ResolvedQuestionSourcePreference s) => s.id), [ + 'market_history', + 'spelling', + ]); + expect( + merged.firstWhere((ResolvedQuestionSourcePreference s) => s.id == 'market_history').enabled, + isTrue, + ); + expect( + merged.firstWhere((ResolvedQuestionSourcePreference s) => s.id == 'spelling').enabled, + isTrue, + ); + }); + + test('parsePutBody rejects all disabled sources', () { + expect( + () => QuestionSourcePreferences.parsePutBody({ + 'sources': >[ + {'id': 'market_history', 'enabled': false, 'ratio': 50}, + {'id': 'spelling', 'enabled': false, 'ratio': 50}, + ], + }), + throwsA(isA()), + ); + }); + + test('parsePutBody accepts enabled spelling source', () { + final List> stored = + QuestionSourcePreferences.parsePutBody({ + 'sources': >[ + {'id': 'market_history', 'enabled': true, 'ratio': 50}, + {'id': 'spelling', 'enabled': true, 'ratio': 50}, + ], + }); + + expect(stored.length, 2); + expect( + stored.firstWhere((Map row) => row['id'] == 'spelling')['enabled'], + isTrue, + ); + }); + + test('effectiveMix normalizes enabled source ratios', () { + final List resolved = + QuestionSourcePreferences.mergeStored(>[ + {'id': 'market_history', 'enabled': true, 'ratio': 75}, + {'id': 'spelling', 'enabled': true, 'ratio': 25}, + ]); + + final Map mix = + QuestionSourcePreferences.effectiveMix(resolved); + + expect(mix['market_history'], 75); + expect(mix['spelling'], 25); + }); + + test('isEnabled returns false for disabled spelling', () { + final List resolved = + QuestionSourcePreferences.mergeStored(>[ + {'id': 'market_history', 'enabled': true, 'ratio': 100}, + {'id': 'spelling', 'enabled': false, 'ratio': 0}, + ]); + + expect( + QuestionSourcePreferences.isEnabled(resolved, 'market_history'), + isTrue, + ); + expect(QuestionSourcePreferences.isEnabled(resolved, 'spelling'), isFalse); + }); +} diff --git a/server/test/questions_client_payload_test.dart b/server/test/questions_client_payload_test.dart new file mode 100644 index 0000000..87d7204 --- /dev/null +++ b/server/test/questions_client_payload_test.dart @@ -0,0 +1,55 @@ +import 'package:cyberhybridhub_server/questions_db.dart'; +import 'package:test/test.dart'; + +void main() { + group('QuestionsDb client presentation fields', () { + test('market_history prospective maps sourceId and presentation', () { + final Map question = { + 'sourceTag': 'market_history:prospective', + 'pipelineKey': 'trading', + }; + + expect(QuestionsDb.resolveSourceId(question), 'market_history'); + expect(QuestionsDb.resolvePresentation(question), 'market_glyph_slider'); + }); + + test('spelling word quiz maps sourceId and presentation', () { + final Map question = { + 'sourceTag': 'spelling:word_quiz', + 'pipelineKey': 'spelling', + 'pipelineStep': 'spelling_word_quiz', + }; + + expect(QuestionsDb.resolveSourceId(question), 'spelling'); + expect(QuestionsDb.resolvePresentation(question), 'spelling_word'); + }); + + test('spelling follow-up uses slider presentation', () { + final Map question = { + 'sourceTag': 'spelling:follow_up', + 'pipelineKey': 'spelling', + 'pipelineStep': 'spelling_follow_up', + }; + + expect(QuestionsDb.resolveSourceId(question), 'spelling'); + expect(QuestionsDb.resolvePresentation(question), 'market_glyph_slider'); + }); + + test('spelling pipeline key without step still maps to spelling_word', () { + final Map question = { + 'pipelineKey': 'spelling', + }; + + expect(QuestionsDb.resolveSourceId(question), 'spelling'); + expect(QuestionsDb.resolvePresentation(question), 'spelling_word'); + }); + + test('missing metadata defaults to market presentation', () { + expect(QuestionsDb.resolveSourceId({}), 'market_history'); + expect( + QuestionsDb.resolvePresentation({}), + 'market_glyph_slider', + ); + }); + }); +} diff --git a/server/test/spelling/spelling_answer_policy_test.dart b/server/test/spelling/spelling_answer_policy_test.dart new file mode 100644 index 0000000..1c0a206 --- /dev/null +++ b/server/test/spelling/spelling_answer_policy_test.dart @@ -0,0 +1,9 @@ +import 'package:cyberhybridhub_server/spelling/spelling_answer_policy.dart'; +import 'package:test/test.dart'; + +void main() { + test('matches is case insensitive and trims whitespace', () { + expect(SpellingAnswerPolicy.matches(' Accommodate ', 'accommodate'), isTrue); + expect(SpellingAnswerPolicy.matches('separate', 'rhythm'), isFalse); + }); +} diff --git a/server/test/spelling/spelling_item_selector_test.dart b/server/test/spelling/spelling_item_selector_test.dart new file mode 100644 index 0000000..a507497 --- /dev/null +++ b/server/test/spelling/spelling_item_selector_test.dart @@ -0,0 +1,82 @@ +import 'dart:math'; + +import 'package:cyberhybridhub_server/spelling/spelling_item_record.dart'; +import 'package:cyberhybridhub_server/spelling/spelling_item_selector.dart'; +import 'package:cyberhybridhub_server/spelling/spelling_progress_policy.dart'; +import 'package:test/test.dart'; + +void main() { + test('mastered item is still selectable over many draws', () { + final SpellingItemSelector selector = SpellingItemSelector(random: Random(99)); + + const SpellingItemRecord mastered = SpellingItemRecord( + id: 'mastered', + spellingWordId: 'w1', + canonicalWord: 'separate', + displayedWord: 'separate', + isCorrectlySpelled: true, + correctAnswer: 10, + familiarityScore: 20, + ); + const SpellingItemRecord fresh = SpellingItemRecord( + id: 'fresh', + spellingWordId: 'w2', + canonicalWord: 'rhythm', + displayedWord: 'rythm', + isCorrectlySpelled: false, + correctAnswer: -10, + familiarityScore: 0, + ); + + var masteredHits = 0; + for (var i = 0; i < 500; i++) { + final SpellingItemRecord? pick = selector.pickFromCandidates( + [mastered, fresh], + ); + if (pick!.id == mastered.id) { + masteredHits++; + } + } + + expect(masteredHits, greaterThan(0)); + expect( + SpellingProgressPolicy.weightForScore(mastered.familiarityScore), + SpellingProgressPolicy.minWeight, + ); + }); + + test('struggling item is picked more often than mastered peer', () { + final SpellingItemSelector selector = SpellingItemSelector(random: Random(7)); + + const SpellingItemRecord struggling = SpellingItemRecord( + id: 'struggling', + spellingWordId: 'w1', + canonicalWord: 'separate', + displayedWord: 'seperate', + isCorrectlySpelled: false, + correctAnswer: -10, + familiarityScore: -4, + ); + const SpellingItemRecord mastered = SpellingItemRecord( + id: 'mastered', + spellingWordId: 'w2', + canonicalWord: 'rhythm', + displayedWord: 'rhythm', + isCorrectlySpelled: true, + correctAnswer: 10, + familiarityScore: 8, + ); + + var strugglingHits = 0; + for (var i = 0; i < 300; i++) { + final SpellingItemRecord? pick = selector.pickFromCandidates( + [struggling, mastered], + ); + if (pick!.id == struggling.id) { + strugglingHits++; + } + } + + expect(strugglingHits, greaterThan(150)); + }); +} diff --git a/server/test/spelling/spelling_progress_policy_test.dart b/server/test/spelling/spelling_progress_policy_test.dart new file mode 100644 index 0000000..991fff5 --- /dev/null +++ b/server/test/spelling/spelling_progress_policy_test.dart @@ -0,0 +1,39 @@ +import 'package:cyberhybridhub_server/spelling/spelling_progress_policy.dart'; +import 'package:test/test.dart'; + +void main() { + test('weightForScore never drops below minWeight', () { + expect( + SpellingProgressPolicy.weightForScore(100), + SpellingProgressPolicy.minWeight, + ); + }); + + test('weightForScore is higher for negative familiarity', () { + expect( + SpellingProgressPolicy.weightForScore(-3), + greaterThan(SpellingProgressPolicy.weightForScore(0)), + ); + }); + + test('weightForScore is lower for positive familiarity', () { + expect( + SpellingProgressPolicy.weightForScore(5), + lessThan(SpellingProgressPolicy.weightForScore(0)), + ); + }); + + test('scoreAfterAttempt moves up on correct and down on wrong', () { + expect( + SpellingProgressPolicy.scoreAfterAttempt(currentScore: 2, correct: true), + 3, + ); + expect( + SpellingProgressPolicy.scoreAfterAttempt( + currentScore: 2, + correct: false, + ), + 1, + ); + }); +} diff --git a/server/test/spelling/spelling_words_catalog_test.dart b/server/test/spelling/spelling_words_catalog_test.dart new file mode 100644 index 0000000..d9d3552 --- /dev/null +++ b/server/test/spelling/spelling_words_catalog_test.dart @@ -0,0 +1,19 @@ +import 'package:cyberhybridhub_server/pipeline/spelling_word_list.dart'; +import 'package:test/test.dart'; + +void main() { + group('SpellingWordList helpers', () { + test('misspellingFor returns curated typo', () { + expect( + SpellingWordList.misspellingFor('separate'), + 'seperate', + ); + }); + + test('generatedMisspelling falls back for unknown words', () { + final String typo = SpellingWordList.generatedMisspelling('example'); + expect(typo, isNot('example')); + expect(typo.length, lessThan('example'.length)); + }); + }); +} diff --git a/test/admin/widgets/admin_app_bar_action_test.dart b/test/admin/widgets/admin_app_bar_action_test.dart index 8be2d1f..996bf4e 100644 --- a/test/admin/widgets/admin_app_bar_action_test.dart +++ b/test/admin/widgets/admin_app_bar_action_test.dart @@ -42,7 +42,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('admin-app-bar-action')), findsOneWidget); - expect(find.byIcon(Icons.admin_panel_settings_outlined), findsOneWidget); + expect(find.byIcon(Icons.admin_panel_settings), findsOneWidget); }); testWidgets('hides admin icon when user is not on allowlist', ( diff --git a/test/models/incoming_question_test.dart b/test/models/incoming_question_test.dart new file mode 100644 index 0000000..6d22c22 --- /dev/null +++ b/test/models/incoming_question_test.dart @@ -0,0 +1,47 @@ +import 'package:cyberhybridhub/models/incoming_question.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('IncomingQuestion spelling routing', () { + test('pending spelling uses word tile even with slider presentation', () { + final IncomingQuestion question = IncomingQuestion( + id: 'spell-pending', + text: 'Spelling question loading…', + sentAt: DateTime.utc(2026, 1, 1), + sourceId: 'spelling', + presentation: 'market_glyph_slider', + needsPrepare: true, + ); + + expect(question.usesSpellingWordTile, isTrue); + expect(question.isSpellingFollowUp, isFalse); + }); + + test('spelling follow-up uses slider tile', () { + final IncomingQuestion question = IncomingQuestion( + id: 'spell-follow', + text: "Correct — 'colour' is the proper spelling. Enter +1 for another word.", + sentAt: DateTime.utc(2026, 1, 1), + sourceId: 'spelling', + presentation: 'market_glyph_slider', + ); + + expect(question.isSpellingFollowUp, isTrue); + expect(question.usesSpellingWordTile, isFalse); + }); + + test('prepared spelling with spokenWord uses word tile', () { + final IncomingQuestion question = IncomingQuestion( + id: 'spell-ready', + text: 'Spell the word you hear.', + sentAt: DateTime.utc(2026, 1, 1), + sourceId: 'spelling', + presentation: 'market_glyph_slider', + spokenWord: 'colour', + audioTitle: 'spell-ready', + ); + + expect(question.usesSpellingWordTile, isTrue); + }); + }); +} diff --git a/test/question_source_settings_test.dart b/test/question_source_settings_test.dart new file mode 100644 index 0000000..6db7368 --- /dev/null +++ b/test/question_source_settings_test.dart @@ -0,0 +1,139 @@ +import 'package:cyberhybridhub/models/app_user.dart'; +import 'package:cyberhybridhub/repositories/question_source_config_repository.dart'; +import 'package:cyberhybridhub/screens/home_screen.dart'; +import 'package:cyberhybridhub/widgets/question_source_settings_sheet.dart'; +import 'package:cyberhybridhub/services/questions_hub_service.dart'; +import 'package:cyberhybridhub/theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + setUp(() { + QuestionSourceConfigRepository.instance.resetToDefaults(); + }); + + testWidgets('gear icon opens question source settings dialog', ( + WidgetTester tester, + ) async { + final QuestionsHubService hub = QuestionsHubService.instance; + hub.hasPendingQuestion.value = false; + hub.pendingQuestionCount.value = 0; + + addTearDown(() { + hub.hasPendingQuestion.value = false; + hub.pendingQuestionCount.value = 0; + }); + + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: const HomeScreen( + user: AppUser(uid: 'u1', displayName: 'Test User'), + ), + ), + ); + + expect(find.byKey(const Key('question-source-settings-button')), findsOneWidget); + + await tester.tap(find.byKey(const Key('question-source-settings-button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('question-source-settings-dialog')), findsOneWidget); + expect(find.text('Question sources'), findsOneWidget); + expect(find.text('Market question'), findsOneWidget); + expect(find.text('Spelling'), findsOneWidget); + expect(find.text('Coming soon'), findsNothing); + expect(find.text('Geography'), findsNothing); + expect(find.text('Weather'), findsNothing); + expect(find.text('Trading'), findsNothing); + expect( + find.byKey(const Key('question-source-enabled-spelling')), + findsOneWidget, + ); + final Switch spellingSwitch = tester.widget( + find.byKey(const Key('question-source-enabled-spelling')), + ); + expect(spellingSwitch.onChanged, isNotNull); + expect( + find.byKey(const Key('question-source-ratio-spelling')), + findsOneWidget, + ); + expect( + find.byKey(const Key('question-source-edit-spelling')), + findsOneWidget, + ); + }); + + testWidgets('spelling edit opens spelling word list screen', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + onGenerateRoute: (RouteSettings settings) { + if (settings.name == '/spelling-words') { + return MaterialPageRoute( + settings: settings, + builder: (_) => const Scaffold( + body: Center(child: Text('Spelling words page')), + ), + ); + } + return null; + }, + home: Builder( + builder: (BuildContext context) { + return Scaffold( + body: Center( + child: FilledButton( + onPressed: () => QuestionSourceSettingsSheet.show(context), + child: const Text('Open'), + ), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('question-source-edit-spelling'))); + await tester.pumpAndSettle(); + + expect(find.text('Spelling words page'), findsOneWidget); + }); + + testWidgets('saving question source settings updates repository', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: const HomeScreen( + user: AppUser(uid: 'u1', displayName: 'Test User'), + ), + ), + ); + + await tester.tap(find.byKey(const Key('question-source-settings-button'))); + await tester.pumpAndSettle(); + + await tester.drag( + find.byKey(const Key('question-source-ratio-market_history')), + const Offset(80, 0), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('question-source-settings-save'))); + await tester.pumpAndSettle(); + + expect( + QuestionSourceConfigRepository.instance.current + .firstWhere((source) => source.id == 'market_history') + .ratio, + isNot(50), + ); + expect(find.byKey(const Key('question-source-settings-dialog')), findsNothing); + }); +} diff --git a/test/repositories/question_source_config_repository_test.dart b/test/repositories/question_source_config_repository_test.dart new file mode 100644 index 0000000..e27c6d0 --- /dev/null +++ b/test/repositories/question_source_config_repository_test.dart @@ -0,0 +1,137 @@ +import 'package:cyberhybridhub/models/question_source_preference.dart'; +import 'package:cyberhybridhub/repositories/question_source_config_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + setUp(() { + QuestionSourceConfigRepository.instance.resetToDefaults(); + }); + + test('mergeWithDefaults adds new sources from defaults', () { + const List saved = [ + QuestionSourcePreference( + id: 'market_history', + label: 'Market question', + description: 'Prospective price-move guessing game', + enabled: true, + ratio: 100, + ), + ]; + + final List merged = + QuestionSourceConfigRepository.mergeWithDefaults(saved); + + expect(merged.map((QuestionSourcePreference s) => s.id), [ + 'market_history', + 'spelling', + ]); + expect( + merged.firstWhere((QuestionSourcePreference s) => s.id == 'market_history').ratio, + 100, + ); + expect( + merged.firstWhere((QuestionSourcePreference s) => s.id == 'spelling').comingSoon, + isFalse, + ); + }); + + test('mergeWithDefaults drops removed legacy sources', () { + const List saved = [ + QuestionSourcePreference( + id: 'geography', + label: 'Geography', + description: 'Legacy', + ratio: 25, + ), + QuestionSourcePreference( + id: 'market_history', + label: 'Market question', + description: 'Prospective price-move guessing game', + ratio: 75, + ), + ]; + + final List merged = + QuestionSourceConfigRepository.mergeWithDefaults(saved); + + expect(merged.map((QuestionSourcePreference s) => s.id), [ + 'market_history', + 'spelling', + ]); + }); + + test('mergeWithDefaults refreshes labels and disables comingSoon sources', () { + const List saved = [ + QuestionSourcePreference( + id: 'market_history', + label: 'Market history', + description: 'Old description', + ratio: 30, + ), + QuestionSourcePreference( + id: 'spelling', + label: 'Spelling', + description: 'Old description', + enabled: true, + comingSoon: false, + ratio: 70, + ), + ]; + + final List merged = + QuestionSourceConfigRepository.mergeWithDefaults(saved); + + final QuestionSourcePreference market = merged.firstWhere( + (QuestionSourcePreference s) => s.id == 'market_history', + ); + expect(market.label, 'Market question'); + expect(market.description, 'Prospective price-move guessing game'); + + final QuestionSourcePreference spelling = merged.firstWhere( + (QuestionSourcePreference s) => s.id == 'spelling', + ); + expect(spelling.comingSoon, isFalse); + expect(spelling.enabled, isTrue); + }); + + test('effectiveMix excludes disabled and comingSoon sources', () { + final List sources = + QuestionSourceConfigRepository.defaultPreferences; + + final Map mix = + QuestionSourceConfigRepository.effectiveMix(sources); + + expect(mix.containsKey('spelling'), isTrue); + expect(mix.length, 2); + expect(mix['market_history'], 50); + expect(mix['spelling'], 50); + }); + + test('saveAll without active session updates in-memory preferences only', () async { + await QuestionSourceConfigRepository.instance.saveAll( + [ + const QuestionSourcePreference( + id: 'market_history', + label: 'Market question', + description: 'Prospective price-move guessing game', + enabled: true, + ratio: 75, + ), + const QuestionSourcePreference( + id: 'spelling', + label: 'Spelling', + description: 'Spell-check quiz from curated word lists', + enabled: true, + ratio: 25, + ), + ], + ); + + expect( + QuestionSourceConfigRepository.instance.current + .firstWhere((QuestionSourcePreference s) => s.id == 'market_history') + .ratio, + 75, + ); + }); +} diff --git a/test/utils/spelling_spoken_word_test.dart b/test/utils/spelling_spoken_word_test.dart new file mode 100644 index 0000000..f2032c9 --- /dev/null +++ b/test/utils/spelling_spoken_word_test.dart @@ -0,0 +1,29 @@ +import 'package:cyberhybridhub/utils/spelling_spoken_word.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('spellingWordToSpeak extracts quoted word', () { + expect( + spellingWordToSpeak( + "Is 'colour' spelled correctly? Swipe toward +10 for yes.", + ), + 'colour', + ); + }); + + test('spellingWordToSpeak handles double quotes', () { + expect( + spellingWordToSpeak('Is "separate" spelled correctly?'), + 'separate', + ); + }); + + test('spellingWordToSpeak returns null for generic test text', () { + expect( + spellingWordToSpeak( + 'Starter question: enter a whole number between -10 and 10.', + ), + isNull, + ); + }); +} diff --git a/test/widgets/question_presentation_router_test.dart b/test/widgets/question_presentation_router_test.dart new file mode 100644 index 0000000..18d5174 --- /dev/null +++ b/test/widgets/question_presentation_router_test.dart @@ -0,0 +1,133 @@ +import 'package:cyberhybridhub/models/incoming_question.dart'; +import 'package:cyberhybridhub/widgets/question_presentation_router.dart'; +import 'package:cyberhybridhub/widgets/spelling_question_tile.dart'; +import 'package:cyberhybridhub/widgets/swipe_question_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future _mockPrepare(String questionId) async { + return IncomingQuestion( + id: questionId, + text: 'Spell the word you hear.', + sentAt: DateTime.utc(2026, 1, 1), + sourceId: 'spelling', + presentation: 'spelling_word', + spokenWord: 'colour', + audioTitle: questionId, + ); +} + +void main() { + testWidgets('renders SwipeQuestionTile for market_history', (tester) async { + final IncomingQuestion question = IncomingQuestion( + id: 'market-1', + text: 'Will price move up?', + sentAt: DateTime.utc(2026, 1, 1), + sourceId: 'market_history', + presentation: 'market_glyph_slider', + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: QuestionPresentationRouter( + question: question, + onAnswer: (_) async {}, + onDefer: () async {}, + ), + ), + ), + ); + + expect(find.byType(SwipeQuestionTile), findsOneWidget); + expect(find.byType(SpellingQuestionTile), findsNothing); + }); + + testWidgets('renders SwipeQuestionTile for spelling follow-up', (tester) async { + final IncomingQuestion question = IncomingQuestion( + id: 'spell-follow-1', + text: "Correct — 'colour' is the proper spelling. Enter +1 for another word.", + sentAt: DateTime.utc(2026, 1, 1), + sourceId: 'spelling', + presentation: 'market_glyph_slider', + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: QuestionPresentationRouter( + question: question, + onAnswer: (_) async {}, + onDefer: () async {}, + ), + ), + ), + ); + + expect(find.byType(SwipeQuestionTile), findsOneWidget); + expect(find.byType(SpellingQuestionTile), findsNothing); + }); + + testWidgets('renders SpellingQuestionTile for spelling', (tester) async { + final IncomingQuestion question = IncomingQuestion( + id: 'spell-1', + text: 'Spell the word you hear.', + sentAt: DateTime.utc(2026, 1, 1), + sourceId: 'spelling', + presentation: 'spelling_word', + spokenWord: 'colour', + audioTitle: 'spell-1', + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: QuestionPresentationRouter( + question: question, + onAnswer: (_) async {}, + onAnswerText: (_) async {}, + onDefer: () async {}, + ), + ), + ), + ); + + expect(find.byType(SpellingQuestionTile), findsOneWidget); + expect(find.byType(SwipeQuestionTile), findsNothing); + expect(find.byKey(const Key('spelling-speak-word')), findsOneWidget); + expect(find.byKey(const Key('spelling-answer-field')), findsOneWidget); + }); + + testWidgets('spelling tile shows speaker and text field after prepare', + (tester) async { + final IncomingQuestion question = IncomingQuestion( + id: 'spell-1', + text: 'Spelling question loading…', + sentAt: DateTime.utc(2026, 1, 1), + sourceId: 'spelling', + presentation: 'spelling_word', + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SpellingQuestionTile( + questionId: question.id, + questionText: question.text, + needsPrepare: true, + prepareQuestion: _mockPrepare, + onSubmitAnswer: (_) async {}, + onDefer: () async {}, + ), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byKey(const Key('spelling-speak-word')), findsOneWidget); + expect(find.byKey(const Key('spelling-answer-field')), findsOneWidget); + expect(find.byKey(const Key('spelling-submit-answer')), findsOneWidget); + }); +} diff --git a/test/widgets/spelling_question_placeholder_test.dart b/test/widgets/spelling_question_placeholder_test.dart new file mode 100644 index 0000000..ac43af4 --- /dev/null +++ b/test/widgets/spelling_question_placeholder_test.dart @@ -0,0 +1,24 @@ +import 'package:cyberhybridhub/widgets/spelling_question_placeholder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('SpellingQuestionPlaceholder shows retry', (tester) async { + var retried = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SpellingQuestionPlaceholder( + onRetry: () => retried = true, + ), + ), + ), + ); + + expect(find.text('No spelling question ready'), findsOneWidget); + await tester.tap(find.byKey(const Key('spelling-placeholder-retry'))); + await tester.pump(); + expect(retried, isTrue); + }); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 4d662be..1fb0989 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + FlutterTtsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterTtsPlugin")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index ea130c3..d8348dc 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST connectivity_plus firebase_auth firebase_core + flutter_tts sqlite3_flutter_libs url_launcher_windows )