From 59b039a3c023b51836651c86b12d4c711a550be0 Mon Sep 17 00:00:00 2001 From: nathanAndrinoid Date: Sun, 7 Jun 2026 03:10:21 +0000 Subject: [PATCH] all spelling --- .flutter-plugins-dependencies | 2 +- .../remote/spelling_words_remote_store.dart | 21 ++ lib/screens/spelling_list_screen.dart | 142 +++++++--- .../spelling_tts/spelling_tts_web.dart | 14 +- lib/utils/file_saver.dart | 2 + lib/utils/file_saver_stub.dart | 5 + lib/utils/file_saver_web.dart | 14 + lib/widgets/spelling_question_tile.dart | 252 +++++++++++------- pubspec.lock | 4 +- pubspec.yaml | 2 +- .../lib/handlers/spelling_words_handler.dart | 24 +- server/lib/pipeline/question_pipeline.dart | 23 +- server/lib/spelling/spelling_items_db.dart | 54 ++++ .../integration/spelling_pipeline_test.dart | 4 +- .../integration/spelling_words_api_test.dart | 20 ++ test/widgets/spelling_list_screen_test.dart | 190 +++++++++++++ test/widgets/spelling_question_tile_test.dart | 54 +++- 17 files changed, 656 insertions(+), 171 deletions(-) create mode 100644 lib/utils/file_saver.dart create mode 100644 lib/utils/file_saver_stub.dart create mode 100644 lib/utils/file_saver_web.dart create mode 100644 test/widgets/spelling_list_screen_test.dart diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index cd1a086..20d816d 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_picker","path":"/home/chh/.pub-cache/hosted/pub.dev/file_picker-8.3.7/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_tts","path":"/home/chh/.pub-cache/hosted/pub.dev/flutter_tts-4.2.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_mobile","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_mobile-0.2.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_ios","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_ios-6.3.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/chh/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/chh/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_ios","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_ios-6.4.1/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_picker","path":"/home/chh/.pub-cache/hosted/pub.dev/file_picker-8.3.7/","native_build":true,"dependencies":["flutter_plugin_android_lifecycle"],"dev_dependency":false},{"name":"firebase_auth","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_plugin_android_lifecycle","path":"/home/chh/.pub-cache/hosted/pub.dev/flutter_plugin_android_lifecycle-2.0.35/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_tts","path":"/home/chh/.pub-cache/hosted/pub.dev/flutter_tts-4.2.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_mobile","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_mobile-0.2.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_android","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_android-7.2.11/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/chh/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/chh/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/chh/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"shared_preferences_android","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_android-2.4.23/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/chh/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_android","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_android-6.3.29/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_picker","path":"/home/chh/.pub-cache/hosted/pub.dev/file_picker-8.3.7/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_tts","path":"/home/chh/.pub-cache/hosted/pub.dev/flutter_tts-4.2.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_desktop","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_desktop-0.2.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_ios","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_ios-6.3.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/chh/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/chh/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_macos","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_macos-3.2.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_picker","path":"/home/chh/.pub-cache/hosted/pub.dev/file_picker-8.3.7/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_desktop","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_desktop-0.2.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/chh/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/chh/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_linux-2.4.1/","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/chh/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_linux","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.2/","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_picker","path":"/home/chh/.pub-cache/hosted/pub.dev/file_picker-8.3.7/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_tts","path":"/home/chh/.pub-cache/hosted/pub.dev/flutter_tts-4.2.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_desktop","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_desktop-0.2.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/chh/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/chh/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_windows-2.4.1/","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/chh/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_windows","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_windows-3.1.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","dependencies":[],"dev_dependency":false},{"name":"file_picker","path":"/home/chh/.pub-cache/hosted/pub.dev/file_picker-8.3.7/","dependencies":[],"dev_dependency":false},{"name":"firebase_auth_web","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_auth_web-6.2.1/","dependencies":["firebase_core_web"],"dev_dependency":false},{"name":"firebase_core_web","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_core_web-3.7.0/","dependencies":[],"dev_dependency":false},{"name":"flutter_tts","path":"/home/chh/.pub-cache/hosted/pub.dev/flutter_tts-4.2.5/","dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_web","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_web-0.1.0/","dependencies":["google_sign_in_web"],"dev_dependency":false},{"name":"google_sign_in_web","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_web-1.1.3/","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_web-2.4.3/","dependencies":[],"dev_dependency":false},{"name":"url_launcher_web","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_web-2.4.3/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"connectivity_plus","dependencies":[]},{"name":"file_picker","dependencies":["flutter_plugin_android_lifecycle"]},{"name":"firebase_auth","dependencies":["firebase_auth_web","firebase_core"]},{"name":"firebase_auth_web","dependencies":["firebase_core","firebase_core_web"]},{"name":"firebase_core","dependencies":["firebase_core_web"]},{"name":"firebase_core_web","dependencies":[]},{"name":"flutter_plugin_android_lifecycle","dependencies":[]},{"name":"flutter_tts","dependencies":[]},{"name":"google_sign_in","dependencies":["google_sign_in_android","google_sign_in_ios","google_sign_in_web"]},{"name":"google_sign_in_all_platforms","dependencies":["google_sign_in_all_platforms_desktop","google_sign_in_all_platforms_mobile","google_sign_in_all_platforms_web"]},{"name":"google_sign_in_all_platforms_desktop","dependencies":["url_launcher"]},{"name":"google_sign_in_all_platforms_mobile","dependencies":["google_sign_in"]},{"name":"google_sign_in_all_platforms_web","dependencies":["google_sign_in","google_sign_in_web","google_sign_in_all_platforms_mobile"]},{"name":"google_sign_in_android","dependencies":[]},{"name":"google_sign_in_ios","dependencies":[]},{"name":"google_sign_in_web","dependencies":[]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"sqlite3_flutter_libs","dependencies":[]},{"name":"url_launcher","dependencies":["url_launcher_android","url_launcher_ios","url_launcher_linux","url_launcher_macos","url_launcher_web","url_launcher_windows"]},{"name":"url_launcher_android","dependencies":[]},{"name":"url_launcher_ios","dependencies":[]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_macos","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]}],"date_created":"2026-06-06 02:05:50.623480","version":"3.44.1","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_picker","path":"/home/chh/.pub-cache/hosted/pub.dev/file_picker-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":"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":"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":"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":"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":"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":"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-07 02:22:09.009404","version":"3.44.1","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/lib/data/remote/spelling_words_remote_store.dart b/lib/data/remote/spelling_words_remote_store.dart index 1e3ef80..973d321 100644 --- a/lib/data/remote/spelling_words_remote_store.dart +++ b/lib/data/remote/spelling_words_remote_store.dart @@ -110,6 +110,27 @@ class SpellingWordsRemoteStore { return response.body; } + Future> deleteWord(String word) async { + final String? token = await AuthService.instance.getIdToken(); + if (token == null) { + throw StateError('Not signed in'); + } + + final String encodedWord = Uri.encodeComponent(word); + final http.Response response = await _client.delete( + Uri.parse('$apiBaseUrl$_basePath/$encodedWord'), + headers: _authHeaders(token), + ); + if (response.statusCode != 200) { + throw SpellingWordsApiException(response.statusCode, response.body); + } + + final Map body = + jsonDecode(response.body) as Map; + final List words = body['words'] as List? ?? []; + return words.map((dynamic w) => w as String).toList(growable: false); + } + Map _authHeaders(String token) { return { 'Authorization': 'Bearer $token', diff --git a/lib/screens/spelling_list_screen.dart b/lib/screens/spelling_list_screen.dart index 422f740..8687316 100644 --- a/lib/screens/spelling_list_screen.dart +++ b/lib/screens/spelling_list_screen.dart @@ -2,21 +2,28 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import '../data/remote/spelling_words_remote_store.dart'; import '../theme/app_theme.dart'; +import '../utils/file_saver.dart'; /// Lists and manages canonical spelling words in the shared catalog. class SpellingListScreen extends StatefulWidget { - const SpellingListScreen({super.key}); + const SpellingListScreen({ + super.key, + this.api, + }); + + final SpellingWordsRemoteStore? api; @override State createState() => _SpellingListScreenState(); } class _SpellingListScreenState extends State { - final SpellingWordsRemoteStore _api = SpellingWordsRemoteStore(); + late final SpellingWordsRemoteStore _api; List _words = []; bool _loading = true; @@ -26,6 +33,7 @@ class _SpellingListScreenState extends State { @override void initState() { super.initState(); + _api = widget.api ?? SpellingWordsRemoteStore(); _load(); } @@ -97,34 +105,37 @@ class _SpellingListScreenState extends State { }); } - Future _onUpload() async { - final FilePickerResult? result = await FilePicker.platform.pickFiles( + void _onUpload() { + FilePicker.pickFiles( type: FileType.custom, allowedExtensions: ['txt'], withData: true, - ); - if (result == null || result.files.isEmpty) { - return; - } - - final PlatformFile file = result.files.first; - final String? text = _readFileText(file); - if (text == null || text.trim().isEmpty) { - _showSnack('The file is empty or could not be read.'); - return; - } - - await _runBusy(() async { - try { - final ({int added, int skipped}) stats = await _api.importText(text); - await _load(); - if (!mounted) { - return; - } - _showSnack('Upload complete: ${stats.added} added, ${stats.skipped} skipped'); - } catch (_) { - _showSnack('Upload failed.'); + ).then((FilePickerResult? result) { + if (result == null || result.files.isEmpty) { + return; } + + final PlatformFile file = result.files.first; + final String? text = _readFileText(file); + if (text == null || text.trim().isEmpty) { + _showSnack('The file is empty or could not be read.'); + return; + } + + _runBusy(() async { + try { + final ({int added, int skipped}) stats = await _api.importText(text); + await _load(); + if (!mounted) { + return; + } + _showSnack('Upload complete: ${stats.added} added, ${stats.skipped} skipped'); + } catch (_) { + _showSnack('Upload failed.'); + } + }); + }).catchError((dynamic _) { + _showSnack('Could not open file picker.'); }); } @@ -133,20 +144,24 @@ class _SpellingListScreenState extends State { try { final String text = await _api.exportText(); final Uint8List bytes = Uint8List.fromList(utf8.encode(text)); - final String? path = await FilePicker.platform.saveFile( - dialogTitle: 'Save spelling words', - fileName: 'spelling-words.txt', - type: FileType.custom, - allowedExtensions: ['txt'], - bytes: bytes, - ); - if (!mounted) { - return; + if (kIsWeb) { + await saveFileBytes(bytes, 'spelling-words.txt'); + _showSnack('Saved spelling word list'); + } else { + final String? path = await FilePicker.saveFile( + dialogTitle: 'Save spelling words', + fileName: 'spelling-words.txt', + type: FileType.custom, + allowedExtensions: ['txt'], + bytes: bytes, + ); + if (!mounted) { + return; + } + if (path != null) { + _showSnack('Saved spelling word list'); + } } - if (path == null) { - return; - } - _showSnack('Saved spelling word list'); } catch (_) { _showSnack('Download failed.'); } @@ -167,6 +182,49 @@ class _SpellingListScreenState extends State { ); } + Future _onDelete(String word) async { + final bool? confirmed = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + key: const Key('spelling-word-delete-confirm-dialog'), + title: const Text('Delete spelling word?'), + content: Text( + 'This deletes "$word" from the spelling catalog, along with ' + 'all linked quiz history and progress. You cannot undo this.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + key: const Key('spelling-word-delete-confirm-button'), + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ); + }, + ); + if (confirmed != true || !mounted) { + return; + } + + await _runBusy(() async { + try { + final List words = await _api.deleteWord(word); + if (!mounted) { + return; + } + setState(() => _words = words); + _showSnack('Deleted "$word"'); + } catch (_) { + _showSnack('Could not delete word.'); + } + }); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -273,6 +331,12 @@ class _SpellingListScreenState extends State { fontWeight: FontWeight.w500, ), ), + trailing: IconButton( + key: Key('spelling-word-delete-$word'), + icon: const Icon(Icons.delete_outline, color: AppColors.error), + tooltip: 'Delete word', + onPressed: _busy ? null : () => _onDelete(word), + ), ); }, ), diff --git a/lib/services/spelling_tts/spelling_tts_web.dart b/lib/services/spelling_tts/spelling_tts_web.dart index ef2b258..ee25be2 100644 --- a/lib/services/spelling_tts/spelling_tts_web.dart +++ b/lib/services/spelling_tts/spelling_tts_web.dart @@ -62,7 +62,14 @@ class _WebSpeechBackend implements SpellingTtsBackend { } }).toJS; utterance.onError = ((JSAny event) { - debugPrint('Spelling web TTS error: $event'); + String details = 'unknown'; + if (event.isA()) { + try { + final errEvent = event as JSObject; + details = (errEvent as SpeechSynthesisErrorEvent).error; + } catch (_) {} + } + debugPrint('Spelling web TTS error: $details ($event)'); if (!done.isCompleted) { done.complete(); } @@ -100,3 +107,8 @@ class _WebSpeechBackend implements SpellingTtsBackend { pending?.complete(); } } + +@JS() +extension type SpeechSynthesisErrorEvent._(JSObject _) implements JSObject { + external String get error; +} diff --git a/lib/utils/file_saver.dart b/lib/utils/file_saver.dart new file mode 100644 index 0000000..f5376d2 --- /dev/null +++ b/lib/utils/file_saver.dart @@ -0,0 +1,2 @@ +export 'file_saver_stub.dart' + if (dart.library.html) 'file_saver_web.dart'; diff --git a/lib/utils/file_saver_stub.dart b/lib/utils/file_saver_stub.dart new file mode 100644 index 0000000..bb46ac7 --- /dev/null +++ b/lib/utils/file_saver_stub.dart @@ -0,0 +1,5 @@ +import 'dart:typed_data'; + +Future saveFileBytes(Uint8List bytes, String fileName) async { + throw UnsupportedError('saveFileBytes is only supported on web'); +} diff --git a/lib/utils/file_saver_web.dart b/lib/utils/file_saver_web.dart new file mode 100644 index 0000000..e3095d8 --- /dev/null +++ b/lib/utils/file_saver_web.dart @@ -0,0 +1,14 @@ +import 'dart:html' as html; +import 'dart:typed_data'; + +Future saveFileBytes(Uint8List bytes, String fileName) async { + final blob = html.Blob([bytes], 'text/plain; charset=utf-8'); + final url = html.Url.createObjectUrlFromBlob(blob); + final anchor = html.AnchorElement(href: url) + ..setAttribute("download", fileName) + ..style.display = "none"; + html.document.body?.append(anchor); + anchor.click(); + anchor.remove(); + html.Url.revokeObjectUrl(url); +} diff --git a/lib/widgets/spelling_question_tile.dart b/lib/widgets/spelling_question_tile.dart index 9277e5b..3ac1e70 100644 --- a/lib/widgets/spelling_question_tile.dart +++ b/lib/widgets/spelling_question_tile.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../models/incoming_question.dart'; import '../services/questions_api_service.dart'; @@ -66,6 +67,9 @@ class _SpellingQuestionTileState extends State { bool _preparing = false; bool _showingFeedback = false; bool? _answerCorrect; + final FocusNode _tileFocusNode = FocusNode(); + bool _awaitingNext = false; + bool _submitting = false; @override void initState() { @@ -85,6 +89,8 @@ class _SpellingQuestionTileState extends State { _audioTitle = widget.audioTitle ?? widget.questionId; _canonicalWord = widget.canonicalWord; _showingFeedback = false; + _awaitingNext = false; + _submitting = false; _answerCorrect = null; WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_prepareAndSpeak())); } @@ -94,6 +100,7 @@ class _SpellingQuestionTileState extends State { void dispose() { _answerController.dispose(); _answerFocusNode.dispose(); + _tileFocusNode.dispose(); unawaited(_speech.stop()); super.dispose(); } @@ -103,7 +110,8 @@ class _SpellingQuestionTileState extends State { !_wordReady || widget.busy || _preparing || - _showingFeedback) { + _showingFeedback || + _awaitingNext) { return; } WidgetsBinding.instance.addPostFrameCallback((_) { @@ -120,7 +128,7 @@ class _SpellingQuestionTileState extends State { } if (!widget.needsPrepare) { - await _speakWord(); + unawaited(_speakWord()); _requestAnswerFieldFocus(); return; } @@ -142,7 +150,7 @@ class _SpellingQuestionTileState extends State { }); widget.onPrepared?.call(prepared); } - await _speakWord(); + unawaited(_speakWord()); } finally { if (mounted) { setState(() => _preparing = false); @@ -173,19 +181,20 @@ class _SpellingQuestionTileState extends State { String? get _canonicalForGrading => _canonicalWord ?? widget.canonicalWord; - Future _submitAnswer() async { + Future _handleFeedbackShow() async { final String answer = _answerController.text.trim(); if (answer.isEmpty || widget.busy || _preparing || _showingFeedback || + _awaitingNext || !_wordReady) { return; } final String? canonical = _canonicalForGrading; if (canonical == null || canonical.isEmpty) { - await widget.onSubmitAnswer(answer); + await _triggerNextQuestion(); return; } @@ -195,17 +204,31 @@ class _SpellingQuestionTileState extends State { _showingFeedback = true; }); - final DateTime startTime = DateTime.now(); + await Future.delayed(const Duration(seconds: 1)); + if (mounted) { + setState(() { + _showingFeedback = false; + _awaitingNext = true; + }); + _tileFocusNode.requestFocus(); + } + } + + Future _triggerNextQuestion() async { + if (_submitting) { + return; + } + setState(() { + _submitting = true; + }); try { + final String answer = _answerController.text.trim(); await widget.onSubmitAnswer(answer); } catch (_) { - final int elapsedMs = DateTime.now().difference(startTime).inMilliseconds; - final int remainingMs = 1000 - elapsedMs; - if (remainingMs > 0) { - await Future.delayed(Duration(milliseconds: remainingMs)); - } if (mounted) { setState(() { + _submitting = false; + _awaitingNext = false; _showingFeedback = false; _answerCorrect = null; }); @@ -217,105 +240,134 @@ class _SpellingQuestionTileState extends State { @override Widget build(BuildContext context) { final bool canInteract = - _wordReady && !widget.busy && !_preparing && !_showingFeedback; - final bool canSpeak = _wordReady && !_showingFeedback; + _wordReady && !widget.busy && !_preparing && !_showingFeedback && !_awaitingNext; + final bool canSpeak = _wordReady && !_showingFeedback && !_awaitingNext; final String? feedbackWord = _showingFeedback ? _canonicalForGrading : null; final Color? feedbackColor = _answerCorrect == null ? null : (_answerCorrect! ? AppColors.success : AppColors.error); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_preparing) - const Padding( - padding: EdgeInsets.only(bottom: 24), - child: Center( - child: SizedBox( - width: 28, - height: 28, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ), - Center( - child: IconButton.filled( - key: const Key('spelling-speak-word'), - onPressed: canSpeak && !widget.busy - ? () => unawaited(_speakWord()) - : null, - tooltip: 'Hear the word', - iconSize: 36, - style: IconButton.styleFrom( - backgroundColor: AppColors.surfaceElevated, - foregroundColor: canSpeak ? AppColors.accent : AppColors.textSecondary, - disabledForegroundColor: AppColors.textSecondary, - minimumSize: const Size(72, 72), - ), - icon: const Icon(Icons.volume_up_rounded), - ), - ), - const SizedBox(height: 28), - if (feedbackWord != null && feedbackColor != null) - Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: Text( - feedbackWord, - key: const Key('spelling-feedback-word'), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: feedbackColor, - fontWeight: FontWeight.w700, - ), - ), - ) - else ...[ - TextField( - key: const Key('spelling-answer-field'), - controller: _answerController, - focusNode: _answerFocusNode, - autofocus: canInteract, - enabled: canInteract, - textCapitalization: TextCapitalization.none, - autocorrect: false, - enableSuggestions: false, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: AppColors.textPrimary, + return Focus( + focusNode: _tileFocusNode, + autofocus: true, + onKeyEvent: (FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && + (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.numpadEnter)) { + if (_awaitingNext && !_submitting) { + unawaited(_triggerNextQuestion()); + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_preparing) + const Padding( + padding: EdgeInsets.only(bottom: 24), + child: Center( + child: SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(strokeWidth: 2), ), - decoration: InputDecoration( - hintText: canInteract ? 'Type the spelling' : 'Loading word…', - hintStyle: const TextStyle(color: AppColors.textSecondary), - filled: true, - fillColor: AppColors.surfaceElevated, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, ), ), - onSubmitted: - canInteract ? (_) => unawaited(_submitAnswer()) : null, - ), - const SizedBox(height: 16), - FilledButton( - key: const Key('spelling-submit-answer'), - onPressed: canInteract ? () => unawaited(_submitAnswer()) : null, - child: const Text('Submit'), - ), - const SizedBox(height: 8), - TextButton( - key: const Key('spelling-skip-question'), - onPressed: widget.busy || _preparing ? null : widget.onDefer, - child: const Text('Skip for now'), + Center( + child: IconButton.filled( + key: const Key('spelling-speak-word'), + onPressed: canSpeak && !widget.busy + ? () => unawaited(_speakWord()) + : null, + tooltip: 'Hear the word', + iconSize: 36, + style: IconButton.styleFrom( + backgroundColor: AppColors.surfaceElevated, + foregroundColor: canSpeak ? AppColors.accent : AppColors.textSecondary, + disabledForegroundColor: AppColors.textSecondary, + minimumSize: const Size(72, 72), + ), + icon: const Icon(Icons.volume_up_rounded), + ), ), + const SizedBox(height: 28), + if (feedbackWord != null && feedbackColor != null) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + feedbackWord, + key: const Key('spelling-feedback-word'), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: feedbackColor, + fontWeight: FontWeight.w700, + ), + ), + ) + else if (_awaitingNext) ...[ + const SizedBox(height: 24), + FilledButton( + key: const Key('spelling-next-question'), + onPressed: !_submitting ? () => unawaited(_triggerNextQuestion()) : null, + child: _submitting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Next'), + ), + const SizedBox(height: 24), + ] else ...[ + TextField( + key: const Key('spelling-answer-field'), + controller: _answerController, + focusNode: _answerFocusNode, + autofocus: canInteract, + enabled: canInteract, + textCapitalization: TextCapitalization.none, + autocorrect: false, + enableSuggestions: false, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: AppColors.textPrimary, + ), + decoration: InputDecoration( + hintText: canInteract ? 'Type the spelling' : 'Loading word…', + hintStyle: const TextStyle(color: AppColors.textSecondary), + filled: true, + fillColor: AppColors.surfaceElevated, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + onSubmitted: + canInteract ? (_) => unawaited(_handleFeedbackShow()) : null, + ), + const SizedBox(height: 16), + FilledButton( + key: const Key('spelling-submit-answer'), + onPressed: canInteract ? () => unawaited(_handleFeedbackShow()) : null, + child: const Text('Submit'), + ), + const SizedBox(height: 8), + TextButton( + key: const Key('spelling-skip-question'), + onPressed: widget.busy || _preparing ? null : widget.onDefer, + child: const Text('Skip for now'), + ), + ], ], - ], + ), ), ); } diff --git a/pubspec.lock b/pubspec.lock index 339d9d9..5c67b8e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -285,10 +285,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387 url: "https://pub.dev" source: hosted - version: "8.3.7" + version: "11.0.2" firebase_auth: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index f27b41c..aad0eb5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,7 +47,7 @@ dependencies: connectivity_plus: ^6.1.4 signalr_netcore: ^1.4.4 logging: ^1.3.0 - file_picker: ^8.3.7 + file_picker: ^11.0.2 flutter_tts: ^4.2.2 dev_dependencies: diff --git a/server/lib/handlers/spelling_words_handler.dart b/server/lib/handlers/spelling_words_handler.dart index f8b4121..4823dd1 100644 --- a/server/lib/handlers/spelling_words_handler.dart +++ b/server/lib/handlers/spelling_words_handler.dart @@ -99,6 +99,28 @@ Handler spellingWordsHandler({ } }); + router.delete('$spellingWordsBasePath/', (Request request, String word) async { + final String? firebaseUid = await _verify(auth, request); + if (firebaseUid == null) { + return _jsonResponse(401, {'error': 'Unauthorized'}); + } + try { + final String decodedWord = Uri.decodeComponent(word); + final bool deleted = await spellingItemsDb.deleteCanonicalWord(decodedWord); + if (!deleted) { + return _jsonResponse(404, {'error': 'Word not found'}); + } + final List words = await spellingItemsDb.listCanonicalWords(); + return _jsonResponse(200, { + 'word': decodedWord.trim().toLowerCase(), + 'words': words, + }); + } catch (e, st) { + stderr.writeln('Delete spelling word error: $e\n$st'); + return _jsonResponse(500, {'error': 'Internal error'}); + } + }); + return (Request request) async { if (request.method == 'OPTIONS' && request.requestedUri.path.startsWith(spellingWordsBasePath)) { @@ -120,7 +142,7 @@ Future _verify(FirebaseAuthVerifier auth, Request request) { } Map _jsonCorsHeaders() => - apiCorsHeaders(methods: 'GET, POST, OPTIONS'); + apiCorsHeaders(methods: 'GET, POST, DELETE, OPTIONS'); Map _textCorsHeaders() => apiCorsHeaders(methods: 'GET, OPTIONS'); diff --git a/server/lib/pipeline/question_pipeline.dart b/server/lib/pipeline/question_pipeline.dart index e1295ed..ae9a1e3 100644 --- a/server/lib/pipeline/question_pipeline.dart +++ b/server/lib/pipeline/question_pipeline.dart @@ -820,28 +820,7 @@ class QuestionPipeline { correct: outcome == BranchOutcome.match, ); } - final String followUp = switch (outcome) { - BranchOutcome.match => - "Correct — '$canonicalWord' is the proper spelling. " - 'Enter +1 for another word, -1 to pause.', - _ => - "The proper spelling is '$canonicalWord'. " - 'Enter +1 to try another word, -1 to pause.', - }; - await _questionsDb.upsertPipelineState( - assignedUserId: firebaseUid, - pipelineKey: PipelineKeys.spelling, - step: PipelineSteps.spellingFollowUp, - context: context, - ); - await _deliverPipelineQuestion( - firebaseUid: firebaseUid, - questionText: followUp, - correctAnswer: 1, - sourceTag: 'spelling:follow_up', - pipelineKey: PipelineKeys.spelling, - pipelineStep: PipelineSteps.spellingFollowUp, - ); + await _startSpellingPipeline(firebaseUid, force: true); case PipelineSteps.spellingFollowUp: if (userResponse > 0) { await _startSpellingPipeline(firebaseUid, force: true); diff --git a/server/lib/spelling/spelling_items_db.dart b/server/lib/spelling/spelling_items_db.dart index fc4eae6..cea29f9 100644 --- a/server/lib/spelling/spelling_items_db.dart +++ b/server/lib/spelling/spelling_items_db.dart @@ -238,6 +238,60 @@ class SpellingItemsDb { ); } + /// Deletes a canonical word, its items, user progress, links, and associated questions. + Future deleteCanonicalWord(String rawWord) async { + final String canonical = _normalizeCanonical(rawWord); + if (canonical.isEmpty) { + return false; + } + + final Result wordRow = await _connection.execute( + Sql.named( + ''' + SELECT id FROM spelling_words + WHERE canonical_word = @canonical + ''', + ), + parameters: {'canonical': canonical}, + ); + if (wordRow.isEmpty) { + return false; + } + final String wordId = wordRow.first[0]!.toString(); + + // 1. Delete associated questions in the questions table. + await _connection.execute( + Sql.named( + ''' + DELETE FROM questions + WHERE id IN ( + SELECT question_id + FROM spelling_question_links + WHERE spelling_item_id IN ( + SELECT id FROM spelling_items + WHERE spelling_word_id = @wordId::uuid + ) + ) + ''', + ), + parameters: {'wordId': wordId}, + ); + + // 2. Delete the spelling word (ON DELETE CASCADE will clean up spelling_items, + // user_spelling_progress, and spelling_question_links). + final Result deleteResult = await _connection.execute( + Sql.named( + ''' + DELETE FROM spelling_words + WHERE id = @wordId::uuid + ''', + ), + parameters: {'wordId': wordId}, + ); + + return deleteResult.affectedRows > 0; + } + static num _readNumeric(Object? value) { if (value is num) { return value; diff --git a/server/test/integration/spelling_pipeline_test.dart b/server/test/integration/spelling_pipeline_test.dart index 0cf88aa..884f439 100644 --- a/server/test/integration/spelling_pipeline_test.dart +++ b/server/test/integration/spelling_pipeline_test.dart @@ -254,8 +254,8 @@ void main() { parameters: {'uid': uid}, ); expect(rows.length, greaterThanOrEqualTo(2)); - expect(rows.last[0], PipelineSteps.spellingFollowUp); - expect(rows.last[1], 'spelling:follow_up'); + expect(rows.last[0], PipelineSteps.spellingWordQuiz); + expect(rows.last[1], 'spelling:word_quiz'); }); test('resetSpellingProgressForUser clears links and progress then re-seeds', diff --git a/server/test/integration/spelling_words_api_test.dart b/server/test/integration/spelling_words_api_test.dart index 6f66b13..7340857 100644 --- a/server/test/integration/spelling_words_api_test.dart +++ b/server/test/integration/spelling_words_api_test.dart @@ -61,4 +61,24 @@ void main() { expect(words, contains('alpha')); expect(words, contains('beta')); }); + + test('deleteCanonicalWord deletes word and cascades progress and links', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + final SpellingItemsDb db = testDb!.spellingItemsDb; + final bool added = await db.addCanonicalWord('deleteme'); + expect(added, isTrue); + + final List wordsBefore = await db.listCanonicalWords(); + expect(wordsBefore, contains('deleteme')); + + final bool deleted = await db.deleteCanonicalWord('deleteme'); + expect(deleted, isTrue); + + final List wordsAfter = await db.listCanonicalWords(); + expect(wordsAfter, isNot(contains('deleteme'))); + }); } diff --git a/test/widgets/spelling_list_screen_test.dart b/test/widgets/spelling_list_screen_test.dart new file mode 100644 index 0000000..3eb72a5 --- /dev/null +++ b/test/widgets/spelling_list_screen_test.dart @@ -0,0 +1,190 @@ +import 'package:cyberhybridhub/data/remote/spelling_words_remote_store.dart'; +import 'package:cyberhybridhub/screens/spelling_list_screen.dart'; +import 'package:cyberhybridhub/theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class MockSpellingWordsRemoteStore extends SpellingWordsRemoteStore { + MockSpellingWordsRemoteStore({ + this.words = const [], + this.fetchWordsResult, + this.deleteWordResult, + this.deleteWordError, + }); + + List words; + List? fetchWordsResult; + List? deleteWordResult; + Object? deleteWordError; + + final List deletedWords = []; + int fetchCount = 0; + int deleteCount = 0; + + @override + Future> fetchWords() async { + fetchCount++; + if (fetchWordsResult != null) { + return fetchWordsResult!; + } + return words; + } + + @override + Future> deleteWord(String word) async { + deleteCount++; + deletedWords.add(word); + if (deleteWordError != null) { + throw deleteWordError!; + } + if (deleteWordResult != null) { + return deleteWordResult!; + } + final List newWords = List.from(words)..remove(word); + return newWords; + } +} + +void main() { + testWidgets('lists words loaded from remote store', (WidgetTester tester) async { + final mockStore = MockSpellingWordsRemoteStore( + words: ['apple', 'banana', 'cherry'], + ); + + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: SpellingListScreen(api: mockStore), + ), + ); + + // Shows loading first + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + await tester.pumpAndSettle(); + + // Verify loading is gone and words are displayed + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('apple'), findsOneWidget); + expect(find.text('banana'), findsOneWidget); + expect(find.text('cherry'), findsOneWidget); + expect(find.byKey(const Key('spelling-word-delete-apple')), findsOneWidget); + expect(find.byKey(const Key('spelling-word-delete-banana')), findsOneWidget); + expect(find.byKey(const Key('spelling-word-delete-cherry')), findsOneWidget); + }); + + testWidgets('delete button cancel does not call delete API and leaves list intact', + (WidgetTester tester) async { + final mockStore = MockSpellingWordsRemoteStore( + words: ['apple', 'banana'], + ); + + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: SpellingListScreen(api: mockStore), + ), + ); + await tester.pumpAndSettle(); + + // Tap delete button for apple + await tester.tap(find.byKey(const Key('spelling-word-delete-apple'))); + await tester.pumpAndSettle(); + + // Verify confirmation dialog is visible + expect(find.byKey(const Key('spelling-word-delete-confirm-dialog')), findsOneWidget); + expect(find.text('Delete spelling word?'), findsOneWidget); + expect(find.textContaining('This deletes "apple"'), findsOneWidget); + + // Tap Cancel + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + // Dialog should be gone + expect(find.byKey(const Key('spelling-word-delete-confirm-dialog')), findsNothing); + + // Store is not called + expect(mockStore.deleteCount, 0); + expect(mockStore.deletedWords, isEmpty); + + // Words still exist + expect(find.text('apple'), findsOneWidget); + expect(find.text('banana'), findsOneWidget); + }); + + testWidgets('delete button confirm calls delete API, updates UI list, and shows snackbar', + (WidgetTester tester) async { + final mockStore = MockSpellingWordsRemoteStore( + words: ['apple', 'banana'], + ); + + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: SpellingListScreen(api: mockStore), + ), + ); + await tester.pumpAndSettle(); + + // Tap delete button for apple + await tester.tap(find.byKey(const Key('spelling-word-delete-apple'))); + await tester.pumpAndSettle(); + + // Tap Delete inside dialog + await tester.tap(find.byKey(const Key('spelling-word-delete-confirm-button'))); + await tester.pump(); // Start async action + + // Verify mockStore is called + expect(mockStore.deleteCount, 1); + expect(mockStore.deletedWords, ['apple']); + + await tester.pumpAndSettle(); + + // Dialog should be gone + expect(find.byKey(const Key('spelling-word-delete-confirm-dialog')), findsNothing); + + // List is updated + expect(find.text('apple'), findsNothing); + expect(find.text('banana'), findsOneWidget); + + // Snackbar is shown + expect(find.text('Deleted "apple"'), findsOneWidget); + }); + + testWidgets('delete failure shows error snackbar and retains word in list', + (WidgetTester tester) async { + final mockStore = MockSpellingWordsRemoteStore( + words: ['apple', 'banana'], + deleteWordError: Exception('Failed to delete'), + ); + + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: SpellingListScreen(api: mockStore), + ), + ); + await tester.pumpAndSettle(); + + // Tap delete button for apple + await tester.tap(find.byKey(const Key('spelling-word-delete-apple'))); + await tester.pumpAndSettle(); + + // Tap Delete inside dialog + await tester.tap(find.byKey(const Key('spelling-word-delete-confirm-button'))); + await tester.pumpAndSettle(); + + // Dialog is gone + expect(find.byKey(const Key('spelling-word-delete-confirm-dialog')), findsNothing); + + // Store was called + expect(mockStore.deleteCount, 1); + + // List is NOT updated, apple is still there + expect(find.text('apple'), findsOneWidget); + expect(find.text('banana'), findsOneWidget); + + // Error snackbar is shown + expect(find.text('Could not delete word.'), findsOneWidget); + }); +} diff --git a/test/widgets/spelling_question_tile_test.dart b/test/widgets/spelling_question_tile_test.dart index 65e31fb..55e1401 100644 --- a/test/widgets/spelling_question_tile_test.dart +++ b/test/widgets/spelling_question_tile_test.dart @@ -1,10 +1,11 @@ import 'package:cyberhybridhub/theme/app_theme.dart'; import 'package:cyberhybridhub/widgets/spelling_question_tile.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - testWidgets('shows green canonical word then submits after one second', (tester) async { + testWidgets('shows green canonical word then shows Next button and submits when clicked', (tester) async { var submitCount = 0; await tester.pumpWidget( @@ -29,7 +30,7 @@ void main() { await tester.tap(find.byKey(const Key('spelling-submit-answer'))); await tester.pump(); - expect(submitCount, 1); + expect(submitCount, 0); final Text feedback = tester.widget( find.byKey(const Key('spelling-feedback-word')), ); @@ -39,6 +40,13 @@ void main() { await tester.pump(const Duration(seconds: 1)); await tester.pump(); + expect(find.byKey(const Key('spelling-feedback-word')), findsNothing); + expect(find.byKey(const Key('spelling-next-question')), findsOneWidget); + expect(submitCount, 0); + + await tester.tap(find.byKey(const Key('spelling-next-question'))); + await tester.pump(); + expect(submitCount, 1); }); @@ -71,5 +79,47 @@ void main() { await tester.pump(const Duration(seconds: 1)); await tester.pump(); + + expect(find.byKey(const Key('spelling-next-question')), findsOneWidget); + }); + + testWidgets('submits answer when pressing Enter a second time', (tester) async { + var submitCount = 0; + + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + body: SpellingQuestionTile( + questionId: 'spell-3', + questionText: 'Spell the word you hear.', + spokenWord: 'colour', + canonicalWord: 'colour', + onSubmitAnswer: (_) async { + submitCount++; + }, + onDefer: () async {}, + ), + ), + ), + ); + + await tester.enterText(find.byKey(const Key('spelling-answer-field')), 'colour'); + await tester.showKeyboard(find.byKey(const Key('spelling-answer-field'))); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(submitCount, 0); + expect(find.byKey(const Key('spelling-feedback-word')), findsOneWidget); + + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + + expect(find.byKey(const Key('spelling-next-question')), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(submitCount, 1); }); }