From a521a74214f15e0315f049fdcc64c65fc5a08ee8 Mon Sep 17 00:00:00 2001 From: nathanAndrinoid Date: Wed, 17 Jun 2026 04:20:45 +0000 Subject: [PATCH] balance controled working spelling and market questions. --- .flutter-plugins-dependencies | 2 +- .gitignore | 3 + .../screens/market_history_log_screen.dart | 715 +++++++++++++++++- .../services/market_history_admin_api.dart | 97 +++ lib/models/question_submit_result.dart | 2 + lib/screens/home_screen.dart | 217 +----- lib/services/questions_api_service.dart | 1 + lib/services/questions_hub_service.dart | 146 ++-- .../spelling_tts/spelling_tts_flutter.dart | 94 ++- .../spelling_tts/spelling_tts_web.dart | 92 ++- lib/widgets/question_presentation_router.dart | 2 +- .../question_source_settings_sheet.dart | 193 +++++ lib/widgets/swipe_question_tile.dart | 28 +- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 64 ++ pubspec.yaml | 1 + question_system_expantion_guide.md | 202 +++++ scripts/admin-portal-coverage.sh | 5 + scripts/build-production-web.sh | 4 +- server/.env.example | 2 + server/bin/server.dart | 8 + server/lib/env.dart | 8 + .../market_history_admin_handler.dart | 157 ++++ server/lib/handlers/questions_handler.dart | 10 + .../lib/handlers/spelling_audio_handler.dart | 354 +++++++++ server/lib/question_service.dart | 8 +- server/lib/questions_db.dart | 34 +- server/lib/trading/guess_score_store.dart | 10 +- .../lib/trading/market_question_logger.dart | 67 ++ .../trading/prospective_answer_scoring.dart | 13 +- .../022_prospective_answers_is_inverted.sql | 4 + .../market_history_admin_handler_test.dart | 459 ++++++++++- ...et_history_prospective_questions_test.dart | 320 ++++++++ .../spelling/spelling_audio_handler_test.dart | 209 +++++ test/admin/models/admin_models_test.dart | 125 +++ .../market_history_log_screen_test.dart | 270 ++++++- .../market_history_admin_api_test.dart | 73 ++ test/home_screen_score_test.dart | 13 +- test/question_source_settings_test.dart | 14 +- .../dictionary_audio_parsing_test.dart | 112 +++ .../question_presentation_router_test.dart | 6 +- test/widgets/swipe_question_tile_test.dart | 131 ++++ .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 46 files changed, 3952 insertions(+), 334 deletions(-) create mode 100644 question_system_expantion_guide.md create mode 100644 server/lib/handlers/spelling_audio_handler.dart create mode 100644 server/lib/trading/market_question_logger.dart create mode 100644 server/migrations/022_prospective_answers_is_inverted.sql create mode 100644 server/test/spelling/spelling_audio_handler_test.dart create mode 100644 test/admin/models/admin_models_test.dart create mode 100644 test/services/dictionary_audio_parsing_test.dart create mode 100644 test/widgets/swipe_question_tile_test.dart diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index 8318cf4..7af5bc6 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/nathan/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_picker","path":"/home/nathan/.pub-cache/hosted/pub.dev/file_picker-11.0.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/nathan/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/nathan/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_tts","path":"/home/nathan/.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/nathan/.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/nathan/.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/nathan/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/nathan/.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/nathan/.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/nathan/.pub-cache/hosted/pub.dev/url_launcher_ios-6.4.1/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"connectivity_plus","path":"/home/nathan/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_picker","path":"/home/nathan/.pub-cache/hosted/pub.dev/file_picker-11.0.2/","native_build":true,"dependencies":["flutter_plugin_android_lifecycle"],"dev_dependency":false},{"name":"firebase_auth","path":"/home/nathan/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/nathan/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_plugin_android_lifecycle","path":"/home/nathan/.pub-cache/hosted/pub.dev/flutter_plugin_android_lifecycle-2.0.35/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_tts","path":"/home/nathan/.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/nathan/.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/nathan/.pub-cache/hosted/pub.dev/google_sign_in_android-7.2.11/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/nathan/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/nathan/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/nathan/.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/nathan/.pub-cache/hosted/pub.dev/shared_preferences_android-2.4.23/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/nathan/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_android","path":"/home/nathan/.pub-cache/hosted/pub.dev/url_launcher_android-6.3.29/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"connectivity_plus","path":"/home/nathan/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_picker","path":"/home/nathan/.pub-cache/hosted/pub.dev/file_picker-11.0.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/nathan/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/nathan/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_tts","path":"/home/nathan/.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/nathan/.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/nathan/.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/nathan/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/nathan/.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/nathan/.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/nathan/.pub-cache/hosted/pub.dev/url_launcher_macos-3.2.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"connectivity_plus","path":"/home/nathan/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_picker","path":"/home/nathan/.pub-cache/hosted/pub.dev/file_picker-11.0.2/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_desktop","path":"/home/nathan/.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/nathan/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/nathan/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"/home/nathan/.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/nathan/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_linux","path":"/home/nathan/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.2/","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"connectivity_plus","path":"/home/nathan/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_picker","path":"/home/nathan/.pub-cache/hosted/pub.dev/file_picker-11.0.2/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/nathan/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/nathan/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_tts","path":"/home/nathan/.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/nathan/.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/nathan/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/nathan/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"/home/nathan/.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/nathan/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_windows","path":"/home/nathan/.pub-cache/hosted/pub.dev/url_launcher_windows-3.1.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"connectivity_plus","path":"/home/nathan/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","dependencies":[],"dev_dependency":false},{"name":"file_picker","path":"/home/nathan/.pub-cache/hosted/pub.dev/file_picker-11.0.2/","dependencies":[],"dev_dependency":false},{"name":"firebase_auth_web","path":"/home/nathan/.pub-cache/hosted/pub.dev/firebase_auth_web-6.2.1/","dependencies":["firebase_core_web"],"dev_dependency":false},{"name":"firebase_core_web","path":"/home/nathan/.pub-cache/hosted/pub.dev/firebase_core_web-3.7.0/","dependencies":[],"dev_dependency":false},{"name":"flutter_tts","path":"/home/nathan/.pub-cache/hosted/pub.dev/flutter_tts-4.2.5/","dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_web","path":"/home/nathan/.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/nathan/.pub-cache/hosted/pub.dev/google_sign_in_web-1.1.3/","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"/home/nathan/.pub-cache/hosted/pub.dev/shared_preferences_web-2.4.3/","dependencies":[],"dev_dependency":false},{"name":"url_launcher_web","path":"/home/nathan/.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-09 16:32:21.035502","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":"audioplayers_darwin","path":"/home/chh/.pub-cache/hosted/pub.dev/audioplayers_darwin-6.4.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"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-11.0.2/","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":"audioplayers_android","path":"/home/chh/.pub-cache/hosted/pub.dev/audioplayers_android-5.2.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"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-11.0.2/","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":"audioplayers_darwin","path":"/home/chh/.pub-cache/hosted/pub.dev/audioplayers_darwin-6.4.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"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-11.0.2/","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":"audioplayers_linux","path":"/home/chh/.pub-cache/hosted/pub.dev/audioplayers_linux-4.2.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"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-11.0.2/","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":"audioplayers_windows","path":"/home/chh/.pub-cache/hosted/pub.dev/audioplayers_windows-4.3.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"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-11.0.2/","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":"audioplayers_web","path":"/home/chh/.pub-cache/hosted/pub.dev/audioplayers_web-5.2.1/","dependencies":[],"dev_dependency":false},{"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-11.0.2/","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":"audioplayers","dependencies":["audioplayers_android","audioplayers_darwin","audioplayers_linux","audioplayers_web","audioplayers_windows","path_provider"]},{"name":"audioplayers_android","dependencies":[]},{"name":"audioplayers_darwin","dependencies":[]},{"name":"audioplayers_linux","dependencies":[]},{"name":"audioplayers_web","dependencies":[]},{"name":"audioplayers_windows","dependencies":[]},{"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-10 02:56:11.187584","version":"3.44.1","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 817e1a1..25baa70 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,6 @@ migrate_working_dir/ *.env.* !server/.env.example !hosting/hosting.env.example + +# Server Cache +server/spelling_audio_cache/ diff --git a/lib/admin/screens/market_history_log_screen.dart b/lib/admin/screens/market_history_log_screen.dart index cf4a4f5..cd63681 100644 --- a/lib/admin/screens/market_history_log_screen.dart +++ b/lib/admin/screens/market_history_log_screen.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/material.dart'; +import '../../config/api_config.dart'; import '../../theme/app_theme.dart'; import '../models/sync_run_event.dart'; import '../repositories/sync_run_log_repository.dart'; @@ -45,6 +47,18 @@ class _MarketHistoryLogScreenState extends State { bool _triggerInFlight = false; bool _weekCoverageLoading = false; bool _questionAuditLoading = false; + String _selectedAdminOption = 'market_history_log'; + bool _questionLogLoading = false; + List _questionLogEntries = []; + String? _questionLogError; + bool _audioCacheLoading = false; + List _audioCacheFiles = []; + String? _audioCacheError; + AudioPlayer? _audioPlayer; + String? _playingFile; + final Map _volumes = {}; + final Map _normalizeFlags = {}; + final Map _savingFlags = {}; MarketHistoryAdminApi get _coverageApi => widget.coverageApi ?? MarketHistoryAdminApi(); @@ -74,6 +88,7 @@ class _MarketHistoryLogScreenState extends State { void dispose() { _autoRefreshTimer?.cancel(); _scrollController.dispose(); + _audioPlayer?.dispose(); super.dispose(); } @@ -280,57 +295,673 @@ class _MarketHistoryLogScreenState extends State { _triggerInFlight || _state.hasInProgressRun; + Future _loadQuestionLog() async { + setState(() { + _questionLogLoading = true; + _questionLogError = null; + }); + try { + final List entries = await _coverageApi.fetchMarketQuestionLog(asOf: widget.now); + if (!mounted) { + return; + } + setState(() { + _questionLogEntries = entries; + _questionLogLoading = false; + }); + } catch (e) { + if (!mounted) { + return; + } + setState(() { + _questionLogError = e.toString(); + _questionLogLoading = false; + }); + } + } + + final RegExp _logRegex = RegExp( + r'^\[([^\]]+)\] Question ID: ([^,]+), User ID: ([^,]+), Symbol: ([^,]+), Response: ([^,]+), Correct: ([^,]+), Inverted: (true|false)', + ); + + Widget _buildQuestionLogTile(String line) { + final Match? match = _logRegex.firstMatch(line); + if (match == null) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + line, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ); + } + + final String timestamp = match.group(1)!; + final String questionId = match.group(2)!.trim(); + final String userId = match.group(3)!.trim(); + final String symbol = match.group(4)!.trim(); + final String response = match.group(5)!.trim(); + final String correct = match.group(6)!.trim(); + final String inverted = match.group(7)!.trim(); + + String displayTime = timestamp; + try { + final DateTime parsedTime = DateTime.parse(timestamp).toLocal(); + final String hour = parsedTime.hour.toString().padLeft(2, '0'); + final String minute = parsedTime.minute.toString().padLeft(2, '0'); + final String second = parsedTime.second.toString().padLeft(2, '0'); + displayTime = '$hour:$minute:$second'; + } catch (_) {} + + final bool isInverted = inverted == 'true'; + final double? respVal = double.tryParse(response); + final double? corrVal = double.tryParse(correct); + bool isCorrect = false; + if (respVal != null && corrVal != null) { + final double effectiveCorrect = isInverted ? corrVal * -1 : corrVal; + isCorrect = respVal.compareTo(0) == effectiveCorrect.compareTo(0); + } + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceElevated, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: (isCorrect ? AppColors.success : AppColors.error).withValues(alpha: 0.15), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayTime, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: AppColors.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + symbol, + style: const TextStyle( + fontFamily: 'monospace', + fontWeight: FontWeight.bold, + color: AppColors.accent, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'User: $userId • Q: $questionId', + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + 'Response: $response', + style: TextStyle( + color: isCorrect ? AppColors.success : AppColors.textSecondary, + fontSize: 12, + ), + ), + Text( + 'Correct: $correct', + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + ), + ), + Text( + 'Inverted: $inverted', + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + ), + ), + Text( + 'Points: ${isCorrect ? "+1" : "-1"}', + style: TextStyle( + color: isCorrect ? AppColors.success : AppColors.error, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + if (isInverted) + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(3), + ), + child: const Text( + 'INVERTED', + style: TextStyle( + color: Colors.amber, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ], + ), + ), + Icon( + isCorrect ? Icons.check_circle_outline : Icons.highlight_off, + color: isCorrect ? AppColors.success : AppColors.error, + size: 20, + ), + ], + ), + ); + } + + Widget _buildQuestionLogBody() { + if (_questionLogLoading) { + return const Center( + key: Key('question-log-loading-indicator'), + child: CircularProgressIndicator(color: AppColors.accent), + ); + } + + if (_questionLogError != null) { + return Center( + key: const Key('question-log-error-state'), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, color: Colors.redAccent, size: 40), + const SizedBox(height: 12), + Text( + _questionLogError!, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.textSecondary), + ), + const SizedBox(height: 12), + FilledButton( + onPressed: _loadQuestionLog, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + if (_questionLogEntries.isEmpty) { + return const Center( + key: Key('question-log-empty-state'), + child: Text( + 'No questions answered today yet.', + style: TextStyle(color: AppColors.textSecondary), + ), + ); + } + + return RefreshIndicator( + key: const Key('question-log-refresh-indicator'), + onRefresh: _loadQuestionLog, + color: AppColors.accent, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + itemCount: _questionLogEntries.length, + itemBuilder: (BuildContext context, int index) { + return _buildQuestionLogTile(_questionLogEntries[index]); + }, + ), + ); + } + + AudioPlayer get _player { + _audioPlayer ??= AudioPlayer(); + return _audioPlayer!; + } + + Future _loadSpellingAudioCache() async { + setState(() { + _audioCacheLoading = true; + _audioCacheError = null; + }); + try { + final List files = await _coverageApi.fetchSpellingAudioCache(); + if (!mounted) { + return; + } + setState(() { + _audioCacheFiles = files; + _audioCacheLoading = false; + for (final String file in files) { + _volumes.putIfAbsent(file, () => 1.0); + _normalizeFlags.putIfAbsent(file, () => false); + _savingFlags.putIfAbsent(file, () => false); + } + }); + } catch (e) { + if (!mounted) { + return; + } + setState(() { + _audioCacheError = e.toString(); + _audioCacheLoading = false; + }); + } + } + + Future _playAudio(String filename) async { + final String url = '$apiBaseUrl/v1/spelling/audio/$filename?cb=${DateTime.now().millisecondsSinceEpoch}'; + try { + if (_playingFile == filename) { + await _player.stop(); + setState(() { + _playingFile = null; + }); + } else { + await _player.stop(); + setState(() { + _playingFile = filename; + }); + _player.onPlayerComplete.first.then((_) { + if (mounted && _playingFile == filename) { + setState(() { + _playingFile = null; + }); + } + }); + await _player.setVolume((_volumes[filename] ?? 1.0).clamp(0.0, 1.0)); + await _player.play(UrlSource(url)); + } + } catch (e) { + debugPrint('Error playing audio: $e'); + if (mounted) { + setState(() { + _playingFile = null; + }); + } + } + } + + Future _saveAudioAdjustments(String filename) async { + setState(() { + _savingFlags[filename] = true; + }); + try { + if (_playingFile == filename) { + await _player.stop(); + setState(() { + _playingFile = null; + }); + } + final double vol = _volumes[filename] ?? 1.0; + final bool norm = _normalizeFlags[filename] ?? false; + await _coverageApi.adjustSpellingAudio( + filename: filename, + volume: vol, + normalize: norm, + ); + if (!mounted) return; + _showSnack('Audio levels updated successfully for $filename'); + setState(() { + _savingFlags[filename] = false; + _volumes[filename] = 1.0; + _normalizeFlags[filename] = false; + }); + _loadSpellingAudioCache(); + } catch (e) { + if (!mounted) return; + _showSnack('Error saving audio levels: $e'); + setState(() { + _savingFlags[filename] = false; + }); + } + } + + Widget _buildSpellingAudioCacheBody() { + if (_audioCacheLoading) { + return const Center( + key: Key('spelling-audio-cache-loading-indicator'), + child: CircularProgressIndicator(color: AppColors.accent), + ); + } + + if (_audioCacheError != null) { + return Center( + key: const Key('spelling-audio-cache-error-state'), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, color: Colors.redAccent, size: 40), + const SizedBox(height: 12), + Text( + _audioCacheError!, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.textSecondary), + ), + const SizedBox(height: 12), + FilledButton( + onPressed: _loadSpellingAudioCache, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + if (_audioCacheFiles.isEmpty) { + return const Center( + key: Key('spelling-audio-cache-empty-state'), + child: Text( + 'No cached spelling audio files.', + style: TextStyle(color: AppColors.textSecondary), + ), + ); + } + + return RefreshIndicator( + key: const Key('spelling-audio-cache-refresh-indicator'), + onRefresh: _loadSpellingAudioCache, + color: AppColors.accent, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + itemCount: _audioCacheFiles.length, + itemBuilder: (BuildContext context, int index) { + final String filename = _audioCacheFiles[index]; + final bool isPlaying = _playingFile == filename; + + final double vol = _volumes[filename] ?? 1.0; + final bool norm = _normalizeFlags[filename] ?? false; + final bool saving = _savingFlags[filename] ?? false; + + return Container( + key: Key('audio-file-$filename'), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.surfaceElevated, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: (isPlaying ? AppColors.accent : AppColors.surfaceElevated) + .withValues(alpha: 0.15), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + IconButton( + key: Key('play-button-$filename'), + tooltip: isPlaying ? 'Stop' : 'Listen', + icon: Icon( + isPlaying ? Icons.stop : Icons.volume_up, + color: isPlaying ? AppColors.accent : AppColors.textSecondary, + ), + onPressed: () => _playAudio(filename), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + filename, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 13, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + const Divider(height: 16), + Padding( + padding: const EdgeInsets.only(top: 4, bottom: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Volume Level: ${vol.toStringAsFixed(1)}x', + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + Row( + children: [ + const Icon(Icons.volume_down, color: AppColors.textSecondary, size: 16), + Expanded( + child: Slider( + key: Key('volume-slider-$filename'), + value: vol, + min: 0.1, + max: 2.0, + divisions: 19, + activeColor: AppColors.accent, + inactiveColor: AppColors.surface, + onChanged: saving ? null : (double val) { + setState(() { + _volumes[filename] = val; + }); + }, + ), + ), + const Icon(Icons.volume_up, color: AppColors.textSecondary, size: 16), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + SizedBox( + height: 24, + width: 24, + child: Checkbox( + key: Key('normalize-checkbox-$filename'), + value: norm, + activeColor: AppColors.accent, + onChanged: saving ? null : (bool? val) { + setState(() { + _normalizeFlags[filename] = val ?? false; + }); + }, + ), + ), + const SizedBox(width: 8), + const Text( + 'Loudness Normalization', + style: TextStyle( + fontSize: 13, + color: AppColors.textPrimary, + ), + ), + const Spacer(), + if (saving) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.accent, + ), + ) + else + FilledButton( + key: Key('save-adjust-$filename'), + style: FilledButton.styleFrom( + backgroundColor: AppColors.accent, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + onPressed: () => _saveAudioAdjustments(filename), + child: const Text('Save to Server'), + ), + ], + ), + ], + ), + ), + ], + ), + ); + }, + ), + ); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Market history log'), - actions: [ - IconButton( - key: const Key('question-audit-button'), - tooltip: 'Audit prospective question data', - onPressed: _questionAuditLoading ? null : _showQuestionAudit, - icon: const Icon(Icons.help_outline), - ), - IconButton( - key: const Key('week-coverage-button'), - tooltip: 'Trading-week sync calendar', - onPressed: _weekCoverageLoading ? null : _showWeekCoverage, - icon: const Icon(Icons.calendar_month_outlined), - ), - PopupMenuButton( - key: const Key('actions-menu'), - enabled: !_actionsDisabled, - onSelected: (String value) { - switch (value) { - case 'resync': - _runResync(); - case 'cleanup': - _confirmAndRunCleanup(); - } - }, - itemBuilder: (BuildContext context) => >[ - const PopupMenuItem( - key: Key('action-resync'), - value: 'resync', - child: Text('Run backfill now'), + title: DropdownButtonHideUnderline( + child: DropdownButton( + key: const Key('admin-portal-dropdown'), + value: _selectedAdminOption, + isExpanded: true, + dropdownColor: AppColors.surfaceElevated, + icon: const Icon(Icons.arrow_drop_down, color: AppColors.textSecondary), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w600, + ), + items: const >[ + DropdownMenuItem( + value: 'market_history_log', + child: Text( + 'Market history log', + overflow: TextOverflow.ellipsis, + ), ), - const PopupMenuItem( - key: Key('action-cleanup'), - value: 'cleanup', - child: Text('Run cleanup now'), + DropdownMenuItem( + value: 'market_question_log', + child: Text( + 'Market Question Log', + overflow: TextOverflow.ellipsis, + ), + ), + DropdownMenuItem( + value: 'spelling_audio_cache', + child: Text( + 'Spelling Audio Cache', + overflow: TextOverflow.ellipsis, + ), ), ], + onChanged: (String? value) { + if (value != null) { + setState(() { + _selectedAdminOption = value; + }); + if (value == 'market_question_log') { + _loadQuestionLog(); + } else if (value == 'spelling_audio_cache') { + _loadSpellingAudioCache(); + } + } + }, ), - IconButton( - key: const Key('refresh-button'), - tooltip: 'Refresh', - onPressed: _state.isLoading ? null : _refresh, - icon: const Icon(Icons.refresh), - ), - ], + ), + actions: _selectedAdminOption == 'market_question_log' + ? [ + IconButton( + key: const Key('question-log-refresh-button'), + tooltip: 'Refresh', + onPressed: _questionLogLoading ? null : _loadQuestionLog, + icon: const Icon(Icons.refresh), + ), + ] + : _selectedAdminOption == 'spelling_audio_cache' + ? [ + IconButton( + key: const Key('audio-cache-refresh-button'), + tooltip: 'Refresh', + onPressed: _audioCacheLoading ? null : _loadSpellingAudioCache, + icon: const Icon(Icons.refresh), + ), + ] + : [ + IconButton( + key: const Key('question-audit-button'), + tooltip: 'Audit prospective question data', + onPressed: _questionAuditLoading ? null : _showQuestionAudit, + icon: const Icon(Icons.help_outline), + ), + IconButton( + key: const Key('week-coverage-button'), + tooltip: 'Trading-week sync calendar', + onPressed: _weekCoverageLoading ? null : _showWeekCoverage, + icon: const Icon(Icons.calendar_month_outlined), + ), + PopupMenuButton( + key: const Key('actions-menu'), + enabled: !_actionsDisabled, + onSelected: (String value) { + switch (value) { + case 'resync': + _runResync(); + case 'cleanup': + _confirmAndRunCleanup(); + } + }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem( + key: Key('action-resync'), + value: 'resync', + child: Text('Run backfill now'), + ), + const PopupMenuItem( + key: Key('action-cleanup'), + value: 'cleanup', + child: Text('Run cleanup now'), + ), + ], + ), + IconButton( + key: const Key('refresh-button'), + tooltip: 'Refresh', + onPressed: _state.isLoading ? null : _refresh, + icon: const Icon(Icons.refresh), + ), + ], ), - body: _buildBody(), + body: _selectedAdminOption == 'market_question_log' + ? _buildQuestionLogBody() + : _selectedAdminOption == 'spelling_audio_cache' + ? _buildSpellingAudioCacheBody() + : _buildBody(), ); } diff --git a/lib/admin/services/market_history_admin_api.dart b/lib/admin/services/market_history_admin_api.dart index 31c098a..1feade4 100644 --- a/lib/admin/services/market_history_admin_api.dart +++ b/lib/admin/services/market_history_admin_api.dart @@ -161,6 +161,103 @@ class MarketHistoryAdminApi { return MarketHistoryWeekCoverageReport.fromJson(body); } + Future> fetchMarketQuestionLog({DateTime? asOf}) async { + final String? token = await _tokenProvider(); + if (token == null || token.isEmpty) { + throw MarketHistoryAdminApiException('Missing auth token'); + } + + final Map query = {}; + if (asOf != null) { + query['asOf'] = asOf.toUtc().toIso8601String(); + } + final Uri uri = Uri.parse('$_baseUrl/v1/admin/market-history/question-log') + .replace(queryParameters: query.isEmpty ? null : query); + + final http.Response response = await _client.get( + uri, + headers: { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + + if (response.statusCode != 200) { + throw MarketHistoryAdminApiException( + response.body, + statusCode: response.statusCode, + ); + } + + final Map body = + jsonDecode(response.body) as Map; + final List raw = body['entries'] as List? ?? []; + return raw.cast().toList(); + } + + Future> fetchSpellingAudioCache() async { + final String? token = await _tokenProvider(); + if (token == null || token.isEmpty) { + throw MarketHistoryAdminApiException('Missing auth token'); + } + + final Uri uri = Uri.parse('$_baseUrl/v1/admin/market-history/spelling-audio-cache'); + final http.Response response = await _client.get( + uri, + headers: { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + + if (response.statusCode != 200) { + throw MarketHistoryAdminApiException( + response.body, + statusCode: response.statusCode, + ); + } + + final Map body = + jsonDecode(response.body) as Map; + final List raw = body['files'] as List? ?? []; + return raw.cast().toList(); + } + + Future adjustSpellingAudio({ + required String filename, + double? volume, + bool? normalize, + }) async { + final String? token = await _tokenProvider(); + if (token == null || token.isEmpty) { + throw MarketHistoryAdminApiException('Missing auth token'); + } + + final Uri uri = Uri.parse('$_baseUrl/v1/admin/market-history/spelling-audio-cache/adjust'); + final http.Response response = await _client.post( + uri, + headers: { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: jsonEncode({ + 'filename': filename, + if (volume != null) 'volume': volume, + if (normalize != null) 'normalize': normalize, + }), + ); + + if (response.statusCode != 200) { + throw MarketHistoryAdminApiException( + response.body, + statusCode: response.statusCode, + ); + } + } + static List _parseEventList(dynamic raw, String field) { if (raw == null) { return []; diff --git a/lib/models/question_submit_result.dart b/lib/models/question_submit_result.dart index 8286598..5cb8309 100644 --- a/lib/models/question_submit_result.dart +++ b/lib/models/question_submit_result.dart @@ -7,9 +7,11 @@ class QuestionSubmitResult { required this.unansweredCount, required this.score, this.nextQuestion, + this.correctValue, }); final int unansweredCount; final GuessScoreSummary score; final IncomingQuestion? nextQuestion; + final num? correctValue; } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 0a63efd..42fa479 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -2,10 +2,8 @@ import 'package:flutter/material.dart'; import '../admin/widgets/admin_app_bar_action.dart'; import '../models/app_user.dart'; -import '../models/guess_score_summary.dart'; import '../models/incoming_question.dart'; import '../models/question_source_preference.dart'; -import '../utils/guess_slot_format.dart'; import '../models/sync_result.dart'; import '../models/user_profile.dart'; import '../repositories/user_profile_repository.dart'; @@ -61,7 +59,6 @@ class HomeScreen extends StatelessWidget { QuestionsHubService.instance.hasPendingQuestion, QuestionsHubService.instance.pendingQuestion, QuestionsHubService.instance.pendingQuestionCount, - QuestionsHubService.instance.guessScoreSummary, QuestionsHubService.instance.questionPanelOpen, QuestionsHubService.instance.questionQueue, QuestionSourceConfigRepository.instance.preferences, @@ -79,44 +76,22 @@ class HomeScreen extends StatelessWidget { 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: [ - if (showMarketScore) - _CumulativeScoreChip( - summary: score, - onPressed: () => - _showGuessScoreStatsDialog(context, score), - ), - if (showMarketScore && showEnvelope) - const SizedBox(width: 8), - 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(), - ), - ], - ], + if (!showEnvelope) { + return const SizedBox.shrink(); + } + return 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(), ); }, ), @@ -425,166 +400,6 @@ class HomeScreen extends StatelessWidget { } } -void _showGuessScoreStatsDialog(BuildContext context, GuessScoreSummary summary) { - final String totalText = _formatScoreNumber(summary.total); - final String percentText = _formatScoreNumber(summary.percentCorrect); - final String? slotText = summary.slotStart == null - ? null - : formatGuessSlotRange( - slotStart: summary.slotStart!, - newerSlotStart: summary.newerSlotStart, - ); - - showDialog( - context: context, - builder: (BuildContext dialogContext) { - return AlertDialog( - key: const Key('guess-score-stats-dialog'), - title: const Text('Score statistics'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (slotText != null) - _ScoreStatRow( - key: const Key('guess-score-slot-row'), - label: 'Time slot', - value: slotText, - ), - _ScoreStatRow(label: 'Total score', value: totalText), - _ScoreStatRow( - label: 'Questions answered', - value: '${summary.answersTotal}', - ), - _ScoreStatRow(label: 'Percent correct', value: '$percentText%'), - ], - ), - actions: [ - TextButton( - key: const Key('guess-score-reset-button'), - onPressed: () => _confirmResetGuessScore(dialogContext), - child: const Text('Reset'), - ), - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('Close'), - ), - ], - ); - }, - ); -} - -String _formatScoreNumber(num value) { - return value == value.roundToDouble() - ? value.toStringAsFixed(0) - : value.toStringAsFixed(2); -} - -Future _confirmResetGuessScore(BuildContext context) async { - final bool? confirmed = await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - key: const Key('guess-score-reset-confirm-dialog'), - title: const Text('Reset score?'), - content: const Text( - 'This clears your total score and answer statistics. ' - 'You cannot undo this.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - key: const Key('guess-score-reset-confirm-button'), - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Reset'), - ), - ], - ); - }, - ); - if (confirmed != true || !context.mounted) { - return; - } - - Navigator.of(context).pop(); - await QuestionsHubService.instance.resetGuessScoreSummary(); -} - -class _ScoreStatRow extends StatelessWidget { - const _ScoreStatRow({ - super.key, - required this.label, - required this.value, - }); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label, style: Theme.of(context).textTheme.bodyLarge), - Text( - value, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w700, - color: AppColors.accent, - ), - ), - ], - ), - ); - } -} - -class _CumulativeScoreChip extends StatelessWidget { - const _CumulativeScoreChip({ - required this.summary, - required this.onPressed, - }); - - final GuessScoreSummary summary; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - final String scoreText = summary.total == summary.total.roundToDouble() - ? summary.total.toStringAsFixed(0) - : summary.total.toStringAsFixed(2); - return Material( - color: Colors.transparent, - child: InkWell( - key: const Key('topbar-cumulative-score'), - onTap: onPressed, - borderRadius: BorderRadius.circular(999), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: AppColors.accent.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: AppColors.accent.withValues(alpha: 0.35)), - ), - child: Text( - 'Score: $scoreText', - style: const TextStyle( - color: AppColors.accent, - fontWeight: FontWeight.w700, - fontSize: 12, - ), - ), - ), - ), - ); - } -} class _SyncStatusChip extends StatelessWidget { const _SyncStatusChip({required this.status}); diff --git a/lib/services/questions_api_service.dart b/lib/services/questions_api_service.dart index 0ef9375..f54ad35 100644 --- a/lib/services/questions_api_service.dart +++ b/lib/services/questions_api_service.dart @@ -128,6 +128,7 @@ class QuestionsApiService { nextQuestion: nextQuestionJson == null ? null : IncomingQuestion.fromJson(nextQuestionJson), + correctValue: body['correctValue'] as num?, ); } diff --git a/lib/services/questions_hub_service.dart b/lib/services/questions_hub_service.dart index 2ac9175..4eba2ee 100644 --- a/lib/services/questions_hub_service.dart +++ b/lib/services/questions_hub_service.dart @@ -402,10 +402,10 @@ class QuestionsHubService { } /// Swipe right: submit slider value as the answer. - Future submitCurrentAnswer({num answer = 0}) async { + Future submitCurrentAnswer({num answer = 0}) async { final IncomingQuestion? question = currentQuestion; if (question == null || questionActionBusy.value) { - return; + return null; } num effectiveAnswer = answer; @@ -413,14 +413,14 @@ class QuestionsHubService { effectiveAnswer = 1; } - await _submitAnswer( + return await _submitAnswer( answeredQuestionId: question.id, question: question, answer: effectiveAnswer, ); } - Future _submitAnswer({ + Future _submitAnswer({ required String answeredQuestionId, required IncomingQuestion question, num? answer, @@ -435,7 +435,8 @@ class QuestionsHubService { answerText: answerText, ); if (result == null) { - return; + questionActionBusy.value = false; + return null; } if (question.usesSpellingWordTile) { @@ -450,66 +451,99 @@ class QuestionsHubService { guessScoreSummary.value = result.score; } - if (result.nextQuestion != null && - _acceptsQuestionSource(result.nextQuestion!)) { - IncomingQuestion nextQ = result.nextQuestion!; - if (nextQ.needsPrepare) { + if (question.isMarketHistory && result.correctValue != null) { + unawaited(Future.delayed(const Duration(seconds: 2)).then((_) async { try { - final IncomingQuestion? prepared = await _api.prepareSpellingQuestion( - questionId: nextQ.id, + await _advanceQueue( + result: result, + question: question, + answeredQuestionId: answeredQuestionId, ); - if (prepared != null) { - nextQ = prepared; - } - } catch (e) { - debugPrint('Failed to prepare next question in submit: $e'); + } finally { + questionActionBusy.value = false; } + })); + return result.correctValue; + } else { + try { + await _advanceQueue( + result: result, + question: question, + answeredQuestionId: answeredQuestionId, + ); + } finally { + questionActionBusy.value = false; } - _setActiveQuestion( - nextQ, - unansweredCount: result.unansweredCount, - ); - return; + return null; } - - if (result.unansweredCount == 0) { - if (_shouldKeepPanelOpenWithoutQuestion()) { - pendingQuestion.value = null; - questionQueue.value = []; - hasPendingQuestion.value = true; - questionPanelOpen.value = true; - } else { - _clearPendingUi(); - } - return; - } - - final List refreshed = await _fetchUnansweredForPanel(); - final List remaining = refreshed - .where((IncomingQuestion q) => q.id != answeredQuestionId) - .toList(); - if (remaining.isEmpty) { - if (_shouldKeepPanelOpenWithoutQuestion()) { - pendingQuestion.value = null; - questionQueue.value = []; - hasPendingQuestion.value = true; - questionPanelOpen.value = true; - } else { - _clearPendingUi(); - } - return; - } - - _setActiveQuestion( - remaining.first, - unansweredCount: result.unansweredCount, - queue: remaining, - ); - } finally { + } catch (e) { questionActionBusy.value = false; + rethrow; } } + Future _advanceQueue({ + required QuestionSubmitResult result, + required IncomingQuestion question, + required String answeredQuestionId, + }) async { + if (result.nextQuestion != null && + _acceptsQuestionSource(result.nextQuestion!)) { + IncomingQuestion nextQ = result.nextQuestion!; + if (nextQ.needsPrepare) { + try { + final IncomingQuestion? prepared = await _api.prepareSpellingQuestion( + questionId: nextQ.id, + ); + if (prepared != null) { + nextQ = prepared; + } + } catch (e) { + debugPrint('Failed to prepare next question in submit: $e'); + } + } + _setActiveQuestion( + nextQ, + unansweredCount: result.unansweredCount, + ); + return; + } + + if (result.unansweredCount == 0) { + if (_shouldKeepPanelOpenWithoutQuestion()) { + pendingQuestion.value = null; + questionQueue.value = []; + hasPendingQuestion.value = true; + questionPanelOpen.value = true; + } else { + _clearPendingUi(); + } + return; + } + + final List refreshed = await _fetchUnansweredForPanel(); + final List remaining = refreshed + .where((IncomingQuestion q) => q.id != answeredQuestionId) + .toList(); + if (remaining.isEmpty) { + if (_shouldKeepPanelOpenWithoutQuestion()) { + pendingQuestion.value = null; + questionQueue.value = []; + hasPendingQuestion.value = true; + questionPanelOpen.value = true; + } else { + _clearPendingUi(); + } + return; + } + + _setActiveQuestion( + remaining.first, + unansweredCount: result.unansweredCount, + queue: remaining, + ); + } + /// Makes [question] the sole active card (queue head matches [pendingQuestion]). void _setActiveQuestion( IncomingQuestion question, { diff --git a/lib/services/spelling_tts/spelling_tts_flutter.dart b/lib/services/spelling_tts/spelling_tts_flutter.dart index adf8d11..a6c4f88 100644 --- a/lib/services/spelling_tts/spelling_tts_flutter.dart +++ b/lib/services/spelling_tts/spelling_tts_flutter.dart @@ -1,9 +1,101 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:cyberhybridhub/config/api_config.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_tts/flutter_tts.dart'; +import 'package:http/http.dart' as http; import 'spelling_tts_backend.dart'; -SpellingTtsBackend createSpellingTtsBackend() => _FlutterTtsBackend(); +SpellingTtsBackend createSpellingTtsBackend() => + _FlutterDictionaryFallbackBackend(_FlutterTtsBackend()); + +class _FlutterDictionaryFallbackBackend implements SpellingTtsBackend { + _FlutterDictionaryFallbackBackend(this._fallback); + + final SpellingTtsBackend _fallback; + final AudioPlayer _player = AudioPlayer(); + Completer? _completer; + StreamSubscription? _onCompleteSubscription; + + Future _fetchAudioBytes(String word) async { + final String query = word.trim().toLowerCase(); + if (query.isEmpty) return null; + try { + final String encoded = Uri.encodeComponent(query); + final String url = '$apiBaseUrl/v1/spelling/audio/$encoded.mp3'; + final response = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 8)); + if (response.statusCode == 200) { + return response.bodyBytes; + } + } catch (e) { + debugPrint('Client audio fetch failed: $e'); + } + return null; + } + + @override + Future speak(String text, {String? title}) async { + await stop(); + final String trimmed = text.trim(); + if (trimmed.isEmpty) return; + + final Uint8List? bytes = await _fetchAudioBytes(trimmed); + if (bytes != null) { + try { + final Completer completer = Completer(); + _completer = completer; + + _onCompleteSubscription = _player.onPlayerComplete.listen( + (_) { + if (!completer.isCompleted) completer.complete(); + }, + onError: (Object err) { + if (!completer.isCompleted) completer.completeError(err); + }, + onDone: () { + if (!completer.isCompleted) completer.complete(); + }, + ); + + await _player.play(BytesSource(bytes)); + await completer.future.timeout( + const Duration(seconds: 10), + onTimeout: () => debugPrint('Native audio playback timed out'), + ); + } catch (e) { + debugPrint('Native audio play failed, falling back to TTS: $e'); + await _fallback.speak(trimmed, title: title); + } finally { + await _onCompleteSubscription?.cancel(); + _onCompleteSubscription = null; + _completer = null; + } + } else { + await _fallback.speak(trimmed, title: title); + } + } + + @override + Future stop() async { + final Completer? completer = _completer; + _completer = null; + await _onCompleteSubscription?.cancel(); + _onCompleteSubscription = null; + try { + await _player.stop(); + } catch (e) { + debugPrint('Failed to stop audio player: $e'); + } + if (completer != null && !completer.isCompleted) { + completer.complete(); + } + await _fallback.stop(); + } +} + class _FlutterTtsBackend implements SpellingTtsBackend { final FlutterTts _tts = FlutterTts(); diff --git a/lib/services/spelling_tts/spelling_tts_web.dart b/lib/services/spelling_tts/spelling_tts_web.dart index ee25be2..47af155 100644 --- a/lib/services/spelling_tts/spelling_tts_web.dart +++ b/lib/services/spelling_tts/spelling_tts_web.dart @@ -1,11 +1,101 @@ import 'dart:async'; import 'dart:js_interop'; +import 'package:cyberhybridhub/config/api_config.dart'; import 'package:flutter/foundation.dart'; import 'spelling_tts_backend.dart'; -SpellingTtsBackend createSpellingTtsBackend() => _WebSpeechBackend(); +SpellingTtsBackend createSpellingTtsBackend() => + _WebDictionaryFallbackBackend(_WebSpeechBackend()); + +@JS('Audio') +extension type JSAudio._(JSObject _) implements JSObject { + external JSAudio([String? src]); + external JSPromise play(); + external void pause(); + external void addEventListener(String type, JSFunction listener); + external void removeEventListener(String type, JSFunction listener); +} + +class _WebDictionaryFallbackBackend implements SpellingTtsBackend { + _WebDictionaryFallbackBackend(this._fallback); + + final SpellingTtsBackend _fallback; + JSAudio? _currentAudio; + Completer? _audioPlayCompleter; + + @override + Future speak(String text, {String? title}) async { + await stop(); + final String trimmed = text.trim(); + if (trimmed.isEmpty) return; + + final String query = trimmed.toLowerCase(); + final String encoded = Uri.encodeComponent(query); + final String audioUrl = '$apiBaseUrl/v1/spelling/audio/$encoded.mp3'; + + try { + final JSAudio audio = JSAudio(audioUrl); + _currentAudio = audio; + final Completer completer = Completer(); + _audioPlayCompleter = completer; + + bool hasError = false; + final JSFunction onEnded = (() { + if (!completer.isCompleted) completer.complete(); + }).toJS; + + final JSFunction onError = ((JSAny event) { + hasError = true; + if (!completer.isCompleted) completer.complete(); + }).toJS; + + audio.addEventListener('ended', onEnded); + audio.addEventListener('error', onError); + + try { + await audio.play().toDart; + await completer.future.timeout( + const Duration(seconds: 10), + onTimeout: () => debugPrint('Web audio playback timed out'), + ); + if (hasError) { + throw Exception('HTMLAudio element error event fired'); + } + } catch (e) { + debugPrint('Web HTMLAudio play failed, falling back to TTS: $e'); + await _fallback.speak(trimmed, title: title); + } finally { + audio.removeEventListener('ended', onEnded); + audio.removeEventListener('error', onError); + } + } catch (e, st) { + debugPrint('Web audio playback setup error: $e\n$st'); + await _fallback.speak(trimmed, title: title); + } + } + + @override + Future stop() async { + final Completer? completer = _audioPlayCompleter; + _audioPlayCompleter = null; + final JSAudio? audio = _currentAudio; + _currentAudio = null; + if (audio != null) { + try { + audio.pause(); + } catch (e) { + debugPrint('Failed to pause web audio: $e'); + } + } + if (completer != null && !completer.isCompleted) { + completer.complete(); + } + await _fallback.stop(); + } +} + @JS('speechSynthesis') external SpeechSynthesis get _speechSynthesis; diff --git a/lib/widgets/question_presentation_router.dart b/lib/widgets/question_presentation_router.dart index c9f9fbc..e3f3031 100644 --- a/lib/widgets/question_presentation_router.dart +++ b/lib/widgets/question_presentation_router.dart @@ -17,7 +17,7 @@ class QuestionPresentationRouter extends StatelessWidget { }); final IncomingQuestion question; - final Future Function(num answer) onAnswer; + final Future Function(num answer) onAnswer; final Future Function(String answerText)? onAnswerText; final ValueChanged? onPrepared; final Future Function() onDefer; diff --git a/lib/widgets/question_source_settings_sheet.dart b/lib/widgets/question_source_settings_sheet.dart index 4c1be53..b94dca0 100644 --- a/lib/widgets/question_source_settings_sheet.dart +++ b/lib/widgets/question_source_settings_sheet.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../models/guess_score_summary.dart'; import '../models/question_source_preference.dart'; import '../repositories/question_source_config_repository.dart'; import '../routes/app_routes.dart'; import '../services/questions_hub_service.dart'; import '../theme/app_theme.dart'; +import '../utils/guess_slot_format.dart'; /// Configure which question API sources are active and their relative mix. class QuestionSourceSettingsSheet extends StatefulWidget { @@ -459,6 +461,23 @@ class _QuestionSourceSettingsSheetState extends State { onPressed: widget.resetSpellingBusy ? null : widget.onResetSpelling, icon: const Icon(Icons.restart_alt, size: 20), ), + if (widget.source.id == 'market_history') + ValueListenableBuilder( + valueListenable: QuestionsHubService.instance.guessScoreSummary, + builder: (BuildContext context, GuessScoreSummary score, Widget? child) { + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: _CumulativeScoreChip( + summary: score, + onPressed: () => _showGuessScoreStatsDialog(context, score), + ), + ); + }, + ), Switch( key: Key('question-source-enabled-${widget.source.id}'), value: widget.source.enabled, @@ -723,3 +755,164 @@ class _QuestionSourceRowState extends State<_QuestionSourceRow> { ); } } + +void _showGuessScoreStatsDialog(BuildContext context, GuessScoreSummary summary) { + final String totalText = _formatScoreNumber(summary.total); + final String percentText = _formatScoreNumber(summary.percentCorrect); + final String? slotText = summary.slotStart == null + ? null + : formatGuessSlotRange( + slotStart: summary.slotStart!, + newerSlotStart: summary.newerSlotStart, + ); + + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + key: const Key('guess-score-stats-dialog'), + title: const Text('Score statistics'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (slotText != null) + _ScoreStatRow( + key: const Key('guess-score-slot-row'), + label: 'Time slot', + value: slotText, + ), + _ScoreStatRow(label: 'Total score', value: totalText), + _ScoreStatRow( + label: 'Questions answered', + value: '${summary.answersTotal}', + ), + _ScoreStatRow(label: 'Percent correct', value: '$percentText%'), + ], + ), + actions: [ + TextButton( + key: const Key('guess-score-reset-button'), + onPressed: () => _confirmResetGuessScore(dialogContext), + child: const Text('Reset'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Close'), + ), + ], + ); + }, + ); +} + +String _formatScoreNumber(num value) { + return value == value.roundToDouble() + ? value.toStringAsFixed(0) + : value.toStringAsFixed(2); +} + +Future _confirmResetGuessScore(BuildContext context) async { + final bool? confirmed = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + key: const Key('guess-score-reset-confirm-dialog'), + title: const Text('Reset score?'), + content: const Text( + 'This clears your total score and answer statistics. ' + 'You cannot undo this.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + key: const Key('guess-score-reset-confirm-button'), + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Reset'), + ), + ], + ); + }, + ); + if (confirmed != true || !context.mounted) { + return; + } + + Navigator.of(context).pop(); + await QuestionsHubService.instance.resetGuessScoreSummary(); +} + +class _ScoreStatRow extends StatelessWidget { + const _ScoreStatRow({ + super.key, + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: Theme.of(context).textTheme.bodyLarge), + Text( + value, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w700, + color: AppColors.accent, + ), + ), + ], + ), + ); + } +} + +class _CumulativeScoreChip extends StatelessWidget { + const _CumulativeScoreChip({ + required this.summary, + required this.onPressed, + }); + + final GuessScoreSummary summary; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final String scoreText = summary.total == summary.total.roundToDouble() + ? summary.total.toStringAsFixed(0) + : summary.total.toStringAsFixed(2); + return Material( + color: Colors.transparent, + child: InkWell( + key: const Key('topbar-cumulative-score'), + onTap: onPressed, + borderRadius: BorderRadius.circular(999), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: AppColors.accent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: AppColors.accent.withValues(alpha: 0.35)), + ), + child: Text( + 'Score: $scoreText', + style: const TextStyle( + color: AppColors.accent, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/swipe_question_tile.dart b/lib/widgets/swipe_question_tile.dart index 80ba6e8..59fe55b 100644 --- a/lib/widgets/swipe_question_tile.dart +++ b/lib/widgets/swipe_question_tile.dart @@ -41,7 +41,7 @@ class SwipeQuestionTile extends StatefulWidget { final String questionId; final String? questionText; - final Future Function(num answer) onSwipeRight; + final Future Function(num answer) onSwipeRight; final Future Function() onSwipeLeft; final bool busy; @@ -86,7 +86,7 @@ class _SwipeQuestionTileState extends State bool get _atZero => widget.showGuidGlyph ? _snappedSliderValue == 0 : false; - bool get _holdSaveEnabled => !widget.busy && !_acting && !_atZero; + bool get _holdSaveEnabled => !widget.busy && !_acting; bool get _holdDeferEnabled => !widget.busy && !_acting; @@ -249,7 +249,7 @@ class _SwipeQuestionTileState extends State } Future _completeHoldSave() async { - if (_acting || widget.busy || _atZero || !mounted) { + if (_acting || widget.busy || !mounted) { return; } final double width = MediaQuery.sizeOf(context).width; @@ -257,7 +257,15 @@ class _SwipeQuestionTileState extends State _acting = true; _dragOffset = width; }); - await widget.onSwipeRight(_snappedSliderValue); + final num? correctValue = await widget.onSwipeRight(_snappedSliderValue); + if (correctValue != null && mounted) { + setState(() { + _dragOffset = 0; + final int snappedCorrect = correctValue.round().clamp(_sliderMin, _sliderMax); + _verticalOffset = snappedCorrect / _sliderMax * _maxVerticalDrag; + }); + await Future.delayed(const Duration(seconds: 2)); + } if (mounted) { setState(() { _acting = false; @@ -602,7 +610,7 @@ class _SwipeQuestionTileState extends State } Future _releaseDrag() async { - if (_acting || widget.busy || _atZero) { + if (_acting || widget.busy) { setState(() => _dragOffset = 0); return; } @@ -612,7 +620,15 @@ class _SwipeQuestionTileState extends State _acting = true; _dragOffset = MediaQuery.sizeOf(context).width; }); - await widget.onSwipeRight(_snappedSliderValue); + final num? correctValue = await widget.onSwipeRight(_snappedSliderValue); + if (correctValue != null && mounted) { + setState(() { + _dragOffset = 0; + final int snappedCorrect = correctValue.round().clamp(_sliderMin, _sliderMax); + _verticalOffset = snappedCorrect / _sliderMax * _maxVerticalDrag; + }); + await Future.delayed(const Duration(seconds: 2)); + } } else if (_dragOffset < -_swipeThreshold) { setState(() { _acting = true; diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 4c0025f..40c1195 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 75d875c..09be552 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux sqlite3_flutter_libs url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index fff0edd..6557311 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import audioplayers_darwin import connectivity_plus import file_picker import firebase_auth @@ -16,6 +17,7 @@ import sqlite3_flutter_libs import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 5c67b8e..33b59a0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: f16640453cc47487b7de72a2b28d37c7df1ac97999849f4a46d92b1d2b0f093d + url: "https://pub.dev" + source: hosted + version: "6.7.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91 + url: "https://pub.dev" + source: hosted + version: "6.4.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "24a6f258062bd7da8cb2157e83fccb9816a08dd306cbaaa24f9813d071470545" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "95f875a96c88c3dbbcb608d4f8288e300b0113d256a81d0b3197fcc18f0dc91a" + url: "https://pub.dev" + source: hosted + version: "4.3.1" boolean_selector: dependency: transitive description: @@ -989,6 +1045,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153" + url: "https://pub.dev" + source: hosted + version: "3.4.1" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index aad0eb5..cabf127 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,6 +49,7 @@ dependencies: logging: ^1.3.0 file_picker: ^11.0.2 flutter_tts: ^4.2.2 + audioplayers: ^6.0.0 dev_dependencies: flutter_test: diff --git a/question_system_expantion_guide.md b/question_system_expantion_guide.md new file mode 100644 index 0000000..8431801 --- /dev/null +++ b/question_system_expantion_guide.md @@ -0,0 +1,202 @@ +# Question System Expansion Guide + +This document explains the architecture, databases, and control flow of the Question System in CyberHybridHub. It serves as a design reference and checklist for developers who wish to introduce new types of questions. + +--- + +## 1. System Overview & Architecture + +The question system serves active, unanswered questions to users based on weighted random selection ratios. Currently, it supports: +- **Market History Questions**: Prospective price-movement guessing games where users swipe to guess price directions. +- **Spelling Questions**: Audio-driven quiz items where users listen to a word read aloud via Text-to-Speech (TTS) and type the correct spelling. + +### System Diagram + +```mermaid +graph TD + Client[Flutter Client] <-->|SignalR / HTTP| API[Shelf API Router] + API <--> Dispatcher[QuestionDispatcher] + Dispatcher <--> |Weighted Mix Roll| Mix[QuestionSourceMix] + Dispatcher <--> QService[QuestionService] + Dispatcher <--> Pipeline[QuestionPipeline] + + QService <--> |Create & Fetch| DB[(PostgreSQL)] + Pipeline <--> |Evaluate & Transition| DB + + subgraph Client-Side Presentation + Router[QuestionPresentationRouter] -->|usesSpellingWordTile| SpellingTile[SpellingQuestionTile] + Router -->|isMarket / Swipe| SwipeTile[SwipeQuestionTile] + end + Client --> Router +``` + +--- + +## 2. Core Database Schema + +The questions and their pipeline states are stored in the following PostgreSQL tables: + +### The `questions` Table +Tracks individual questions generated for, queued for, or answered by a user. +- `id` (`UUID PRIMARY KEY`): Unique question identifier. +- `assigned_user_id` (`TEXT`): References the user's Firebase UID. +- `question_text` (`TEXT`): The text prompt displayed to the user. +- `user_response` (`NUMERIC`): Stores numeric answers (e.g., slider inputs or swipe values like `10` / `-10`). +- `user_response_text` (`TEXT`): Stores string-based typed answers (e.g., spelling inputs). +- `correct_answer` (`NUMERIC`): The correct answer/benchmark to grade against. +- `created_at` / `modified_at` (`TIMESTAMPTZ`): Auditing and ordering timestamps. +- `source_tag` (`TEXT`): Category tag (e.g., `market_history:prospective`, `spelling:word_quiz`). +- `pipeline_key` (`TEXT`): The active pipeline context (e.g., `trading`, `spelling`, `weather`). +- `pipeline_step` (`TEXT`): Active step within the pipeline (e.g., `spelling_word_quiz`). +- `metadata` (`JSONB`): Key-value payload for question-specific properties (e.g., symbol tickers, audio URLs, canonical word answers). + +### The `user_pipeline_state` Table +Maintains multi-step session progress for branching or sequential question chains. +- `assigned_user_id` (`TEXT PRIMARY KEY`): User's Firebase UID. +- `pipeline_key` (`TEXT`): The active branching pipeline identifier. +- `step` (`TEXT`): The current step index or name (e.g., `temperature_quiz`, `idle`). +- `context` (`JSONB`): Arbitrary session context (e.g., weather facts, selected words, target numbers). +- `updated_at` (`TIMESTAMPTZ`): Last state transition timestamp. + +--- + +## 3. Configuration & Mix Ratios + +The ratio of different questions served to a user is governed by **Question Source Preferences**. + +### Definition & Mix Allocation +Source definitions are registered statically in [question_source_preferences.dart](file:///home/chh/cyberhybridhub.com/server/lib/question_source_preferences.dart): +```dart +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', + ), +]; +``` + +1. **Preference Selection**: Users can enable/disable individual question sources and configure their relative weights (ratios) from 0 to 100 via the settings panel. +2. **Effective Mix**: The server resolves configuration details and computes the normalized relative percentages using `QuestionSourcePreferences.effectiveMix()`. +3. **Dispatcher Selection**: When dispatching or queueing questions, the system calls `QuestionSourceMix.pickNextSourceId()` to perform a weighted random roll matching the configured percentages. +4. **Zero-Ratio Suppression**: If a source is enabled but configured with a ratio of `0`, it is suppressed and will not yield questions. + +--- + +## 4. Question Lifecycle & Serving Flow + +### 1. Queue Maintenance & Seeding +- The server maintains a small cache of unanswered questions for each active user (typically limited to `maxQueuedQuestions = 3`). +- The periodic cron or a login event invokes `QuestionDispatcher.topUpQueue()` in [question_dispatcher.dart](file:///home/chh/cyberhybridhub.com/server/lib/question_dispatcher.dart). +- If the current count of unanswered questions is below the threshold, the dispatcher rolls for a preferred source and attempts to produce one question using `_produceFromSource()`. If it fails or is capped, it rolls over to other enabled sources sorted by descending ratio weight. + +### 2. Retrieval & Dispatch +- The client initiates a request via `GET /v1/me/questions`. +- The [questions_handler.dart](file:///home/chh/cyberhybridhub.com/server/lib/handlers/questions_handler.dart) delegates to `QuestionDispatcher.listPanelQuestions()`. +- The dispatcher retrieves unanswered questions and orders them so that the currently rolled source preference resides at the head of the list (using `QuestionSourceMix.orderUnansweredByPreferredSource()`). + +### 3. Client Routing & Presentation +- On the Flutter client, the [question_presentation_router.dart](file:///home/chh/cyberhybridhub.com/lib/widgets/question_presentation_router.dart) examines incoming properties: + - If `IncomingQuestion.usesSpellingWordTile` is true, it renders a [spelling_question_tile.dart](file:///home/chh/cyberhybridhub.com/lib/widgets/spelling_question_tile.dart). + - Otherwise, it defaults to rendering a [swipe_question_tile.dart](file:///home/chh/cyberhybridhub.com/lib/widgets/swipe_question_tile.dart) (binary choice swipe interface). + +### 4. Deferral & Skip Actions +- When a user chooses to skip or defer a question (e.g., swiping left or clicking skip): + - A `POST /v1/me/questions//defer` request is sent. + - The database updates the prioritization parameters (moving the question to the back of the queue by editing `view_order_at` or `created_at`). + - **Special Spelling Behavior**: In [question_dispatcher.dart](file:///home/chh/cyberhybridhub.com/server/lib/question_dispatcher.dart#L154-L163), if spelling was the rolled preference but the user just deferred the only spelling question in their queue, the system forcefully enqueues a *new* spelling question immediately so the user is not served the exact same word again. + +### 5. Deferred Preparation (Tile Load) +- For performance and synchronization, some questions defer part of their configuration until they are actively displayed. +- For example, spelling questions are created with a placeholder title and `needsPrepare = true`. +- When [spelling_question_tile.dart](file:///home/chh/cyberhybridhub.com/lib/widgets/spelling_question_tile.dart) is mounted, it sends a `POST /v1/me/questions//spelling/prepare` request. +- The pipeline selects a word, saves it in the database and pipeline context, links spelling progress trackers, and replies with the updated client payload (containing the spoken word and audio properties). + +### 6. Submission, Grading, & Progression +- When a user submits an answer via `POST /v1/me/questions//answer`: + - [questions_db.dart](file:///home/chh/cyberhybridhub.com/server/lib/questions_db.dart) logs and writes the answer to PostgreSQL. + - The dispatcher runs grading logic: + - **Spelling**: String comparison matching `userResponseText` against the `canonicalWord` metadata field via [spelling_answer_policy.dart](file:///home/chh/cyberhybridhub.com/server/lib/spelling/spelling_answer_policy.dart). + - **Market History**: Numeric evaluation using `gradeProspectiveAnswer()`. + - The pipeline runner (`QuestionPipeline.onAnswerSubmitted()`) processes the answer. Based on the matching/scoring outcome, it transitions the `user_pipeline_state` and optionally enqueues a branched follow-up question. + - Finally, the server schedules an asynchronous `topUpQueue()` execution to replenish the unanswered queue. + +--- + +## 5. Guide: Adding a New Question Type + +To introduce a new type of question (e.g., a "Math Trivia" question), follow this systematic implementation checklist: + +### Step 1: Define Database Fields & Custom Tables (Server) +Determine if your question type requires dedicated tracking tables (like spelling word catalogs or trade order details). +- Create a SQL migration file in `server/migrations/` to define any new tables or constraints. +- Ensure any additional metadata needed to present or grade the question can be stored inside the JSONB `metadata` column of the `questions` table. + +### Step 2: Register the Question Source +1. Open [question_source_preferences.dart](file:///home/chh/cyberhybridhub.com/server/lib/question_source_preferences.dart). +2. Append a new `QuestionSourcePreferenceDefinition` to the static `definitions` list: + ```dart + QuestionSourcePreferenceDefinition( + id: 'math_trivia', + label: 'Math Puzzle', + description: 'Solve arithmetic equations and logic puzzles', + defaultRatio: 30, + ) + ``` + +### Step 3: Implement Database Resolution (Server) +Modify the static resolution helpers in [questions_db.dart](file:///home/chh/cyberhybridhub.com/server/lib/questions_db.dart): +1. Locate `resolveSourceId(Map question)`. Add a case to return your new source identifier: + ```dart + if (question['pipelineKey'] == 'math_trivia' || (sourceTag != null && sourceTag.startsWith('math:'))) { + return 'math_trivia'; + } + ``` +2. Locate `resolvePresentation(Map question)`. Return the correct frontend layout identifier: + ```dart + if (resolveSourceId(question) == 'math_trivia') { + return 'math_quiz_keyboard'; // Target layout format + } + ``` + +### Step 4: Add Queue Maintenance Production (Server) +Integrate your question generator into the top-up flow: +1. Open [question_dispatcher.dart](file:///home/chh/cyberhybridhub.com/server/lib/question_dispatcher.dart). +2. Inside `_produceFromSource()`, add a case for your source ID that invokes your generator: + ```dart + case 'math_trivia': + await _questionService.tryEnqueueMathQuestions(firebaseUid); + ``` + +### Step 5: Build the Generation & Pipeline Engine (Server) +Create a service class (or extend `QuestionPipeline`) to handle generation and state progression: +1. **Generation**: Implement a method that creates a database row using `questionsDb.createQuestion()`. Specify your custom `sourceTag`, `pipelineKey`, `pipelineStep`, and grade variables. Ensure it respects batch parameters to avoid flooding the user's queue. +2. **State Progression**: In `QuestionPipeline.onAnswerSubmitted()`, add a switch case for your new `pipelineKey`. Read the answer, evaluate correctness, update any progress records, and transition the user's pipeline state as required. + +### Step 6: Parse the Client Model (Frontend) +1. Open [incoming_question.dart](file:///home/chh/cyberhybridhub.com/lib/models/incoming_question.dart). +2. Add a getter to identify the new question type: + ```dart + bool get isMathTrivia => sourceId == 'math_trivia'; + ``` +3. Update key mapping properties like `usesSpellingWordTile` or custom tile configuration parameters if you need to bypass default layouts. + +### Step 7: Create the UI Component & Router (Frontend) +1. If your question type requires a custom layout (e.g. keyboard input, multi-choice grids), create a new widget in `lib/widgets/` (e.g., `math_question_tile.dart`). +2. Open [question_presentation_router.dart](file:///home/chh/cyberhybridhub.com/lib/widgets/question_presentation_router.dart). +3. Update the `build` method to intercept your presentation format and route to the new widget: + ```dart + if (question.presentation == 'math_quiz_keyboard') { + return MathQuestionTile( + key: ValueKey(question.id), + question: question, + onSubmit: onAnswerText, + onDefer: onDefer, + ); + } + ``` diff --git a/scripts/admin-portal-coverage.sh b/scripts/admin-portal-coverage.sh index 26ced1b..4eac4bf 100755 --- a/scripts/admin-portal-coverage.sh +++ b/scripts/admin-portal-coverage.sh @@ -11,11 +11,16 @@ dart test \ test/trading/market_history_admin_logic_test.dart \ test/trading/market_history_admin_actions_test.dart \ test/integration/market_history_admin_handler_test.dart \ + test/integration/market_history_admin_risk_test.dart \ --coverage=coverage dart pub global activate coverage 2>/dev/null || true +export PATH="$PATH:$HOME/.pub-cache/bin" if command -v format_coverage >/dev/null 2>&1; then format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib echo "Server lcov: server/coverage/lcov.info" +elif dart pub global run coverage:format_coverage --help >/dev/null 2>&1; then + dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib + echo "Server lcov: server/coverage/lcov.info" else echo "Install coverage package for lcov: dart pub global activate coverage" fi diff --git a/scripts/build-production-web.sh b/scripts/build-production-web.sh index 25bd29c..153aeaa 100755 --- a/scripts/build-production-web.sh +++ b/scripts/build-production-web.sh @@ -67,8 +67,8 @@ fi # 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 \ +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 diff --git a/server/.env.example b/server/.env.example index 393430e..2f45ab5 100644 --- a/server/.env.example +++ b/server/.env.example @@ -38,3 +38,5 @@ MARKET_HISTORY_RETENTION_DAYS=5 # Use same origin as the site when HAProxy proxies /v1 and /hubs on :443. API_BASE_URL=https://cyberhybridhub.com QUESTION_DISPATCHER_DEBUG_LOGGING_ENABLED=false +MERRIAM_WEBSTER_API_KEY= +GOOGLE_CLOUD_API_KEY= diff --git a/server/bin/server.dart b/server/bin/server.dart index 3ea0378..310cda7 100644 --- a/server/bin/server.dart +++ b/server/bin/server.dart @@ -17,6 +17,7 @@ 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/spelling_audio_handler.dart'; import '../lib/handlers/trading_dev_handler.dart'; import '../lib/pipeline/question_pipeline.dart'; import '../lib/question_dispatcher.dart'; @@ -263,6 +264,10 @@ Future main() async { auth: auth, spellingItemsDb: spellingItemsDb, ); + final Handler spellingAudio = spellingAudioHandler( + merriamWebsterApiKey: env.merriamWebsterApiKey, + googleCloudApiKey: env.googleCloudApiKey, + ); final Handler questionsHub = questionsHubHandler( auth: auth, questionService: questionService, @@ -321,6 +326,9 @@ Future main() async { if (path.startsWith(spellingWordsBasePath)) { return spellingWords(request); } + if (path.startsWith('/v1/spelling/audio/')) { + return spellingAudio(request); + } return profile(request); }); diff --git a/server/lib/env.dart b/server/lib/env.dart index 16458f6..7df94b5 100644 --- a/server/lib/env.dart +++ b/server/lib/env.dart @@ -22,6 +22,8 @@ class ServerEnv { required this.marketHistory, required this.alpaca, required this.questionDispatcherDebugLoggingEnabled, + required this.merriamWebsterApiKey, + required this.googleCloudApiKey, }); final String databaseUrl; @@ -31,6 +33,8 @@ class ServerEnv { final int questionWorkerIntervalSeconds; final bool questionPipelineTestMode; final bool questionDispatcherDebugLoggingEnabled; + final String? merriamWebsterApiKey; + final String? googleCloudApiKey; /// When true, prospective answers earn closeness bonus points and wrong /// direction scores `-2` instead of `-1`. Default false (`+1`/`-1` only). @@ -121,6 +125,8 @@ class ServerEnv { (env['QUESTION_DISPATCHER_DEBUG_LOGGING_ENABLED'] ?? 'false') .toLowerCase() == 'true'; + final String? merriamWebsterApiKey = env['MERRIAM_WEBSTER_API_KEY']; + final String? googleCloudApiKey = env['GOOGLE_CLOUD_API_KEY']; final Map stringEnv = Map.from(Platform.environment); const List marketHistoryKeys = [ @@ -165,6 +171,8 @@ class ServerEnv { marketHistory: marketHistory, alpaca: alpaca, questionDispatcherDebugLoggingEnabled: questionDispatcherDebugLoggingEnabled, + merriamWebsterApiKey: merriamWebsterApiKey, + googleCloudApiKey: googleCloudApiKey, ); loaded.assertConsistent(); return loaded; diff --git a/server/lib/handlers/market_history_admin_handler.dart b/server/lib/handlers/market_history_admin_handler.dart index b7225e0..1f84219 100644 --- a/server/lib/handlers/market_history_admin_handler.dart +++ b/server/lib/handlers/market_history_admin_handler.dart @@ -45,7 +45,10 @@ Handler marketHistoryAdminHandler({ required Set adminFirebaseUids, MarketHistoryAdminActions? actions, MarketHistoryAdminPortalConfig? portalConfig, + Future Function(String, List)? runProcess, }) { + final Future Function(String, List) runProc = + runProcess ?? Process.run; final Router router = Router(); router.get('$marketHistoryAdminBasePath/sync-runs', (Request request) async { @@ -159,6 +162,160 @@ Handler marketHistoryAdminHandler({ } }); + router.get('$marketHistoryAdminBasePath/question-log', (Request request) async { + final String? firebaseUid = await _verifyAdmin(auth, request, adminFirebaseUids); + if (firebaseUid == null) { + return _authFailure(auth, request, adminFirebaseUids); + } + + try { + final DateTime now = + _parseBefore(request.requestedUri.queryParameters['asOf']) ?? + DateTime.now().toUtc(); + final String y = now.year.toString().padLeft(4, '0'); + final String m = now.month.toString().padLeft(2, '0'); + final String d = now.day.toString().padLeft(2, '0'); + final String dateString = '$y-$m-$d'; + final String filename = 'market_questions_$dateString.log'; + final File file = File(filename); + + final List entries = []; + if (await file.exists()) { + final List lines = await file.readAsLines(); + for (final String line in lines) { + if (line.trim().isNotEmpty) { + entries.add(line); + } + } + } + final List reversedEntries = entries.reversed.toList(); + + return _jsonResponse(200, { + 'entries': reversedEntries, + }); + } catch (e, st) { + stderr.writeln('market history admin question-log failed: $e\n$st'); + return _jsonResponse(500, {'error': 'Internal error'}); + } + }); + + router.get('$marketHistoryAdminBasePath/spelling-audio-cache', (Request request) async { + final String? firebaseUid = await _verifyAdmin(auth, request, adminFirebaseUids); + if (firebaseUid == null) { + return _authFailure(auth, request, adminFirebaseUids); + } + + try { + final Directory cacheDir = Directory('spelling_audio_cache'); + final List files = []; + if (await cacheDir.exists()) { + final List entities = cacheDir.listSync(); + for (final FileSystemEntity entity in entities) { + if (entity is File) { + final String name = entity.uri.pathSegments.last; + if (name.endsWith('.mp3')) { + files.add(name); + } + } + } + } + files.sort(); + + return _jsonResponse(200, { + 'files': files, + }); + } catch (e, st) { + stderr.writeln('market history admin spelling-audio-cache failed: $e\n$st'); + return _jsonResponse(500, {'error': 'Internal error'}); + } + }); + + router.post('$marketHistoryAdminBasePath/spelling-audio-cache/adjust', (Request request) async { + final String? firebaseUid = await _verifyAdmin(auth, request, adminFirebaseUids); + if (firebaseUid == null) { + return _authFailure(auth, request, adminFirebaseUids); + } + + try { + final String bodyString = await request.readAsString(); + final Map body = jsonDecode(bodyString) as Map; + + final String? filename = body['filename'] as String?; + if (filename == null || filename.isEmpty) { + return _jsonResponse(400, {'error': 'Missing filename'}); + } + if (filename.contains('..') || filename.contains('/') || filename.contains('\\') || !filename.endsWith('.mp3')) { + return _jsonResponse(400, {'error': 'Invalid filename'}); + } + + final double? volume = (body['volume'] as num?)?.toDouble(); + final bool? normalize = body['normalize'] as bool?; + + if (volume == null && normalize == null) { + return _jsonResponse(400, {'error': 'No adjustments specified.'}); + } + + if (volume != null && (volume < 0.1 || volume > 2.0)) { + return _jsonResponse(400, {'error': 'Volume must be between 0.1 and 2.0'}); + } + + final File originalFile = File('spelling_audio_cache/$filename'); + if (!await originalFile.exists()) { + return _jsonResponse(404, {'error': 'File not found'}); + } + + // Check if ffmpeg is installed + try { + final ProcessResult versionCheck = await runProc('ffmpeg', ['-version']); + if (versionCheck.exitCode != 0) { + return _jsonResponse(503, {'error': 'ffmpeg is not installed on the server.'}); + } + } catch (_) { + return _jsonResponse(503, {'error': 'ffmpeg is not installed on the server.'}); + } + + final String tempFilename = 'temp_${DateTime.now().microsecondsSinceEpoch}_$filename'; + final File tempFile = File('spelling_audio_cache/$tempFilename'); + + final List filters = []; + if (volume != null) { + filters.add('volume=$volume'); + } + if (normalize == true) { + filters.add('loudnorm'); + } + + final List args = [ + '-y', + '-i', + originalFile.path, + '-filter:a', + filters.join(','), + tempFile.path, + ]; + + final ProcessResult result = await runProc('ffmpeg', args); + if (result.exitCode != 0) { + if (await tempFile.exists()) { + await tempFile.delete(); + } + return _jsonResponse(500, { + 'error': 'ffmpeg execution failed: ${result.stderr}', + }); + } + + if (await tempFile.exists()) { + await tempFile.copy(originalFile.path); + await tempFile.delete(); + } + + return _jsonResponse(200, {'success': true}); + } catch (e, st) { + stderr.writeln('market history admin spelling-audio-cache adjust failed: $e\n$st'); + return _jsonResponse(500, {'error': 'Internal error'}); + } + }); + router.post('$marketHistoryAdminDataBasePath/resync', (Request request) async { final String? firebaseUid = await _verifyAdmin(auth, request, adminFirebaseUids); if (firebaseUid == null) { diff --git a/server/lib/handlers/questions_handler.dart b/server/lib/handlers/questions_handler.dart index d923d11..f193529 100644 --- a/server/lib/handlers/questions_handler.dart +++ b/server/lib/handlers/questions_handler.dart @@ -154,11 +154,21 @@ Handler questionsHandler({ final Map? score = isMarketAnswer ? await questionsDb.getGuessScoreSummary(firebaseUid) : null; + num? correctValue; + if (isMarketAnswer) { + final Map metadata = Map.from( + updated['metadata'] as Map? ?? {}, + ); + final bool isInverted = metadata['is_inverted'] as bool? ?? false; + final num rawCorrect = updated['correctAnswer'] as num? ?? 0; + correctValue = isInverted ? -rawCorrect : rawCorrect; + } return _jsonResponse(200, { 'question': updated, 'unansweredCount': unansweredCount, if (score != null) 'score': score, if (nextQuestion != null) 'nextQuestion': nextQuestion, + if (correctValue != null) 'correctValue': correctValue, }); } catch (e, st) { stderr.writeln('Answer question error: $e\n$st'); diff --git a/server/lib/handlers/spelling_audio_handler.dart b/server/lib/handlers/spelling_audio_handler.dart new file mode 100644 index 0000000..2e142e3 --- /dev/null +++ b/server/lib/handlers/spelling_audio_handler.dart @@ -0,0 +1,354 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'package:shelf/shelf.dart'; + +import '../cors_headers.dart'; + +Handler spellingAudioHandler({String? merriamWebsterApiKey, String? googleCloudApiKey, http.Client? httpClient}) { + final http.Client client = httpClient ?? http.Client(); + final Directory cacheDir = Directory('spelling_audio_cache'); + if (!cacheDir.existsSync()) { + cacheDir.createSync(recursive: true); + } + final File logFile = File('spelling_audio_errors.log'); + + Future logFailure({ + required String word, + required String apiName, + required String url, + int? statusCode, + String? responseBody, + String? errorDetails, + }) async { + final String timestamp = DateTime.now().toUtc().toIso8601String(); + final StringBuffer buffer = StringBuffer() + ..writeln('=== Audio Fetch Failure ===') + ..writeln('Timestamp: $timestamp') + ..writeln('Word: $word') + ..writeln('API: $apiName') + ..writeln('URL: $url'); + if (statusCode != null) { + buffer.writeln('Status Code: $statusCode'); + } + if (responseBody != null) { + buffer.writeln('Response Body: $responseBody'); + } + if (errorDetails != null) { + buffer.writeln('Error Details: $errorDetails'); + } + buffer.writeln('==========================='); + try { + await logFile.writeAsString( + buffer.toString(), + mode: FileMode.append, + flush: true, + ); + } catch (e) { + stderr.writeln('Failed to write spelling audio log: $e'); + } + } + + Future fetchAudioUrl(String word) async { + final String query = word.trim().toLowerCase(); + if (query.isEmpty) return null; + final String url = 'https://api.dictionaryapi.dev/api/v2/entries/en/$query'; + try { + final response = await client.get(Uri.parse(url)).timeout(const Duration(seconds: 4)); + + if (response.statusCode == 200) { + final List entries = jsonDecode(response.body) as List; + for (final entry in entries) { + if (entry is Map) { + final phonetics = entry['phonetics']; + if (phonetics is List) { + for (final phonetic in phonetics) { + if (phonetic is Map) { + final String? audio = phonetic['audio'] as String?; + if (audio != null && audio.isNotEmpty) { + if (audio.startsWith('//')) { + return 'https:$audio'; + } + return audio; + } + } + } + } + } + } + await logFailure( + word: word, + apiName: 'dictionaryapi.dev', + url: url, + statusCode: 200, + responseBody: response.body, + errorDetails: 'No pronunciation audio URL found in response structure.', + ); + } else { + await logFailure( + word: word, + apiName: 'dictionaryapi.dev', + url: url, + statusCode: response.statusCode, + responseBody: response.body, + ); + } + } catch (e) { + stderr.writeln('Server fetchAudioUrl failed for word $word: $e'); + await logFailure( + word: word, + apiName: 'dictionaryapi.dev', + url: url, + errorDetails: e.toString(), + ); + } + return null; + } + + Future fetchMerriamWebsterAudioUrl(String word) async { + if (merriamWebsterApiKey == null || merriamWebsterApiKey.isEmpty) { + return null; + } + final String cleanWord = word.trim().toLowerCase(); + final String url = 'https://www.dictionaryapi.com/api/v3/references/collegiate/json/$cleanWord?key=$merriamWebsterApiKey'; + try { + final response = await client.get(Uri.parse(url)).timeout(const Duration(seconds: 4)); + + if (response.statusCode != 200) { + await logFailure( + word: word, + apiName: 'Merriam-Webster', + url: url, + statusCode: response.statusCode, + responseBody: response.body, + ); + return null; + } + + final List json = jsonDecode(response.body) as List; + if (json.isEmpty || json[0] is String) { + await logFailure( + word: word, + apiName: 'Merriam-Webster', + url: url, + statusCode: 200, + responseBody: response.body, + errorDetails: 'Empty response or suggestions returned instead of entry details.', + ); + return null; + } + + final entry = json[0] as Map; + final hwi = entry['hwi'] as Map?; + final prs = hwi?['prs'] as List?; + if (prs == null || prs.isEmpty) { + await logFailure( + word: word, + apiName: 'Merriam-Webster', + url: url, + statusCode: 200, + responseBody: response.body, + errorDetails: 'No pronunciation (prs) array found in entry.', + ); + return null; + } + + final sound = prs[0]['sound'] as Map?; + final String? audioFileName = sound?['audio']; + + if (audioFileName == null || audioFileName.isEmpty) { + await logFailure( + word: word, + apiName: 'Merriam-Webster', + url: url, + statusCode: 200, + responseBody: response.body, + errorDetails: 'No audio filename found in sound block.', + ); + return null; + } + + String subdirectory = 'number'; + if (RegExp(r'^[a-zA-Z]').hasMatch(audioFileName)) { + if (audioFileName.startsWith('bix')) { + subdirectory = 'bix'; + } else if (audioFileName.startsWith('gg')) { + subdirectory = 'gg'; + } else { + subdirectory = audioFileName[0]; + } + } + + return 'https://media.merriam-webster.com/audio/prons/en/us/mp3/$subdirectory/$audioFileName.mp3'; + } catch (e) { + stderr.writeln('Server Merriam-Webster fetchAudioUrl failed for word $word: $e'); + await logFailure( + word: word, + apiName: 'Merriam-Webster', + url: url, + errorDetails: e.toString(), + ); + } + return null; + } + + Future synthesizeGoogleCloudTts(String word) async { + if (googleCloudApiKey == null || googleCloudApiKey.isEmpty) { + return null; + } + final String url = 'https://texttospeech.googleapis.com/v1/text:synthesize?key=$googleCloudApiKey'; + try { + final response = await client.post( + Uri.parse(url), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'input': {'text': word}, + 'voice': { + 'languageCode': 'en-US', + 'name': 'en-US-Neural2-F', + }, + 'audioConfig': { + 'audioEncoding': 'MP3', + 'speakingRate': 0.85, + } + }), + ).timeout(const Duration(seconds: 8)); + + if (response.statusCode == 200) { + final Map data = jsonDecode(response.body) as Map; + final String? audioContent = data['audioContent'] as String?; + if (audioContent != null && audioContent.isNotEmpty) { + return base64Decode(audioContent); + } + await logFailure( + word: word, + apiName: 'Google Cloud TTS', + url: url, + statusCode: 200, + responseBody: response.body, + errorDetails: 'Empty audioContent in response payload.', + ); + } else { + await logFailure( + word: word, + apiName: 'Google Cloud TTS', + url: url, + statusCode: response.statusCode, + responseBody: response.body, + ); + } + } catch (e) { + stderr.writeln('Server Google Cloud TTS synthesis failed for word $word: $e'); + await logFailure( + word: word, + apiName: 'Google Cloud TTS', + url: url, + errorDetails: e.toString(), + ); + } + return null; + } + + return (Request request) async { + if (request.method == 'OPTIONS') { + return Response.ok('', headers: apiCorsHeaders(methods: 'GET, OPTIONS')); + } + + if (request.method != 'GET') { + return Response(405, body: 'Method not allowed', headers: apiCorsHeaders(methods: 'GET, OPTIONS')); + } + + final String path = request.requestedUri.path; + // Expected path: /v1/spelling/audio/ or /v1/spelling/audio/.mp3 + final String prefix = '/v1/spelling/audio/'; + if (!path.startsWith(prefix)) { + return Response.notFound('Not found', headers: apiCorsHeaders(methods: 'GET, OPTIONS')); + } + + String wordPart = path.substring(prefix.length); + if (wordPart.endsWith('.mp3')) { + wordPart = wordPart.substring(0, wordPart.length - 4); + } + final String word = Uri.decodeComponent(wordPart).trim().toLowerCase(); + if (word.isEmpty || word.contains('/') || word.contains('\\') || word.contains('..')) { + return Response.badRequest(body: 'Invalid word', headers: apiCorsHeaders(methods: 'GET, OPTIONS')); + } + + final File localFile = File('${cacheDir.path}/$word.mp3'); + + try { + if (localFile.existsSync()) { + final Stream> fileStream = localFile.openRead(); + return Response.ok( + fileStream, + headers: { + ...apiCorsHeaders(methods: 'GET, OPTIONS'), + 'Content-Type': 'audio/mpeg', + 'Content-Length': localFile.lengthSync().toString(), + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0', + }, + ); + } + + // Fetch from dictionaryapi.dev + String? audioUrl = await fetchAudioUrl(word); + if (audioUrl == null) { + // Fallback to Merriam-Webster Collegiate API + audioUrl = await fetchMerriamWebsterAudioUrl(word); + } + + Uint8List? audioBytes; + if (audioUrl != null) { + final http.Response audioResponse = await client.get(Uri.parse(audioUrl)).timeout(const Duration(seconds: 10)); + if (audioResponse.statusCode == 200) { + audioBytes = audioResponse.bodyBytes; + } else { + await logFailure( + word: word, + apiName: 'MP3 Download', + url: audioUrl, + statusCode: audioResponse.statusCode, + responseBody: audioResponse.body, + ); + } + } + + if (audioBytes == null) { + // Fallback to Google Cloud TTS + audioBytes = await synthesizeGoogleCloudTts(word); + } + + if (audioBytes == null) { + return Response.notFound('Audio not found', headers: apiCorsHeaders(methods: 'GET, OPTIONS')); + } + + // Cache locally + await localFile.writeAsBytes(audioBytes); + + return Response.ok( + audioBytes, + headers: { + ...apiCorsHeaders(methods: 'GET, OPTIONS'), + 'Content-Type': 'audio/mpeg', + 'Content-Length': audioBytes.length.toString(), + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0', + }, + ); + } catch (e, st) { + stderr.writeln('Error serving spelling audio for word $word: $e\n$st'); + await logFailure( + word: word, + apiName: 'Serving Process', + url: 'N/A', + errorDetails: '$e\n$st', + ); + return Response.internalServerError(body: 'Internal error', headers: apiCorsHeaders(methods: 'GET, OPTIONS')); + } + }; +} diff --git a/server/lib/question_service.dart b/server/lib/question_service.dart index c27bac5..aae616b 100644 --- a/server/lib/question_service.dart +++ b/server/lib/question_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math'; import 'question_source_preferences_db.dart'; import 'questions_db.dart'; @@ -120,9 +121,13 @@ class QuestionService { newerSlotStart: batch.newerSlotStart, ); + final bool isInverted = Random().nextBool(); + final String text = picked['questionText']! as String; + final String questionText = isInverted ? '$text [Inverted]' : text; + final Map question = await _questionsDb.createQuestion( assignedUserId: firebaseUid, - questionText: picked['questionText']! as String, + questionText: questionText, correctAnswer: picked['correctAnswer']! as num, sourceTag: 'market_history:prospective', pipelineKey: 'trading', @@ -133,6 +138,7 @@ class QuestionService { 'older_slot_start': picked['olderSlotStart'], 'newer_slot_start': picked['newerSlotStart'], 'price_delta_pct': picked['priceDeltaPct'], + 'is_inverted': isInverted, }, ); diff --git a/server/lib/questions_db.dart b/server/lib/questions_db.dart index 4c56368..771bdb7 100644 --- a/server/lib/questions_db.dart +++ b/server/lib/questions_db.dart @@ -11,6 +11,7 @@ import 'trading/guess_score_store.dart'; import 'trading/prospective_guess_assignments_db.dart'; import 'trading/prospective_guess_selection.dart'; import 'trading/user_trading_state_db.dart'; +import 'trading/market_question_logger.dart'; /// Postgres access for the questions table. class QuestionsDb { @@ -239,6 +240,24 @@ class QuestionsDb { answeredAt: now, ); await _gradeProspectiveAnswerIfNeeded(updated); + + final Map metadata = Map.from( + updated['metadata'] as Map? ?? {}, + ); + final String symbol = metadata['symbol'] as String? ?? ''; + final bool isInverted = metadata['is_inverted'] as bool? ?? false; + final num? userResponse = _readOptionalNumeric(updated['userResponse']); + final num correctAnswer = _readNumeric(updated['correctAnswer']); + + await MarketQuestionLogger.logAnswer( + questionId: questionId, + userId: assignedUserId, + symbol: symbol, + response: userResponse ?? 0, + correctAnswer: correctAnswer, + isInverted: isInverted, + now: now, + ); } return updated; } @@ -718,7 +737,7 @@ class QuestionsDb { final Map metadata = Map.from( question['metadata'] as Map? ?? {}, ); - final String? spokenWord = metadata['displayed_word'] as String?; + final String? spokenWord = metadata['canonical_word'] as String?; final bool isSpelling = resolveSourceId(question) == 'spelling'; final bool needsPrepare = isSpelling && (spokenWord == null || spokenWord.isEmpty) && @@ -865,9 +884,11 @@ class QuestionsDb { return; } + final bool isInverted = metadata['is_inverted'] as bool? ?? false; final ProspectiveAnswerGrade grade = gradeProspectiveAnswer( userResponse: userResponse, correctAnswer: _readNumeric(answeredQuestion['correctAnswer']), + isInverted: isInverted, ); final String symbol = metadata['symbol'] as String? ?? ''; final String? modifiedAtWire = answeredQuestion['modifiedAt'] as String?; @@ -905,6 +926,7 @@ class QuestionsDb { final DateTime answeredAt = modifiedAtWire == null ? DateTime.now().toUtc() : DateTime.parse(modifiedAtWire).toUtc(); + final bool isInverted = metadata['is_inverted'] as bool? ?? false; await _connection.execute( Sql.named( @@ -912,12 +934,14 @@ class QuestionsDb { INSERT INTO market_history_prospective_answers ( question_id, assigned_user_id, prospective_question_id, symbol, older_slot_start, newer_slot_start, - expected_percent_increase_price, user_slider_value, answered_at + expected_percent_increase_price, user_slider_value, answered_at, + is_inverted ) SELECT @question_id::uuid, @assigned_user_id, p.id, p.symbol, p.older_slot_start, p.newer_slot_start, - p.price_delta_pct, @user_slider_value, @answered_at + p.price_delta_pct, @user_slider_value, @answered_at, + @is_inverted FROM market_history_prospective_questions p WHERE p.id = @prospective_question_id::uuid ON CONFLICT (question_id) DO UPDATE @@ -928,7 +952,8 @@ class QuestionsDb { newer_slot_start = EXCLUDED.newer_slot_start, expected_percent_increase_price = EXCLUDED.expected_percent_increase_price, user_slider_value = EXCLUDED.user_slider_value, - answered_at = EXCLUDED.answered_at + answered_at = EXCLUDED.answered_at, + is_inverted = EXCLUDED.is_inverted ''', ), parameters: { @@ -937,6 +962,7 @@ class QuestionsDb { 'prospective_question_id': prospectiveQuestionId, 'user_slider_value': userSliderValue, 'answered_at': answeredAt, + 'is_inverted': isInverted, }, ); } diff --git a/server/lib/trading/guess_score_store.dart b/server/lib/trading/guess_score_store.dart index d2e901a..2542b0e 100644 --- a/server/lib/trading/guess_score_store.dart +++ b/server/lib/trading/guess_score_store.dart @@ -104,11 +104,14 @@ abstract final class GuessScoreStore { COUNT(*)::int AS total, COUNT(*) FILTER ( WHERE ( - sign(user_slider_value) = sign(expected_percent_increase_price) + CASE + WHEN is_inverted THEN sign(user_slider_value) = -sign(ROUND(expected_percent_increase_price)) + ELSE sign(user_slider_value) = sign(ROUND(expected_percent_increase_price)) + END ) OR ( user_slider_value = 0 - AND expected_percent_increase_price = 0 + AND ROUND(expected_percent_increase_price) = 0 ) )::int AS correct FROM market_history_prospective_answers @@ -133,7 +136,7 @@ abstract final class GuessScoreStore { final Result rows = await connection.execute( Sql.named( ''' - SELECT user_slider_value, expected_percent_increase_price + SELECT user_slider_value, expected_percent_increase_price, is_inverted FROM market_history_prospective_answers WHERE assigned_user_id = @uid ORDER BY answered_at ASC @@ -146,6 +149,7 @@ abstract final class GuessScoreStore { final ProspectiveAnswerGrade grade = gradeProspectiveAnswer( userResponse: _readNum(row[0]), correctAnswer: _readNum(row[1]), + isInverted: row[2] as bool? ?? false, ); total += grade.answerScore; } diff --git a/server/lib/trading/market_question_logger.dart b/server/lib/trading/market_question_logger.dart new file mode 100644 index 0000000..601e00b --- /dev/null +++ b/server/lib/trading/market_question_logger.dart @@ -0,0 +1,67 @@ +import 'dart:io'; + +class MarketQuestionLogger { + MarketQuestionLogger._(); + + static const String _logFilePrefix = 'market_questions_'; + static const String _logFileSuffix = '.log'; + + /// Log an answered market question to a date-stamped file with a 2-day rotation. + static Future logAnswer({ + required String questionId, + required String userId, + required String symbol, + required num response, + required num correctAnswer, + required bool isInverted, + DateTime? now, + }) async { + final DateTime time = now ?? DateTime.now().toUtc(); + final String dateString = _formatDate(time); + + // Construct today's and yesterday's file names + final String todayFileName = '$_logFilePrefix$dateString$_logFileSuffix'; + final String yesterdayFileName = '$_logFilePrefix${_formatDate(time.subtract(const Duration(days: 1)))}$_logFileSuffix'; + + // 1. Write the log entry + final String logLine = '[${time.toIso8601String()}] ' + 'Question ID: $questionId, ' + 'User ID: $userId, ' + 'Symbol: $symbol, ' + 'Response: $response, ' + 'Correct: $correctAnswer, ' + 'Inverted: $isInverted\n'; + + final File logFile = File(todayFileName); + try { + await logFile.writeAsString(logLine, mode: FileMode.append, flush: true); + } catch (e) { + stderr.writeln('Failed to write market question log: $e'); + } + + // 2. Perform rotation (delete any log files that are not today's or yesterday's) + try { + final Directory currentDir = Directory.current; + final List entities = currentDir.listSync(); + for (final FileSystemEntity entity in entities) { + if (entity is File) { + final String name = entity.uri.pathSegments.last; + if (name.startsWith(_logFilePrefix) && name.endsWith(_logFileSuffix)) { + if (name != todayFileName && name != yesterdayFileName) { + await entity.delete(); + } + } + } + } + } catch (e) { + stderr.writeln('Failed to rotate market question logs: $e'); + } + } + + static String _formatDate(DateTime dt) { + final String y = dt.year.toString().padLeft(4, '0'); + final String m = dt.month.toString().padLeft(2, '0'); + final String d = dt.day.toString().padLeft(2, '0'); + return '$y-$m-$d'; + } +} diff --git a/server/lib/trading/prospective_answer_scoring.dart b/server/lib/trading/prospective_answer_scoring.dart index 6200a45..6579468 100644 --- a/server/lib/trading/prospective_answer_scoring.dart +++ b/server/lib/trading/prospective_answer_scoring.dart @@ -38,12 +38,15 @@ const num prospectiveAnswerFullCreditTolerance = 1; ProspectiveAnswerGrade gradeProspectiveAnswer({ required num userResponse, required num correctAnswer, + bool isInverted = false, }) { + final num roundedCorrectAnswer = correctAnswer.round(); + final num effectiveUserResponse = isInverted ? userResponse * -1 : userResponse; final bool sameDirection = _sameDirection( - userResponse: userResponse, - correctAnswer: correctAnswer, + userResponse: effectiveUserResponse, + correctAnswer: roundedCorrectAnswer, ); - final num absError = (userResponse - correctAnswer).abs(); + final num absError = (effectiveUserResponse - roundedCorrectAnswer).abs(); if (!ProspectiveAnswerScoring.closenessExtraPointsEnabled) { final num directionPoint = sameDirection ? 1 : -1; @@ -59,8 +62,8 @@ ProspectiveAnswerGrade gradeProspectiveAnswer({ final num directionPoint = sameDirection ? 1 : -2; final num closenessPoint = sameDirection ? _closenessPoint( - userResponse: userResponse, - correctAnswer: correctAnswer, + userResponse: effectiveUserResponse, + correctAnswer: roundedCorrectAnswer, ) : 0; return ProspectiveAnswerGrade( diff --git a/server/migrations/022_prospective_answers_is_inverted.sql b/server/migrations/022_prospective_answers_is_inverted.sql new file mode 100644 index 0000000..4161f5f --- /dev/null +++ b/server/migrations/022_prospective_answers_is_inverted.sql @@ -0,0 +1,4 @@ +-- Track if the prospective market answer was inverted. + +ALTER TABLE market_history_prospective_answers + ADD COLUMN IF NOT EXISTS is_inverted BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/server/test/integration/market_history_admin_handler_test.dart b/server/test/integration/market_history_admin_handler_test.dart index b9b06fc..abeab06 100644 --- a/server/test/integration/market_history_admin_handler_test.dart +++ b/server/test/integration/market_history_admin_handler_test.dart @@ -2,6 +2,7 @@ library; import 'dart:convert'; +import 'dart:io'; import 'package:cyberhybridhub_server/firebase_auth.dart'; import 'package:cyberhybridhub_server/handlers/market_history_admin_handler.dart'; @@ -14,7 +15,6 @@ import 'package:cyberhybridhub_server/trading/tradable_assets_sync.dart'; import 'package:postgres/postgres.dart'; import 'package:shelf/shelf.dart'; import 'package:test/test.dart'; -import 'package:timezone/timezone.dart' as tz; import '../helpers/test_db.dart'; @@ -870,4 +870,461 @@ void main() { jsonDecode(await response.readAsString()) as Map; expect(body['error'], contains('MARKET_HISTORY_SYNC_ENABLED=false')); }); + + test('handler parsing edge cases and OPTIONS requests', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + final Handler handler = marketHistoryAdminHandler( + auth: _FakeAuthVerifier(), + connection: testDb!.connection, + adminFirebaseUids: {'admin-uid'}, + actions: null, + portalConfig: null, + ); + + // 1. OPTIONS request + final Response optionsResponse = await handler( + Request( + 'OPTIONS', + Uri.parse('http://localhost/v1/admin/market-history/sync-runs'), + ), + ); + expect(optionsResponse.statusCode, 200); + + // 2. Limit parsing boundary checks (< 1 and > 200) + final Response limitLessResponse = await _get( + handler, + path: '/v1/admin/market-history/sync-runs?limit=0', + bearer: 'admin-uid', + ); + expect(limitLessResponse.statusCode, 200); + + final Response limitMoreResponse = await _get( + handler, + path: '/v1/admin/market-history/sync-runs?limit=300', + bearer: 'admin-uid', + ); + expect(limitMoreResponse.statusCode, 200); + + // 3. Kind parsing with invalid kind + final Response invalidKindResponse = await _get( + handler, + path: '/v1/admin/market-history/sync-runs?kind=invalid', + bearer: 'admin-uid', + ); + expect(invalidKindResponse.statusCode, 200); + + // 4. Invalid token verification returning 401 + final Response unauthResponse = await _get( + handler, + path: '/v1/admin/market-history/sync-runs', + bearer: '', // will fail verification + ); + expect(unauthResponse.statusCode, 401); + + // 5. POST cleanup with null actions + final Response cleanupDisabledResponse = await _post( + handler, + path: '/v1/admin/market-data/cleanup?archive=invalid&windowDays=-5', + bearer: 'admin-uid', + ); + expect(cleanupDisabledResponse.statusCode, 503); + + // 6. POST resync with null actions + final Response resyncDisabledResponse = await _post( + handler, + path: '/v1/admin/market-data/resync', + bearer: 'admin-uid', + ); + expect(resyncDisabledResponse.statusCode, 503); + + // 7. GET week-coverage without asOf query parameter + final Response weekCoverageDefaultResponse = await _get( + handler, + path: '/v1/admin/market-history/week-coverage', + bearer: 'admin-uid', + ); + expect(weekCoverageDefaultResponse.statusCode, 200); + + // 8. GET sync-runs with valid token but non-admin UID (403) + final Response nonAdminRunsResponse = await _get( + handler, + path: '/v1/admin/market-history/sync-runs', + bearer: 'non-admin', + ); + expect(nonAdminRunsResponse.statusCode, 403); + }); + + test('GET /v1/admin/market-history/question-log returns reversed log entries', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + final File tempLog = File('market_questions_2026-06-13.log'); + if (tempLog.existsSync()) { + tempLog.deleteSync(); + } + tempLog.writeAsStringSync('line 1\nline 2\n\nline 3\n'); + + final Handler handler = marketHistoryAdminHandler( + auth: _FakeAuthVerifier(), + connection: testDb!.connection, + adminFirebaseUids: {'admin-uid'}, + ); + + try { + final Response response = await _get( + handler, + path: '/v1/admin/market-history/question-log?asOf=2026-06-13T12:00:00Z', + bearer: 'admin-uid', + ); + expect(response.statusCode, 200); + + final Map body = + jsonDecode(await response.readAsString()) as Map; + final List entries = body['entries'] as List; + expect(entries, ['line 3', 'line 2', 'line 1']); + } finally { + if (tempLog.existsSync()) { + tempLog.deleteSync(); + } + } + }); + + test('GET /v1/admin/market-history/question-log returns empty list when file does not exist', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + final File tempLog = File('market_questions_2026-06-14.log'); + if (tempLog.existsSync()) { + tempLog.deleteSync(); + } + + final Handler handler = marketHistoryAdminHandler( + auth: _FakeAuthVerifier(), + connection: testDb!.connection, + adminFirebaseUids: {'admin-uid'}, + ); + + final Response response = await _get( + handler, + path: '/v1/admin/market-history/question-log?asOf=2026-06-14T12:00:00Z', + bearer: 'admin-uid', + ); + expect(response.statusCode, 200); + + final Map body = + jsonDecode(await response.readAsString()) as Map; + final List entries = body['entries'] as List; + expect(entries, isEmpty); + }); + + test('GET /v1/admin/market-history/spelling-audio-cache returns sorted mp3 files list', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + final Directory testDir = Directory('spelling_audio_cache'); + if (!testDir.existsSync()) { + testDir.createSync(); + } + + final File fileB = File('spelling_audio_cache/banana.mp3'); + final File fileA = File('spelling_audio_cache/apple.mp3'); + final File fileTxt = File('spelling_audio_cache/test.txt'); + + fileB.writeAsStringSync('banana'); + fileA.writeAsStringSync('apple'); + fileTxt.writeAsStringSync('txt'); + + final Handler handler = marketHistoryAdminHandler( + auth: _FakeAuthVerifier(), + connection: testDb!.connection, + adminFirebaseUids: {'admin-uid'}, + ); + + try { + final Response response = await _get( + handler, + path: '/v1/admin/market-history/spelling-audio-cache', + bearer: 'admin-uid', + ); + expect(response.statusCode, 200); + + final Map body = + jsonDecode(await response.readAsString()) as Map; + final List files = body['files'] as List; + expect(files, containsAll(['apple.mp3', 'banana.mp3'])); + expect(files, isNot(contains('test.txt'))); + } finally { + if (fileA.existsSync()) fileA.deleteSync(); + if (fileB.existsSync()) fileB.deleteSync(); + if (fileTxt.existsSync()) fileTxt.deleteSync(); + } + }); + + test('GET /v1/admin/market-history/spelling-audio-cache returns empty list when cache directory does not exist', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + final Directory originalDir = Directory('spelling_audio_cache'); + final Directory tempDir = Directory('spelling_audio_cache_temp_backup'); + final bool originallyExists = originalDir.existsSync(); + if (originallyExists) { + originalDir.renameSync(tempDir.path); + } + + final Handler handler = marketHistoryAdminHandler( + auth: _FakeAuthVerifier(), + connection: testDb!.connection, + adminFirebaseUids: {'admin-uid'}, + ); + + try { + final Response response = await _get( + handler, + path: '/v1/admin/market-history/spelling-audio-cache', + bearer: 'admin-uid', + ); + expect(response.statusCode, 200); + + final Map body = + jsonDecode(await response.readAsString()) as Map; + final List files = body['files'] as List; + expect(files, isEmpty); + } finally { + if (originallyExists && tempDir.existsSync()) { + tempDir.renameSync(originalDir.path); + } + } + }); + + test('POST /v1/admin/market-history/spelling-audio-cache/adjust validation and ffmpeg check', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + final Handler handler = marketHistoryAdminHandler( + auth: _FakeAuthVerifier(), + connection: testDb!.connection, + adminFirebaseUids: {'admin-uid'}, + ); + + Future _postJson(Map body) async { + return await handler( + Request( + 'POST', + Uri.parse('http://localhost/v1/admin/market-history/spelling-audio-cache/adjust'), + headers: { + 'Authorization': 'Bearer admin-uid', + 'Content-Type': 'application/json', + }, + body: jsonEncode(body), + ), + ); + } + + // 1. Missing filename + { + final Response res = await _postJson({'volume': 1.0}); + expect(res.statusCode, 400); + final Map body = jsonDecode(await res.readAsString()) as Map; + expect(body['error'], 'Missing filename'); + } + + // 2. Invalid filename (traversal) + { + final Response res = await _postJson({'filename': '../test.mp3', 'volume': 1.0}); + expect(res.statusCode, 400); + final Map body = jsonDecode(await res.readAsString()) as Map; + expect(body['error'], 'Invalid filename'); + } + + // 3. No adjustments specified + { + final Response res = await _postJson({'filename': 'apple.mp3'}); + expect(res.statusCode, 400); + final Map body = jsonDecode(await res.readAsString()) as Map; + expect(body['error'], 'No adjustments specified.'); + } + + // 4. Invalid volume range + { + final Response res = await _postJson({'filename': 'apple.mp3', 'volume': 2.5}); + expect(res.statusCode, 400); + final Map body = jsonDecode(await res.readAsString()) as Map; + expect(body['error'], 'Volume must be between 0.1 and 2.0'); + } + + // 5. File not found + { + final Response res = await _postJson({'filename': 'nonexistent.mp3', 'volume': 1.0}); + expect(res.statusCode, 404); + final Map body = jsonDecode(await res.readAsString()) as Map; + expect(body['error'], 'Not found'); + } + + // 6. Check ffmpeg check + { + // Create temporary file so it bypasses file not found check + final Directory testDir = Directory('spelling_audio_cache'); + if (!testDir.existsSync()) { + testDir.createSync(); + } + final File tempFile = File('spelling_audio_cache/temp_test_adjust.mp3'); + tempFile.writeAsStringSync('mp3 dummy data'); + + try { + final Response res = await _postJson({ + 'filename': 'temp_test_adjust.mp3', + 'volume': 1.2, + 'normalize': true, + }); + + expect(res.statusCode, anyOf(200, 503, 500)); + final Map body = jsonDecode(await res.readAsString()) as Map; + if (res.statusCode == 503) { + expect(body['error'], contains('ffmpeg is not installed on the server.')); + } + } finally { + if (tempFile.existsSync()) { + tempFile.deleteSync(); + } + } + } + }); + + test('POST /v1/admin/market-history/spelling-audio-cache/adjust mock success/failure runs', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + final Directory testDir = Directory('spelling_audio_cache'); + if (!testDir.existsSync()) { + testDir.createSync(); + } + final File originalFile = File('spelling_audio_cache/test_original.mp3'); + originalFile.writeAsStringSync('original audio'); + + try { + // 1. Success mock + int runProcessCalls = 0; + final Handler successHandler = marketHistoryAdminHandler( + auth: _FakeAuthVerifier(), + connection: testDb!.connection, + adminFirebaseUids: {'admin-uid'}, + runProcess: (String command, List args) async { + runProcessCalls++; + if (args.contains('-version')) { + return ProcessResult(0, 0, 'ffmpeg version 4.4', ''); + } + final String outputPath = args.last; + final File tempFile = File(outputPath); + tempFile.writeAsStringSync('processed audio'); + return ProcessResult(0, 0, 'success', ''); + }, + ); + + final Response res1 = await successHandler( + Request( + 'POST', + Uri.parse('http://localhost/v1/admin/market-history/spelling-audio-cache/adjust'), + headers: { + 'Authorization': 'Bearer admin-uid', + 'Content-Type': 'application/json', + }, + body: jsonEncode({ + 'filename': 'test_original.mp3', + 'volume': 1.5, + 'normalize': true, + }), + ), + ); + expect(res1.statusCode, 200); + expect(runProcessCalls, 2); + expect(originalFile.readAsStringSync(), 'processed audio'); + + // Reset file content + originalFile.writeAsStringSync('original audio'); + + // 2. FFMPEG version check fails mock + final Handler noFfmpegHandler = marketHistoryAdminHandler( + auth: _FakeAuthVerifier(), + connection: testDb!.connection, + adminFirebaseUids: {'admin-uid'}, + runProcess: (String command, List args) async { + if (args.contains('-version')) { + return ProcessResult(0, 127, '', 'not found'); + } + return ProcessResult(0, 0, '', ''); + }, + ); + + final Response res2 = await noFfmpegHandler( + Request( + 'POST', + Uri.parse('http://localhost/v1/admin/market-history/spelling-audio-cache/adjust'), + headers: { + 'Authorization': 'Bearer admin-uid', + 'Content-Type': 'application/json', + }, + body: jsonEncode({ + 'filename': 'test_original.mp3', + 'volume': 1.5, + }), + ), + ); + expect(res2.statusCode, 503); + final Map body2 = jsonDecode(await res2.readAsString()) as Map; + expect(body2['error'], contains('ffmpeg is not installed on the server.')); + + // 3. FFMPEG execution fails mock + final Handler failHandler = marketHistoryAdminHandler( + auth: _FakeAuthVerifier(), + connection: testDb!.connection, + adminFirebaseUids: {'admin-uid'}, + runProcess: (String command, List args) async { + if (args.contains('-version')) { + return ProcessResult(0, 0, 'ffmpeg version 4.4', ''); + } + return ProcessResult(0, 1, '', 'ffmpeg error message'); + }, + ); + + final Response res3 = await failHandler( + Request( + 'POST', + Uri.parse('http://localhost/v1/admin/market-history/spelling-audio-cache/adjust'), + headers: { + 'Authorization': 'Bearer admin-uid', + 'Content-Type': 'application/json', + }, + body: jsonEncode({ + 'filename': 'test_original.mp3', + 'volume': 1.5, + }), + ), + ); + expect(res3.statusCode, 500); + final Map body3 = jsonDecode(await res3.readAsString()) as Map; + expect(body3['error'], contains('ffmpeg error message')); + + } finally { + if (originalFile.existsSync()) { + originalFile.deleteSync(); + } + } + }); } + diff --git a/server/test/integration/market_history_prospective_questions_test.dart b/server/test/integration/market_history_prospective_questions_test.dart index df63b53..b9bb43c 100644 --- a/server/test/integration/market_history_prospective_questions_test.dart +++ b/server/test/integration/market_history_prospective_questions_test.dart @@ -11,6 +11,8 @@ import 'package:cyberhybridhub_server/trading/prospective_answer_scoring.dart'; import 'package:cyberhybridhub_server/trading/prospective_guess_assignments_db.dart'; import 'package:cyberhybridhub_server/trading/prospective_guess_selection.dart'; import 'package:cyberhybridhub_server/trading/market_history_session_slot.dart'; +import 'package:cyberhybridhub_server/trading/market_question_logger.dart'; +import 'dart:io'; import 'package:postgres/postgres.dart'; import 'package:test/test.dart'; @@ -1335,4 +1337,322 @@ void main() { expect(queue.last['id'], second.question['id']); }, ); + + test('submitAnswer handles inverted prospective answer correct and incorrect grading', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + ProspectiveAnswerScoring.closenessExtraPointsEnabled = true; + addTearDown(() { + ProspectiveAnswerScoring.closenessExtraPointsEnabled = false; + }); + + const String uid = 'inverted-answer-grading-uid'; + final DateTime now = DateTime.now().toUtc(); + final DateTime newerSlot = + MarketHistorySessionSlot.lastCompletedSlotStart(now); + final DateTime olderSlot = + MarketHistorySessionSlot.previousSlotStart(newerSlot)!; + + await testDb!.seedUser(uid); + + final Result insertedProspective = await testDb!.connection.execute( + Sql.named( + ''' + INSERT INTO market_history_prospective_questions ( + compare_until, newer_slot_start, older_slot_start, + symbol, question_text, correct_answer, + price_delta_pct, volume_delta_pct, avg_volume_usd, + older_slot, newer_slot + ) VALUES ( + @compare_until, @newer_slot_start, @older_slot_start, + 'AAPL', 'What was AAPL move?', 2.0, + 2.0, 2.0, 123456, + '{}'::jsonb, '{}'::jsonb + ) + RETURNING id + ''', + ), + parameters: { + 'compare_until': MarketHistorySessionSlot.endExclusive(newerSlot), + 'newer_slot_start': newerSlot, + 'older_slot_start': olderSlot, + }, + ); + final String prospectiveId = insertedProspective.first[0].toString(); + + final Result insertedProspective2 = await testDb!.connection.execute( + Sql.named( + ''' + INSERT INTO market_history_prospective_questions ( + compare_until, newer_slot_start, older_slot_start, + symbol, question_text, correct_answer, + price_delta_pct, volume_delta_pct, avg_volume_usd, + older_slot, newer_slot + ) VALUES ( + @compare_until, @newer_slot_start, @older_slot_start, + 'MSFT', 'What was MSFT move?', 2.0, + 2.0, 2.0, 123456, + '{}'::jsonb, '{}'::jsonb + ) + RETURNING id + ''', + ), + parameters: { + 'compare_until': MarketHistorySessionSlot.endExclusive(newerSlot), + 'newer_slot_start': newerSlot, + 'older_slot_start': olderSlot, + }, + ); + final String prospectiveId2 = insertedProspective2.first[0].toString(); + + // 1. Correct Answer: since price_delta_pct is 2.0 (positive) and is_inverted is true, + // the user needs to answer negative. Let's answer -1.5 (negative, which is correct direction). + final Map q1 = await testDb!.questionsDb.createQuestion( + assignedUserId: uid, + questionText: 'Guess AAPL move [Inverted]', + correctAnswer: 2.0, + sourceTag: 'market_history:prospective', + pipelineKey: 'trading', + pipelineStep: 'guess_weekly_move:await_answer', + metadata: { + 'prospective_question_id': prospectiveId, + 'symbol': 'AAPL', + 'is_inverted': true, + }, + ); + + await testDb!.questionsDb.submitAnswer( + questionId: q1['id']! as String, + assignedUserId: uid, + userResponse: -1.5, + ); + + final Map score1 = + await testDb!.questionsDb.getGuessScoreSummary(uid); + // User answered -1.5. Since isInverted is true, effectiveUserResponse is 1.5. + // Direction: sign(1.5) == sign(2.0) -> correct direction (+1 point) + // Closeness: (1.5 - 2.0).abs() = 0.5 <= 1.0 (full credit tolerance) -> +1 point + // Total expected score: 2 + expect(score1['answersTotal'], 1); + expect(score1['answersCorrect'], 1); + expect(score1['percentCorrect'], 100); + expect(score1['total'], 2); + + // 2. Incorrect Answer: since price_delta_pct is 2.0 (positive) and is_inverted is true, + // user answers positive (+2.5). Effective answer is -2.5. + // Direction: sign(-2.5) != sign(2.0) -> incorrect direction (-2 points) + // Closeness: 0 + // Total expected score delta: -2. Cumulative score: 2 - 2 = 0 + final Map q2 = await testDb!.questionsDb.createQuestion( + assignedUserId: uid, + questionText: 'Guess MSFT move [Inverted]', + correctAnswer: 2.0, + sourceTag: 'market_history:prospective', + pipelineKey: 'trading', + pipelineStep: 'guess_weekly_move:await_answer', + metadata: { + 'prospective_question_id': prospectiveId2, + 'symbol': 'MSFT', + 'is_inverted': true, + }, + ); + + await testDb!.questionsDb.submitAnswer( + questionId: q2['id']! as String, + assignedUserId: uid, + userResponse: 2.5, + ); + + final Map score2 = + await testDb!.questionsDb.getGuessScoreSummary(uid); + expect(score2['answersTotal'], 2); + expect(score2['answersCorrect'], 1); + expect(score2['percentCorrect'], 50); + expect(score2['total'], 0); + }); + + test('MarketQuestionLogger logs answers and rotates logs correctly', () async { + final DateTime now = DateTime.utc(2026, 6, 13, 12, 0, 0); + final String dateToday = '2026-06-13'; + final String dateYesterday = '2026-06-12'; + final String dateBeforeYesterday = '2026-06-11'; + final String dateOlder = '2026-06-10'; + + final File fileToday = File('market_questions_$dateToday.log'); + final File fileYesterday = File('market_questions_$dateYesterday.log'); + final File fileBeforeYesterday = File('market_questions_$dateBeforeYesterday.log'); + final File fileOlder = File('market_questions_$dateOlder.log'); + + // Clean up any files that might exist beforehand + if (fileToday.existsSync()) fileToday.deleteSync(); + if (fileYesterday.existsSync()) fileYesterday.deleteSync(); + if (fileBeforeYesterday.existsSync()) fileBeforeYesterday.deleteSync(); + if (fileOlder.existsSync()) fileOlder.deleteSync(); + + addTearDown(() { + if (fileToday.existsSync()) fileToday.deleteSync(); + if (fileYesterday.existsSync()) fileYesterday.deleteSync(); + if (fileBeforeYesterday.existsSync()) fileBeforeYesterday.deleteSync(); + if (fileOlder.existsSync()) fileOlder.deleteSync(); + }); + + // Create dummy files for yesterday, day before yesterday, and older + fileYesterday.writeAsStringSync('yesterday content\n'); + fileBeforeYesterday.writeAsStringSync('before yesterday content\n'); + fileOlder.writeAsStringSync('older content\n'); + + // Call logger to log a new answer for today + await MarketQuestionLogger.logAnswer( + questionId: 'q-123', + userId: 'user-456', + symbol: 'TSLA', + response: 3.5, + correctAnswer: 4.0, + isInverted: false, + now: now, + ); + + // Verify today's file was created and contains correct logs + expect(fileToday.existsSync(), isTrue); + final String todayContent = fileToday.readAsStringSync(); + expect(todayContent, contains('Question ID: q-123')); + expect(todayContent, contains('User ID: user-456')); + expect(todayContent, contains('Symbol: TSLA')); + expect(todayContent, contains('Response: 3.5')); + expect(todayContent, contains('Correct: 4.0')); + expect(todayContent, contains('Inverted: false')); + + // Verify yesterday's file still exists + expect(fileYesterday.existsSync(), isTrue); + + // Verify day before yesterday's file and older files were deleted + expect(fileBeforeYesterday.existsSync(), isFalse); + expect(fileOlder.existsSync(), isFalse); + }); + + test('submitAnswer rounds correct answer to nearest integer and handles strict (-0.5, 0.5) interval', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + ProspectiveAnswerScoring.closenessExtraPointsEnabled = true; + addTearDown(() { + ProspectiveAnswerScoring.closenessExtraPointsEnabled = false; + }); + + const String uid = 'rounding-grading-uid'; + final DateTime now = DateTime.now().toUtc(); + final DateTime newerSlot = + MarketHistorySessionSlot.lastCompletedSlotStart(now); + final DateTime olderSlot = + MarketHistorySessionSlot.previousSlotStart(newerSlot)!; + + await testDb!.seedUser(uid); + + Future seedProspective(String symbol, num delta) async { + final Result inserted = await testDb!.connection.execute( + Sql.named( + ''' + INSERT INTO market_history_prospective_questions ( + compare_until, newer_slot_start, older_slot_start, + symbol, question_text, correct_answer, + price_delta_pct, volume_delta_pct, avg_volume_usd, + older_slot, newer_slot + ) VALUES ( + @compare_until, @newer_slot_start, @older_slot_start, + @symbol, 'test text', @correct, + @correct, 1.0, 100, + '{}'::jsonb, '{}'::jsonb + ) + RETURNING id + ''', + ), + parameters: { + 'compare_until': MarketHistorySessionSlot.endExclusive(newerSlot), + 'newer_slot_start': newerSlot, + 'older_slot_start': olderSlot, + 'symbol': symbol, + 'correct': delta, + }, + ); + return inserted.first[0].toString(); + } + + // AAPL: correct answer 0.3 (should round to 0). User response 0. + // Result should be correct. + final String p1 = await seedProspective('AAPL', 0.3); + final Map q1 = await testDb!.questionsDb.createQuestion( + assignedUserId: uid, + questionText: 'Guess AAPL', + correctAnswer: 0.3, + sourceTag: 'market_history:prospective', + pipelineKey: 'trading', + pipelineStep: 'guess_weekly_move:await_answer', + metadata: { + 'prospective_question_id': p1, + 'symbol': 'AAPL', + }, + ); + await testDb!.questionsDb.submitAnswer( + questionId: q1['id']! as String, + assignedUserId: uid, + userResponse: 0, + ); + + // MSFT: correct answer -0.3 (should round to 0). User response 0. + // Result should be correct. + final String p2 = await seedProspective('MSFT', -0.3); + final Map q2 = await testDb!.questionsDb.createQuestion( + assignedUserId: uid, + questionText: 'Guess MSFT', + correctAnswer: -0.3, + sourceTag: 'market_history:prospective', + pipelineKey: 'trading', + pipelineStep: 'guess_weekly_move:await_answer', + metadata: { + 'prospective_question_id': p2, + 'symbol': 'MSFT', + }, + ); + await testDb!.questionsDb.submitAnswer( + questionId: q2['id']! as String, + assignedUserId: uid, + userResponse: 0, + ); + + // GOOG: correct answer 0.5 (should round to 1). User response 0. + // Result should be incorrect. + final String p3 = await seedProspective('GOOG', 0.5); + final Map q3 = await testDb!.questionsDb.createQuestion( + assignedUserId: uid, + questionText: 'Guess GOOG', + correctAnswer: 0.5, + sourceTag: 'market_history:prospective', + pipelineKey: 'trading', + pipelineStep: 'guess_weekly_move:await_answer', + metadata: { + 'prospective_question_id': p3, + 'symbol': 'GOOG', + }, + ); + await testDb!.questionsDb.submitAnswer( + questionId: q3['id']! as String, + assignedUserId: uid, + userResponse: 0, + ); + + final Map score = + await testDb!.questionsDb.getGuessScoreSummary(uid); + // AAPL (correct: 0.3 -> 0, user: 0) -> correct + // MSFT (correct: -0.3 -> 0, user: 0) -> correct + // GOOG (correct: 0.5 -> 1, user: 0) -> incorrect (sameDirection false, score delta -2) + // Score expected: 2 + 2 - 2 = 2 + expect(score['answersTotal'], 3); + expect(score['answersCorrect'], 2); + expect(score['total'], 2); + }); } diff --git a/server/test/spelling/spelling_audio_handler_test.dart b/server/test/spelling/spelling_audio_handler_test.dart new file mode 100644 index 0000000..4d39677 --- /dev/null +++ b/server/test/spelling/spelling_audio_handler_test.dart @@ -0,0 +1,209 @@ +import 'dart:io'; + +import 'package:cyberhybridhub_server/handlers/spelling_audio_handler.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shelf/shelf.dart'; +import 'package:test/test.dart'; + +void main() { + group('SpellingAudioHandler tests', () { + late Directory tempCacheDir; + late File logFile; + + setUp(() { + tempCacheDir = Directory('spelling_audio_cache'); + if (tempCacheDir.existsSync()) { + try { + tempCacheDir.deleteSync(recursive: true); + } catch (_) {} + } + logFile = File('spelling_audio_errors.log'); + if (logFile.existsSync()) { + try { + logFile.deleteSync(); + } catch (_) {} + } + }); + + tearDown(() { + if (tempCacheDir.existsSync()) { + try { + tempCacheDir.deleteSync(recursive: true); + } catch (_) {} + } + if (logFile.existsSync()) { + try { + logFile.deleteSync(); + } catch (_) {} + } + }); + + test('serves cached file if exists', () async { + tempCacheDir.createSync(recursive: true); + final File localFile = File('${tempCacheDir.path}/hello.mp3'); + localFile.writeAsBytesSync([1, 2, 3, 4]); + + final handler = spellingAudioHandler(); + final request = Request('GET', Uri.parse('http://localhost/v1/spelling/audio/hello.mp3')); + final response = await handler(request); + + expect(response.statusCode, 200); + expect(response.headers['Content-Type'], 'audio/mpeg'); + expect(await response.read().first, [1, 2, 3, 4]); + }); + + test('downloads, caches and serves file on-demand if not cached', () async { + final mockClient = MockClient((request) async { + if (request.url.path.contains('/entries/en/hello')) { + const jsonResponse = ''' + [ + { + "word": "hello", + "phonetics": [ + { + "audio": "https://example.com/hello.mp3" + } + ] + } + ] + '''; + return http.Response(jsonResponse, 200); + } + if (request.url.toString() == 'https://example.com/hello.mp3') { + return http.Response.bytes([5, 6, 7, 8], 200); + } + return http.Response('Not found', 404); + }); + + final handler = spellingAudioHandler(httpClient: mockClient); + final request = Request('GET', Uri.parse('http://localhost/v1/spelling/audio/hello.mp3')); + final response = await handler(request); + + expect(response.statusCode, 200); + expect(response.headers['Content-Type'], 'audio/mpeg'); + expect(await response.read().first, [5, 6, 7, 8]); + + // Check if file is saved locally + final File localFile = File('${tempCacheDir.path}/hello.mp3'); + expect(localFile.existsSync(), isTrue); + expect(localFile.readAsBytesSync(), [5, 6, 7, 8]); + }); + + test('returns 404 if dictionary api fails or has no audio', () async { + final mockClient = MockClient((request) async { + return http.Response('[]', 200); + }); + + final handler = spellingAudioHandler(httpClient: mockClient); + final request = Request('GET', Uri.parse('http://localhost/v1/spelling/audio/nonexistent.mp3')); + final response = await handler(request); + + expect(response.statusCode, 404); + }); + + test('falls back to Merriam-Webster Collegiate API if dictionaryapi.dev returns empty/fails', () async { + final mockClient = MockClient((request) async { + if (request.url.path.contains('/entries/en/apple')) { + return http.Response('[]', 200); // dictionaryapi.dev returns no entries + } + if (request.url.path.contains('/references/collegiate/json/apple')) { + expect(request.url.queryParameters['key'], 'TEST_KEY'); + const mwResponse = ''' + [ + { + "hwi": { + "prs": [ + { + "sound": { + "audio": "apple" + } + } + ] + } + } + ] + '''; + return http.Response(mwResponse, 200); + } + if (request.url.toString() == 'https://media.merriam-webster.com/audio/prons/en/us/mp3/a/apple.mp3') { + return http.Response.bytes([9, 10, 11, 12], 200); + } + return http.Response('Not found', 404); + }); + + final handler = spellingAudioHandler(merriamWebsterApiKey: 'TEST_KEY', httpClient: mockClient); + final request = Request('GET', Uri.parse('http://localhost/v1/spelling/audio/apple.mp3')); + final response = await handler(request); + + expect(response.statusCode, 200); + expect(response.headers['Content-Type'], 'audio/mpeg'); + expect(await response.read().first, [9, 10, 11, 12]); + + final File localFile = File('${tempCacheDir.path}/apple.mp3'); + expect(localFile.existsSync(), isTrue); + expect(localFile.readAsBytesSync(), [9, 10, 11, 12]); + }); + + test('logs failures to spelling_audio_errors.log when APIs return non-200 or empty data', () async { + final mockClient = MockClient((request) async { + if (request.url.path.contains('/entries/en/failedword')) { + return http.Response('Server Error', 500); + } + return http.Response('Not found', 404); + }); + + final handler = spellingAudioHandler(httpClient: mockClient); + final request = Request('GET', Uri.parse('http://localhost/v1/spelling/audio/failedword.mp3')); + final response = await handler(request); + + expect(response.statusCode, 404); + expect(logFile.existsSync(), isTrue); + final String logContents = logFile.readAsStringSync(); + expect(logContents, contains('=== Audio Fetch Failure ===')); + expect(logContents, contains('Word: failedword')); + expect(logContents, contains('API: dictionaryapi.dev')); + expect(logContents, contains('Status Code: 500')); + expect(logContents, contains('Response Body: Server Error')); + }); + + test('falls back to Google Cloud TTS if both dictionaries return empty/fails', () async { + final mockClient = MockClient((request) async { + if (request.url.path.contains('/entries/en/orange')) { + return http.Response('[]', 200); + } + if (request.url.path.contains('/references/collegiate/json/orange')) { + return http.Response('[]', 200); + } + if (request.url.path.contains('/text:synthesize')) { + expect(request.url.queryParameters['key'], 'GOOGLE_KEY'); + expect(request.body, contains('"text":"orange"')); + const gcResponse = ''' + { + "audioContent": "ZGVtb2J5dGVz" + } + '''; + return http.Response(gcResponse, 200); + } + return http.Response('Not found', 404); + }); + + final handler = spellingAudioHandler( + merriamWebsterApiKey: 'TEST_KEY', + googleCloudApiKey: 'GOOGLE_KEY', + httpClient: mockClient, + ); + final request = Request('GET', Uri.parse('http://localhost/v1/spelling/audio/orange.mp3')); + final response = await handler(request); + + expect(response.statusCode, 200); + expect(response.headers['Content-Type'], 'audio/mpeg'); + // base64Decode("ZGVtb2J5dGVz") -> "demobytes" -> [100, 101, 109, 111, 98, 121, 116, 101, 115] + expect(await response.read().first, [100, 101, 109, 111, 98, 121, 116, 101, 115]); + + final File localFile = File('${tempCacheDir.path}/orange.mp3'); + expect(localFile.existsSync(), isTrue); + expect(localFile.readAsBytesSync(), [100, 101, 109, 111, 98, 121, 116, 101, 115]); + }); + }); +} diff --git a/test/admin/models/admin_models_test.dart b/test/admin/models/admin_models_test.dart new file mode 100644 index 0000000..9ac190a --- /dev/null +++ b/test/admin/models/admin_models_test.dart @@ -0,0 +1,125 @@ +import 'package:cyberhybridhub/admin/models/market_history_admin_config.dart'; +import 'package:cyberhybridhub/admin/models/market_history_week_coverage.dart'; +import 'package:cyberhybridhub/admin/models/question_audit_asset.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('MarketHistoryAdminConfig', () { + test('parses from null JSON', () { + final config = MarketHistoryAdminConfig.fromJson(null); + expect(config.archiveEnabled, isFalse); + expect(config.windowDays, 5); + expect(config.retentionDays, 5); + expect(config.syncEnabled, isFalse); + }); + + test('parses from valid JSON', () { + final config = MarketHistoryAdminConfig.fromJson({ + 'archiveEnabled': true, + 'windowDays': 10, + 'retentionDays': 30, + 'syncEnabled': true, + }); + expect(config.archiveEnabled, isTrue); + expect(config.windowDays, 10); + expect(config.retentionDays, 30); + expect(config.syncEnabled, isTrue); + }); + }); + + group('QuestionAuditReport', () { + test('parses from JSON with custom slot starts', () { + final report = QuestionAuditReport.fromJson({ + 'compareUntil': '2026-05-30T16:00:00Z', + 'newerSlotStart': '2026-05-30T12:45:00Z', + 'olderSlotStart': '2026-05-30T09:30:00Z', + 'windowDays': 5, + 'canStepOlder': true, + 'canStepNewer': false, + 'stepOlderCompareUntil': '2026-05-25T16:00:00Z', + 'assets': [ + { + 'symbol': 'AAPL', + 'priceDelta': 1.25, + 'volumeDelta': 5000, + 'olderSlot': { + 'asOf': '2026-05-30T09:30:00Z', + 'open': 150.0, + 'high': 152.0, + 'low': 149.0, + 'close': 151.0, + 'avgPrice': 150.5, + 'volume': 10000, + 'volumeUsd': 1505000, + }, + 'newerSlot': { + 'asOf': '2026-05-30T12:45:00Z', + 'avgPrice': 152.0, + 'volume': 12000, + } + } + ] + }); + + expect(report.compareUntil, DateTime.parse('2026-05-30T16:00:00Z').toUtc()); + expect(report.newerSlotStart, DateTime.parse('2026-05-30T12:45:00Z').toUtc()); + expect(report.olderSlotStart, DateTime.parse('2026-05-30T09:30:00Z').toUtc()); + expect(report.canStepOlder, isTrue); + expect(report.canStepNewer, isFalse); + expect(report.stepOlderCompareUntil, DateTime.parse('2026-05-25T16:00:00Z').toUtc()); + expect(report.stepNewerCompareUntil, isNull); + expect(report.assets.length, 1); + + final asset = report.assets.first; + expect(asset.symbol, 'AAPL'); + expect(asset.priceDelta, 1.25); + expect(asset.olderSlot.open, 150.0); + expect(asset.olderSlot.volumeUsd, 1505000); + expect(asset.newerSlot.volumeUsd, 12000 * 152.0); // fallback calculation + }); + + test('parses from JSON with default/missing slot starts', () { + final report = QuestionAuditReport.fromJson({ + 'asOf': '2026-05-30T16:00:00Z', + 'windowDays': 5, + }); + expect(report.compareUntil, DateTime.parse('2026-05-30T16:00:00Z').toUtc()); + expect(report.newerSlotStart, report.compareUntil.subtract(const Duration(hours: 3, minutes: 15))); + expect(report.olderSlotStart, report.compareUntil.subtract(const Duration(hours: 6, minutes: 30))); + }); + }); + + group('MarketHistoryWeekCoverageReport', () { + test('parses from valid JSON', () { + final report = MarketHistoryWeekCoverageReport.fromJson({ + 'asOf': '2026-05-30T15:00:00Z', + 'windowDays': 5, + 'slotsPerDay': 6, + 'symbolCount': 10, + 'isConsistent': true, + 'days': [ + { + 'date': '2026-05-30', + 'slotsPerDay': 6, + 'completedSlots': 6, + 'fullySyncedSlots': 6, + 'slots': [ + { + 'slotStart': '2026-05-30T00:00:00Z', + 'completed': true, + 'fullySynced': true, + 'syncedSymbolCount': 10, + 'expectedSymbolCount': 10, + } + ] + } + ] + }); + + expect(report.asOf, DateTime.parse('2026-05-30T15:00:00Z').toUtc()); + expect(report.days.length, 1); + expect(report.days.first.date, DateTime.parse('2026-05-30T00:00:00Z').toUtc()); + expect(report.days.first.slots.first.completed, isTrue); + }); + }); +} diff --git a/test/admin/screens/market_history_log_screen_test.dart b/test/admin/screens/market_history_log_screen_test.dart index 33b9050..2de82ec 100644 --- a/test/admin/screens/market_history_log_screen_test.dart +++ b/test/admin/screens/market_history_log_screen_test.dart @@ -115,14 +115,19 @@ Future _pumpScreen( } class _FakeCoverageApi extends MarketHistoryAdminApi { - _FakeCoverageApi(this.report) - : super( + _FakeCoverageApi( + this.report, { + this.audioFiles, + this.audioError = false, + }) : super( baseUrl: 'http://localhost:3000', tokenProvider: () async => 'token', client: MockClient((http.Request request) async => http.Response('', 500)), ); final MarketHistoryWeekCoverageReport report; + final List? audioFiles; + final bool audioError; @override Future fetchWeekCoverage() async => report; @@ -138,6 +143,45 @@ class _FakeCoverageApi extends MarketHistoryAdminApi { canStepNewer: false, assets: const [], ); + + @override + Future> fetchMarketQuestionLog({DateTime? asOf}) async { + return [ + '[2026-05-27T10:00:00.000Z] Question ID: q100, User ID: u1, Symbol: AAPL, Response: 100.0, Correct: 100.0, Inverted: false', + '[2026-05-27T09:00:00.000Z] Question ID: q99, User ID: u2, Symbol: TSLA, Response: 150.0, Correct: 200.0, Inverted: true', + '[2026-05-27T08:00:00.000Z] Question ID: q98, User ID: u3, Symbol: MSFT, Response: -50.0, Correct: 100.0, Inverted: false', + '[2026-05-27T07:00:00.000Z] Question ID: q97, User ID: u4, Symbol: GOOG, Response: 50.0, Correct: -100.0, Inverted: true', + 'Raw unparseable console log line fallback check', + ]; + } + + @override + Future> fetchSpellingAudioCache() async { + if (audioError) { + throw Exception('Server error loading audio cache'); + } + return audioFiles ?? [ + 'apple.mp3', + 'banana.mp3', + ]; + } + + bool adjustSpellingAudioCalled = false; + String? adjustFilename; + double? adjustVolume; + bool? adjustNormalize; + + @override + Future adjustSpellingAudio({ + required String filename, + double? volume, + bool? normalize, + }) async { + adjustSpellingAudioCalled = true; + adjustFilename = filename; + adjustVolume = volume; + adjustNormalize = normalize; + } } MarketHistoryWeekCoverageReport _sampleWeekReport() { @@ -542,6 +586,228 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('week-coverage-dialog')), findsNothing); }); + + testWidgets('renders dropdown with market history log selected by default and updates on change', ( + WidgetTester tester, + ) async { + final _FakeController controller = _FakeController([ + const SyncRunLogRepositoryState( + pinned: [], + history: [], + nextBefore: null, + isLoading: false, + errorMessage: null, + ), + ]); + + await _pumpScreen(tester, controller); + + final Finder dropdownFinder = find.byKey(const Key('admin-portal-dropdown')); + expect(dropdownFinder, findsOneWidget); + + final DropdownButton dropdown = tester.widget>(dropdownFinder); + expect(dropdown.value, 'market_history_log'); + }); + + testWidgets('selects Market Question Log option and displays log entries', ( + WidgetTester tester, + ) async { + final _FakeController controller = _FakeController([ + const SyncRunLogRepositoryState( + pinned: [], + history: [], + nextBefore: null, + isLoading: false, + errorMessage: null, + ), + ]); + + await _pumpScreen( + tester, + controller, + coverageApi: _FakeCoverageApi(_sampleWeekReport()), + ); + + expect(find.byKey(const Key('admin-portal-dropdown')), findsOneWidget); + + await tester.tap(find.byKey(const Key('admin-portal-dropdown'))); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Market Question Log').last); + await tester.pumpAndSettle(); + + final DropdownButton dropdown = tester.widget>( + find.byKey(const Key('admin-portal-dropdown')), + ); + expect(dropdown.value, 'market_question_log'); + + expect(find.text('User: u1 • Q: q100'), findsOneWidget); + expect(find.text('User: u2 • Q: q99'), findsOneWidget); + expect(find.text('User: u3 • Q: q98'), findsOneWidget); + expect(find.text('User: u4 • Q: q97'), findsOneWidget); + expect(find.text('Raw unparseable console log line fallback check'), findsOneWidget); + + expect(find.text('AAPL'), findsOneWidget); + expect(find.text('TSLA'), findsOneWidget); + expect(find.text('MSFT'), findsOneWidget); + expect(find.text('GOOG'), findsOneWidget); + expect(find.text('INVERTED'), findsNWidgets(2)); + expect(find.text('Inverted: false'), findsNWidgets(2)); + expect(find.text('Inverted: true'), findsNWidgets(2)); + expect(find.text('Points: +1'), findsNWidgets(2)); + expect(find.text('Points: -1'), findsNWidgets(2)); + + await tester.tap(find.byKey(const Key('question-log-refresh-button'))); + await tester.pumpAndSettle(); + }); + + testWidgets('selects Spelling Audio Cache option, lists files and triggers play', ( + WidgetTester tester, + ) async { + final _FakeController controller = _FakeController([ + const SyncRunLogRepositoryState( + pinned: [], + history: [], + nextBefore: null, + isLoading: false, + errorMessage: null, + ), + ]); + + await _pumpScreen( + tester, + controller, + coverageApi: _FakeCoverageApi(_sampleWeekReport()), + ); + + await tester.tap(find.byKey(const Key('admin-portal-dropdown'))); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Spelling Audio Cache').last); + await tester.pumpAndSettle(); + + final DropdownButton dropdown = tester.widget>( + find.byKey(const Key('admin-portal-dropdown')), + ); + expect(dropdown.value, 'spelling_audio_cache'); + + expect(find.text('apple.mp3'), findsOneWidget); + expect(find.text('banana.mp3'), findsOneWidget); + + final Finder playButton = find.byKey(const Key('play-button-apple.mp3')); + expect(playButton, findsOneWidget); + + await tester.tap(playButton); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('audio-cache-refresh-button'))); + await tester.pumpAndSettle(); + }); + + testWidgets('selects Spelling Audio Cache option and handles empty state', ( + WidgetTester tester, + ) async { + final _FakeController controller = _FakeController([ + const SyncRunLogRepositoryState( + pinned: [], + history: [], + nextBefore: null, + isLoading: false, + errorMessage: null, + ), + ]); + + await _pumpScreen( + tester, + controller, + coverageApi: _FakeCoverageApi( + _sampleWeekReport(), + audioFiles: [], + ), + ); + + await tester.tap(find.byKey(const Key('admin-portal-dropdown'))); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Spelling Audio Cache').last); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('spelling-audio-cache-empty-state')), findsOneWidget); + }); + + testWidgets('selects Spelling Audio Cache option and handles error state', ( + WidgetTester tester, + ) async { + final _FakeController controller = _FakeController([ + const SyncRunLogRepositoryState( + pinned: [], + history: [], + nextBefore: null, + isLoading: false, + errorMessage: null, + ), + ]); + + await _pumpScreen( + tester, + controller, + coverageApi: _FakeCoverageApi( + _sampleWeekReport(), + audioError: true, + ), + ); + + await tester.tap(find.byKey(const Key('admin-portal-dropdown'))); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Spelling Audio Cache').last); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('spelling-audio-cache-error-state')), findsOneWidget); + }); + + testWidgets('selects Spelling Audio Cache option and adjusts levels of a file', ( + WidgetTester tester, + ) async { + final _FakeController controller = _FakeController([ + const SyncRunLogRepositoryState( + pinned: [], + history: [], + nextBefore: null, + isLoading: false, + errorMessage: null, + ), + ]); + + final _FakeCoverageApi api = _FakeCoverageApi(_sampleWeekReport()); + + await _pumpScreen( + tester, + controller, + coverageApi: api, + ); + + await tester.tap(find.byKey(const Key('admin-portal-dropdown'))); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Spelling Audio Cache').last); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('volume-slider-apple.mp3')), findsOneWidget); + expect(find.byKey(const Key('normalize-checkbox-apple.mp3')), findsOneWidget); + expect(find.byKey(const Key('save-adjust-apple.mp3')), findsOneWidget); + + await tester.tap(find.byKey(const Key('normalize-checkbox-apple.mp3'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('save-adjust-apple.mp3'))); + await tester.pumpAndSettle(); + + expect(api.adjustSpellingAudioCalled, isTrue); + expect(api.adjustFilename, 'apple.mp3'); + expect(api.adjustNormalize, isTrue); + expect(api.adjustVolume, 1.0); + }); } class _PendingController implements SyncRunLogController { diff --git a/test/admin/services/market_history_admin_api_test.dart b/test/admin/services/market_history_admin_api_test.dart index aa1493b..8ec4187 100644 --- a/test/admin/services/market_history_admin_api_test.dart +++ b/test/admin/services/market_history_admin_api_test.dart @@ -255,4 +255,77 @@ void main() { expect(report.assets.single.newerSlot.close, 12); expect(report.assets.single.newerSlot.volumeUsd, 1875); }); + + test('fetchMarketQuestionLog parses log entries', () async { + final MarketHistoryAdminApi api = MarketHistoryAdminApi( + baseUrl: 'http://localhost:3000', + tokenProvider: () async => 'test-token', + client: MockClient((http.Request request) async { + expect(request.url.path, '/v1/admin/market-history/question-log'); + expect(request.url.queryParameters['asOf'], '2026-05-30T12:00:00.000Z'); + return http.Response( + jsonEncode({ + 'entries': ['line 2', 'line 1'], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + + final List entries = await api.fetchMarketQuestionLog( + asOf: DateTime.utc(2026, 5, 30, 12), + ); + expect(entries, ['line 2', 'line 1']); + }); + + test('fetchSpellingAudioCache parses files list', () async { + final MarketHistoryAdminApi api = MarketHistoryAdminApi( + baseUrl: 'http://localhost:3000', + tokenProvider: () async => 'test-token', + client: MockClient((http.Request request) async { + expect(request.url.path, '/v1/admin/market-history/spelling-audio-cache'); + return http.Response( + jsonEncode({ + 'files': ['apple.mp3', 'banana.mp3'], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + + final List files = await api.fetchSpellingAudioCache(); + expect(files, ['apple.mp3', 'banana.mp3']); + }); + + test('adjustSpellingAudio posts correct payload and handles success', () async { + late Uri seenUri; + late String seenBody; + final MarketHistoryAdminApi api = MarketHistoryAdminApi( + baseUrl: 'http://localhost:3000', + tokenProvider: () async => 'test-token', + client: MockClient((http.Request request) async { + seenUri = request.url; + seenBody = request.body; + return http.Response( + jsonEncode({'success': true}), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + + await api.adjustSpellingAudio( + filename: 'apple.mp3', + volume: 1.5, + normalize: true, + ); + + expect(seenUri.path, '/v1/admin/market-history/spelling-audio-cache/adjust'); + final Map decoded = jsonDecode(seenBody) as Map; + expect(decoded['filename'], 'apple.mp3'); + expect(decoded['volume'], 1.5); + expect(decoded['normalize'], true); + }); } diff --git a/test/home_screen_score_test.dart b/test/home_screen_score_test.dart index fdd104f..d3cd0e9 100644 --- a/test/home_screen_score_test.dart +++ b/test/home_screen_score_test.dart @@ -38,6 +38,9 @@ void main() { ), ); + await tester.tap(find.byKey(const Key('question-source-settings-button'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('topbar-cumulative-score')), findsOneWidget); expect(find.text('Score: 3.50'), findsOneWidget); @@ -80,6 +83,9 @@ void main() { ), ); + await tester.tap(find.byKey(const Key('question-source-settings-button'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('topbar-cumulative-score'))); await tester.pumpAndSettle(); @@ -91,7 +97,12 @@ void main() { findsOneWidget, ); - await tester.tap(find.text('Cancel')); + await tester.tap( + find.descendant( + of: find.byKey(const Key('guess-score-reset-confirm-dialog')), + matching: find.text('Cancel'), + ), + ); await tester.pumpAndSettle(); expect( diff --git a/test/question_source_settings_test.dart b/test/question_source_settings_test.dart index a145630..c648222 100644 --- a/test/question_source_settings_test.dart +++ b/test/question_source_settings_test.dart @@ -102,7 +102,10 @@ void main() { await tester.tap(find.text('Open')); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('question-source-edit-spelling'))); + final Finder editSpelling = find.byKey(const Key('question-source-edit-spelling')); + await tester.ensureVisible(editSpelling); + await tester.pumpAndSettle(); + await tester.tap(editSpelling); await tester.pumpAndSettle(); expect(find.text('Spelling words page'), findsOneWidget); @@ -247,14 +250,19 @@ void main() { expect(tester.widget(find.byKey(const Key('question-source-input-spelling'))).controller?.text, '30'); // 4. Manually disable spelling. - await tester.tap(find.byKey(const Key('question-source-enabled-spelling'))); + final Finder enabledSpelling = find.byKey(const Key('question-source-enabled-spelling')); + await tester.ensureVisible(enabledSpelling); + await tester.pumpAndSettle(); + await tester.tap(enabledSpelling); await tester.pumpAndSettle(); // Verify spelling is disabled and market_history automatically becomes 100%. expect(tester.widget(marketInput).controller?.text, '100'); // 5. Manually enable spelling. - await tester.tap(find.byKey(const Key('question-source-enabled-spelling'))); + await tester.ensureVisible(enabledSpelling); + await tester.pumpAndSettle(); + await tester.tap(enabledSpelling); await tester.pumpAndSettle(); // Verify spelling gets enabled and shares the ratio evenly (50/50). diff --git a/test/services/dictionary_audio_parsing_test.dart b/test/services/dictionary_audio_parsing_test.dart new file mode 100644 index 0000000..a40772f --- /dev/null +++ b/test/services/dictionary_audio_parsing_test.dart @@ -0,0 +1,112 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; + +String? extractAudioUrl(String responseBody) { + try { + final List entries = jsonDecode(responseBody) as List; + for (final entry in entries) { + if (entry is Map) { + final phonetics = entry['phonetics']; + if (phonetics is List) { + for (final phonetic in phonetics) { + if (phonetic is Map) { + final String? audio = phonetic['audio'] as String?; + if (audio != null && audio.isNotEmpty) { + if (audio.startsWith('//')) { + return 'https:$audio'; + } + return audio; + } + } + } + } + } + } + } catch (_) {} + return null; +} + +void main() { + group('Dictionary Audio Parser Tests', () { + test('extracts standard audio URL', () { + const jsonStr = ''' + [ + { + "word": "hello", + "phonetics": [ + { + "text": "/həˈləʊ/", + "audio": "https://api.dictionaryapi.dev/media/pronunciations/en/hello-uk.mp3" + } + ] + } + ] + '''; + expect( + extractAudioUrl(jsonStr), + 'https://api.dictionaryapi.dev/media/pronunciations/en/hello-uk.mp3', + ); + }); + + test('handles protocol-relative URL starting with //', () { + const jsonStr = ''' + [ + { + "word": "test", + "phonetics": [ + { + "audio": "//ssl.gstatic.com/dictionary/static/sounds/20201201/test--us.mp3" + } + ] + } + ] + '''; + expect( + extractAudioUrl(jsonStr), + 'https://ssl.gstatic.com/dictionary/static/sounds/20201201/test--us.mp3', + ); + }); + + test('skips empty audio fields and selects first valid audio URL', () { + const jsonStr = ''' + [ + { + "word": "multiple", + "phonetics": [ + { + "text": "/mʌl.tɪ.pl̩/", + "audio": "" + }, + { + "audio": "https://example.com/valid.mp3" + } + ] + } + ] + '''; + expect( + extractAudioUrl(jsonStr), + 'https://example.com/valid.mp3', + ); + }); + + test('returns null for empty json or missing audio', () { + expect(extractAudioUrl('[]'), isNull); + expect(extractAudioUrl('invalid json'), isNull); + + const missingAudioJson = ''' + [ + { + "word": "missing", + "phonetics": [ + { + "text": "/mɪs.ɪŋ/" + } + ] + } + ] + '''; + expect(extractAudioUrl(missingAudioJson), isNull); + }); + }); +} diff --git a/test/widgets/question_presentation_router_test.dart b/test/widgets/question_presentation_router_test.dart index ac89198..8214d81 100644 --- a/test/widgets/question_presentation_router_test.dart +++ b/test/widgets/question_presentation_router_test.dart @@ -33,7 +33,7 @@ void main() { home: Scaffold( body: QuestionPresentationRouter( question: question, - onAnswer: (_) async {}, + onAnswer: (_) async => null, onDefer: () async {}, ), ), @@ -62,7 +62,7 @@ void main() { home: Scaffold( body: QuestionPresentationRouter( question: question, - onAnswer: (_) async {}, + onAnswer: (_) async => null, onDefer: () async {}, ), ), @@ -93,7 +93,7 @@ void main() { home: Scaffold( body: QuestionPresentationRouter( question: question, - onAnswer: (_) async {}, + onAnswer: (_) async => null, onAnswerText: (_) async {}, onDefer: () async {}, ), diff --git a/test/widgets/swipe_question_tile_test.dart b/test/widgets/swipe_question_tile_test.dart new file mode 100644 index 0000000..4079413 --- /dev/null +++ b/test/widgets/swipe_question_tile_test.dart @@ -0,0 +1,131 @@ +import 'package:cyberhybridhub/services/questions_hub_service.dart'; +import 'package:cyberhybridhub/widgets/swipe_question_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + setUp(() { + // Ensure the panel is marked open so the tile captures keyboard events. + QuestionsHubService.instance.questionPanelOpen.value = true; + }); + + tearDown(() { + QuestionsHubService.instance.questionPanelOpen.value = false; + }); + + testWidgets('allows swiping right at zero to submit answer', (tester) async { + num? submittedValue; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SwipeQuestionTile( + questionId: 'test-q-1', + onSwipeRight: (val) async { + submittedValue = val; + return null; + }, + onSwipeLeft: () async {}, + ), + ), + ), + ); + + // Swipe right starting from the center of the widget. + // Swipe distance needs to exceed _swipeThreshold (96). + final Finder tileFinder = find.byType(SwipeQuestionTile); + final Offset start = tester.getCenter(tileFinder); + + // Drag right by 200 pixels. + await tester.dragFrom(start, const Offset(200, 0)); + await tester.pumpAndSettle(); + + expect(submittedValue, 0); + }); + + testWidgets('allows swiping left at zero to defer question', (tester) async { + bool deferred = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SwipeQuestionTile( + questionId: 'test-q-2', + onSwipeRight: (_) async => null, + onSwipeLeft: () async { + deferred = true; + }, + ), + ), + ), + ); + + // Swipe left starting from the center of the widget. + final Finder tileFinder = find.byType(SwipeQuestionTile); + final Offset start = tester.getCenter(tileFinder); + + // Drag left by 200 pixels. + await tester.dragFrom(start, const Offset(-200, 0)); + await tester.pumpAndSettle(); + + expect(deferred, true); + }); + + testWidgets('allows right arrow key holding/press at zero to submit answer', (tester) async { + num? submittedValue; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SwipeQuestionTile( + questionId: 'test-q-3', + onSwipeRight: (val) async { + submittedValue = val; + return null; + }, + onSwipeLeft: () async {}, + ), + ), + ), + ); + await tester.pump(); + + // Simulate holding right arrow key. + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.pump(const Duration(seconds: 2)); // Wait past hold edge duration (1s) + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + + expect(submittedValue, 0); + }); + + testWidgets('allows left arrow key holding/press at zero to defer question', (tester) async { + bool deferred = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SwipeQuestionTile( + questionId: 'test-q-4', + onSwipeRight: (_) async => null, + onSwipeLeft: () async { + deferred = true; + }, + ), + ), + ), + ); + await tester.pump(); + + // Simulate holding left arrow key. + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + await tester.pump(const Duration(seconds: 2)); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + + expect(deferred, true); + }); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 1fb0989..cbad602 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -14,6 +15,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); ConnectivityPlusWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FirebaseAuthPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index d8348dc..c6e5910 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_windows connectivity_plus firebase_auth firebase_core