From be57c17c15cb99b6a2e8abc31a23c37ab80c3c76 Mon Sep 17 00:00:00 2001 From: nathanAndrinoid Date: Fri, 5 Jun 2026 15:10:03 +0000 Subject: [PATCH] better spelling --- lib/models/incoming_question.dart | 7 + .../spelling_progress_reset_result.dart | 12 ++ lib/screens/home_screen.dart | 14 +- lib/services/questions_api_service.dart | 31 ++++ lib/services/questions_hub_service.dart | 31 ++++ lib/theme/app_theme.dart | 1 + lib/utils/spelling_answer_policy.dart | 6 + lib/widgets/question_presentation_router.dart | 1 + .../question_source_settings_sheet.dart | 99 ++++++++++- lib/widgets/spelling_question_tile.dart | 157 +++++++++++++----- server/lib/handlers/questions_handler.dart | 18 ++ server/lib/pipeline/question_pipeline.dart | 20 +++ server/lib/questions_db.dart | 33 ++++ .../spelling/spelling_question_links_db.dart | 13 ++ .../spelling/user_spelling_progress_db.dart | 13 ++ .../integration/spelling_pipeline_test.dart | 99 +++++++++++ test/question_source_settings_test.dart | 4 + .../question_presentation_router_test.dart | 1 + test/widgets/spelling_question_tile_test.dart | 75 +++++++++ 19 files changed, 588 insertions(+), 47 deletions(-) create mode 100644 lib/models/spelling_progress_reset_result.dart create mode 100644 lib/utils/spelling_answer_policy.dart create mode 100644 test/widgets/spelling_question_tile_test.dart diff --git a/lib/models/incoming_question.dart b/lib/models/incoming_question.dart index f0fec91..3ae3ebc 100644 --- a/lib/models/incoming_question.dart +++ b/lib/models/incoming_question.dart @@ -9,6 +9,7 @@ class IncomingQuestion { this.presentation = defaultPresentation, this.spokenWord, this.audioTitle, + this.canonicalWord, this.needsPrepare = false, }); @@ -34,6 +35,9 @@ class IncomingQuestion { /// Question id referenced as the audio utterance title. final String? audioTitle; + /// Proper spelling used to grade the typed answer. + final String? canonicalWord; + /// True when the server still needs tile-load word selection. final bool needsPrepare; @@ -77,6 +81,7 @@ class IncomingQuestion { presentation: json['presentation'] as String? ?? defaultPresentation, spokenWord: json['spokenWord'] as String?, audioTitle: json['audioTitle'] as String?, + canonicalWord: json['canonicalWord'] as String?, needsPrepare: json['needsPrepare'] as bool? ?? false, ); } @@ -85,6 +90,7 @@ class IncomingQuestion { String? text, String? spokenWord, String? audioTitle, + String? canonicalWord, int? unansweredCount, bool? needsPrepare, }) { @@ -97,6 +103,7 @@ class IncomingQuestion { presentation: presentation, spokenWord: spokenWord ?? this.spokenWord, audioTitle: audioTitle ?? this.audioTitle, + canonicalWord: canonicalWord ?? this.canonicalWord, needsPrepare: needsPrepare ?? this.needsPrepare, ); } diff --git a/lib/models/spelling_progress_reset_result.dart b/lib/models/spelling_progress_reset_result.dart new file mode 100644 index 0000000..4b3d00b --- /dev/null +++ b/lib/models/spelling_progress_reset_result.dart @@ -0,0 +1,12 @@ +import 'incoming_question.dart'; + +/// Outcome of resetting per-user spelling links and familiarity progress. +class SpellingProgressResetResult { + const SpellingProgressResetResult({ + required this.unansweredCount, + this.question, + }); + + final int unansweredCount; + final IncomingQuestion? question; +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index e9a80d7..af74bee 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -386,13 +386,21 @@ class HomeScreen extends StatelessWidget { ), ), ListenableBuilder( - listenable: QuestionsHubService.instance.scoreResetBusy, + listenable: Listenable.merge([ + QuestionsHubService.instance.scoreResetBusy, + QuestionsHubService.instance.spellingResetBusy, + ]), builder: (BuildContext context, Widget? child) { - if (!QuestionsHubService.instance.scoreResetBusy.value) { + final bool blocking = + QuestionsHubService.instance.scoreResetBusy.value || + QuestionsHubService.instance.spellingResetBusy.value; + if (!blocking) { return const SizedBox.shrink(); } return Stack( - key: const Key('guess-score-reset-blocking-overlay'), + key: QuestionsHubService.instance.spellingResetBusy.value + ? const Key('spelling-progress-reset-blocking-overlay') + : const Key('guess-score-reset-blocking-overlay'), children: [ ModalBarrier( dismissible: false, diff --git a/lib/services/questions_api_service.dart b/lib/services/questions_api_service.dart index 5eb8b42..0ef9375 100644 --- a/lib/services/questions_api_service.dart +++ b/lib/services/questions_api_service.dart @@ -7,6 +7,7 @@ import '../config/api_config.dart'; import '../models/guess_score_reset_result.dart'; import '../models/guess_score_summary.dart'; import '../models/incoming_question.dart'; +import '../models/spelling_progress_reset_result.dart'; import '../models/question_defer_result.dart'; import '../models/question_submit_result.dart'; import 'auth_service.dart'; @@ -160,6 +161,36 @@ class QuestionsApiService { return IncomingQuestion.fromJson(questionJson); } + /// Clears spelling links and familiarity progress, then re-seeds when enabled. + Future resetSpellingProgress() async { + final String? token = await AuthService.instance.getIdToken(); + if (token == null) { + return null; + } + + final http.Response response = await _client.post( + Uri.parse('$apiBaseUrl/v1/me/questions/spelling/reset'), + headers: _authHeaders(token), + ); + if (response.statusCode != 200) { + debugPrint( + 'resetSpellingProgress failed: ${response.statusCode} ${response.body}', + ); + return null; + } + + final Map body = + jsonDecode(response.body) as Map; + final Map? questionJson = + body['question'] as Map?; + return SpellingProgressResetResult( + unansweredCount: (body['unansweredCount'] as num?)?.toInt() ?? 0, + question: questionJson == null + ? null + : IncomingQuestion.fromJson(questionJson), + ); + } + Future deferQuestion({required String questionId}) async { final String? token = await AuthService.instance.getIdToken(); if (token == null) { diff --git a/lib/services/questions_hub_service.dart b/lib/services/questions_hub_service.dart index 995095b..fb43a38 100644 --- a/lib/services/questions_hub_service.dart +++ b/lib/services/questions_hub_service.dart @@ -31,6 +31,7 @@ class QuestionsHubService { ValueNotifier>([]); final ValueNotifier questionActionBusy = ValueNotifier(false); final ValueNotifier scoreResetBusy = ValueNotifier(false); + final ValueNotifier spellingResetBusy = ValueNotifier(false); final ValueNotifier guessScoreSummary = ValueNotifier(GuessScoreSummary.empty); @@ -418,6 +419,36 @@ class QuestionsHubService { guessScoreSummary.value = score; } + /// Clears spelling links, familiarity progress, and spelling questions on the server. + Future resetSpellingProgress() async { + spellingResetBusy.value = true; + try { + final result = await _api.resetSpellingProgress(); + if (result == null) { + return false; + } + + final List refreshed = await _api.fetchUnanswered(); + final List active = + refreshed.where(_acceptsQuestionSource).toList(growable: false); + if (active.isNotEmpty) { + pendingQuestion.value = active.first; + pendingQuestionCount.value = active.length; + hasPendingQuestion.value = true; + if (questionPanelOpen.value) { + questionQueue.value = active; + } + } else { + pendingQuestion.value = null; + questionQueue.value = []; + hasPendingQuestion.value = _shouldKeepPanelOpenWithoutQuestion(); + } + return true; + } finally { + spellingResetBusy.value = false; + } + } + /// Clears cumulative guess score and answer statistics on the server. Future resetGuessScoreSummary() async { scoreResetBusy.value = true; diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index a94d598..d602019 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -9,6 +9,7 @@ abstract final class AppColors { static const Color textPrimary = Color(0xFFF8FAFC); static const Color textSecondary = Color(0xFF94A3B8); static const Color success = Color(0xFF34D399); + static const Color error = Color(0xFFF87171); } ThemeData buildAppTheme() { diff --git a/lib/utils/spelling_answer_policy.dart b/lib/utils/spelling_answer_policy.dart new file mode 100644 index 0000000..cc0e4f5 --- /dev/null +++ b/lib/utils/spelling_answer_policy.dart @@ -0,0 +1,6 @@ +/// Grading rules for typed spelling answers (mirrors server policy). +abstract final class SpellingAnswerPolicy { + static bool matches(String typed, String canonical) { + return typed.trim().toLowerCase() == canonical.trim().toLowerCase(); + } +} diff --git a/lib/widgets/question_presentation_router.dart b/lib/widgets/question_presentation_router.dart index d3e3858..a961808 100644 --- a/lib/widgets/question_presentation_router.dart +++ b/lib/widgets/question_presentation_router.dart @@ -32,6 +32,7 @@ class QuestionPresentationRouter extends StatelessWidget { questionText: question.text, spokenWord: question.spokenWord, audioTitle: question.audioTitle, + canonicalWord: question.canonicalWord, needsPrepare: question.needsPrepare, busy: busy, onSubmitAnswer: onAnswerText ?? (_) async {}, diff --git a/lib/widgets/question_source_settings_sheet.dart b/lib/widgets/question_source_settings_sheet.dart index ee796a3..cce1ec3 100644 --- a/lib/widgets/question_source_settings_sheet.dart +++ b/lib/widgets/question_source_settings_sheet.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import '../models/question_source_preference.dart'; import '../repositories/question_source_config_repository.dart'; import '../routes/app_routes.dart'; -import '../screens/spelling_list_screen.dart'; +import '../services/questions_hub_service.dart'; import '../theme/app_theme.dart'; /// Configure which question API sources are active and their relative mix. @@ -43,6 +43,7 @@ class _QuestionSourceSettingsSheetState extends State _draft; String? _validationMessage; bool _saving = false; + bool _resettingSpelling = false; @override void initState() { @@ -63,6 +64,53 @@ class _QuestionSourceSettingsSheetState extends State _confirmResetSpellingProgress() async { + final bool? confirmed = await showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + key: const Key('spelling-progress-reset-confirm-dialog'), + title: const Text('Reset spelling progress?'), + content: const Text( + 'This deletes your spelling question links and familiarity scores ' + 'and returns spelling to its initial state. You cannot undo this.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + TextButton( + key: const Key('spelling-progress-reset-confirm-button'), + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Reset'), + ), + ], + ); + }, + ); + if (confirmed != true || !mounted) { + return; + } + + setState(() { + _resettingSpelling = true; + _validationMessage = null; + }); + + final bool ok = + await QuestionsHubService.instance.resetSpellingProgress(); + if (!mounted) { + return; + } + setState(() { + _resettingSpelling = false; + if (!ok) { + _validationMessage = 'Could not reset spelling progress.'; + } + }); + } + Future _save() async { final bool hasEnabled = _draft.any((QuestionSourcePreference source) => source.enabled); @@ -103,7 +151,9 @@ class _QuestionSourceSettingsSheetState extends State s.enabled).length; - return Padding( + return Stack( + children: [ + Padding( padding: const EdgeInsets.fromLTRB(16, 12, 8, 16), child: Column( mainAxisSize: MainAxisSize.min, @@ -126,7 +176,9 @@ class _QuestionSourceSettingsSheetState extends State Navigator.of(context).pop(), + onPressed: _resettingSpelling || _saving + ? null + : () => Navigator.of(context).pop(), icon: const Icon(Icons.close), ), ], @@ -150,6 +202,10 @@ class _QuestionSourceSettingsSheetState extends State _openSpellingWordList(context) : null, + onResetSpelling: _draft[index].id == 'spelling' + ? _confirmResetSpellingProgress + : null, + resetSpellingBusy: _resettingSpelling, ), ], ), @@ -182,13 +238,15 @@ class _QuestionSourceSettingsSheetState extends State[ TextButton( - onPressed: () => Navigator.of(context).pop(), + onPressed: _resettingSpelling || _saving + ? null + : () => Navigator.of(context).pop(), child: const Text('Cancel'), ), const SizedBox(width: 8), FilledButton( key: const Key('question-source-settings-save'), - onPressed: _saving ? null : _save, + onPressed: _saving || _resettingSpelling ? null : _save, child: _saving ? const SizedBox( width: 18, @@ -201,6 +259,24 @@ class _QuestionSourceSettingsSheetState extends State onChanged; final VoidCallback? onEditSpelling; + final VoidCallback? onResetSpelling; + final bool resetSpellingBusy; @override Widget build(BuildContext context) { @@ -288,9 +368,16 @@ class _QuestionSourceRow extends StatelessWidget { IconButton( key: const Key('question-source-edit-spelling'), tooltip: 'Edit spelling word list', - onPressed: onEditSpelling, + onPressed: resetSpellingBusy ? null : onEditSpelling, icon: const Icon(Icons.edit, size: 20), ), + if (onResetSpelling != null) + IconButton( + key: const Key('question-source-reset-spelling'), + tooltip: 'Reset spelling progress', + onPressed: resetSpellingBusy ? null : onResetSpelling, + icon: const Icon(Icons.restart_alt, size: 20), + ), Switch( key: Key('question-source-enabled-${source.id}'), value: source.enabled, diff --git a/lib/widgets/spelling_question_tile.dart b/lib/widgets/spelling_question_tile.dart index 9c7f306..6b67170 100644 --- a/lib/widgets/spelling_question_tile.dart +++ b/lib/widgets/spelling_question_tile.dart @@ -6,6 +6,7 @@ import '../models/incoming_question.dart'; import '../services/questions_api_service.dart'; import '../services/spelling_text_to_speech.dart'; import '../theme/app_theme.dart'; +import '../utils/spelling_answer_policy.dart'; import '../utils/spelling_spoken_word.dart'; /// Spelling quiz: hear the word, type your spelling, and submit. @@ -19,6 +20,7 @@ class SpellingQuestionTile extends StatefulWidget { this.busy = false, this.spokenWord, this.audioTitle, + this.canonicalWord, this.needsPrepare = false, this.onPrepared, this.prepareQuestion, @@ -36,6 +38,9 @@ class SpellingQuestionTile extends StatefulWidget { /// Question id used as the TTS utterance title. final String? audioTitle; + /// Proper spelling shown after submit for green/red feedback. + final String? canonicalWord; + /// When true, calls the prepare API to select a word at tile load. final bool needsPrepare; @@ -53,16 +58,21 @@ class _SpellingQuestionTileState extends State { final SpellingTextToSpeech _speech = SpellingTextToSpeech(); final QuestionsApiService _api = QuestionsApiService(); final TextEditingController _answerController = TextEditingController(); + final FocusNode _answerFocusNode = FocusNode(); String? _spokenWord; String? _audioTitle; + String? _canonicalWord; bool _preparing = false; + bool _showingFeedback = false; + bool? _answerCorrect; @override void initState() { super.initState(); _spokenWord = widget.spokenWord; _audioTitle = widget.audioTitle ?? widget.questionId; + _canonicalWord = widget.canonicalWord; WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_prepareAndSpeak())); } @@ -73,6 +83,9 @@ class _SpellingQuestionTileState extends State { _answerController.clear(); _spokenWord = widget.spokenWord; _audioTitle = widget.audioTitle ?? widget.questionId; + _canonicalWord = widget.canonicalWord; + _showingFeedback = false; + _answerCorrect = null; WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_prepareAndSpeak())); } } @@ -80,10 +93,27 @@ class _SpellingQuestionTileState extends State { @override void dispose() { _answerController.dispose(); + _answerFocusNode.dispose(); unawaited(_speech.stop()); super.dispose(); } + void _requestAnswerFieldFocus() { + if (!mounted || + !_wordReady || + widget.busy || + _preparing || + _showingFeedback) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + _answerFocusNode.requestFocus(); + }); + } + Future _prepareAndSpeak() async { if (_preparing) { return; @@ -91,6 +121,7 @@ class _SpellingQuestionTileState extends State { if (!widget.needsPrepare) { await _speakWord(); + _requestAnswerFieldFocus(); return; } @@ -107,6 +138,7 @@ class _SpellingQuestionTileState extends State { setState(() { _spokenWord = prepared.spokenWord ?? _spokenWord; _audioTitle = prepared.audioTitle ?? prepared.id; + _canonicalWord = prepared.canonicalWord ?? _canonicalWord; }); widget.onPrepared?.call(prepared); } @@ -114,6 +146,7 @@ class _SpellingQuestionTileState extends State { } finally { if (mounted) { setState(() => _preparing = false); + _requestAnswerFieldFocus(); } } } @@ -137,18 +170,48 @@ class _SpellingQuestionTileState extends State { ); } + String? get _canonicalForGrading => + _canonicalWord ?? widget.canonicalWord; + Future _submitAnswer() async { final String answer = _answerController.text.trim(); - if (answer.isEmpty || widget.busy || _preparing || !_wordReady) { + if (answer.isEmpty || + widget.busy || + _preparing || + _showingFeedback || + !_wordReady) { return; } + + final String? canonical = _canonicalForGrading; + if (canonical == null || canonical.isEmpty) { + await widget.onSubmitAnswer(answer); + return; + } + + final bool correct = SpellingAnswerPolicy.matches(answer, canonical); + setState(() { + _answerCorrect = correct; + _showingFeedback = true; + }); + + await Future.delayed(const Duration(seconds: 1)); + if (!mounted) { + return; + } + await widget.onSubmitAnswer(answer); } @override Widget build(BuildContext context) { - final bool canInteract = _wordReady && !widget.busy && !_preparing; - final bool canSpeak = _wordReady; + final bool canInteract = + _wordReady && !widget.busy && !_preparing && !_showingFeedback; + final bool canSpeak = _wordReady && !_showingFeedback; + 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), @@ -185,44 +248,62 @@ class _SpellingQuestionTileState extends State { ), ), const SizedBox(height: 28), - TextField( - key: const Key('spelling-answer-field'), - controller: _answerController, - enabled: canInteract, - textCapitalization: TextCapitalization.none, - autocorrect: false, - enableSuggestions: false, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: AppColors.textPrimary, + 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, + ), + 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, ), - 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, ), - 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'), - ), + const SizedBox(height: 16), + FilledButton( + key: const Key('spelling-submit-answer'), + onPressed: canInteract ? () => unawaited(_submitAnswer()) : null, + child: const Text('Submit'), + ), + const SizedBox(height: 8), + TextButton( + key: const Key('spelling-skip-question'), + onPressed: widget.busy || _preparing ? null : widget.onDefer, + child: const Text('Skip for now'), + ), + ], ], ), ); diff --git a/server/lib/handlers/questions_handler.dart b/server/lib/handlers/questions_handler.dart index 104ed27..8133117 100644 --- a/server/lib/handlers/questions_handler.dart +++ b/server/lib/handlers/questions_handler.dart @@ -165,6 +165,24 @@ Handler questionsHandler({ }, ); + router.post('$questionsBasePath/spelling/reset', (Request request) async { + final String? firebaseUid = await _verify(auth, request); + if (firebaseUid == null) { + return _jsonResponse(401, {'error': 'Unauthorized'}); + } + if (questionPipeline == null) { + return _jsonResponse(503, {'error': 'Unavailable'}); + } + try { + final Map result = + await questionPipeline.resetSpellingProgressForUser(firebaseUid); + return _jsonResponse(200, result); + } catch (e, st) { + stderr.writeln('Reset spelling progress error: $e\n$st'); + return _jsonResponse(500, {'error': 'Internal error'}); + } + }); + router.post( '$questionsBasePath//spelling/prepare', (Request request, String id) async { diff --git a/server/lib/pipeline/question_pipeline.dart b/server/lib/pipeline/question_pipeline.dart index c6cc736..e08520c 100644 --- a/server/lib/pipeline/question_pipeline.dart +++ b/server/lib/pipeline/question_pipeline.dart @@ -459,6 +459,26 @@ class QuestionPipeline { ); } + /// Clears spelling links, progress, questions, and pipeline state; re-seeds when enabled. + Future> resetSpellingProgressForUser( + String firebaseUid, + ) async { + await _spellingQuestionLinksDb.deleteAllForUser(firebaseUid); + await _spellingProgressDb.deleteAllForUser(firebaseUid); + await _questionsDb.deleteAllSpellingQuestionsForUser(firebaseUid); + await _questionsDb.clearSpellingPipelineState(firebaseUid); + await maintainSpellingForUser(firebaseUid); + + final Map? question = + await _questionDispatcher?.dispatchNextQuestion(firebaseUid); + final int unansweredCount = + await _questionsDb.countUnansweredQuestions(firebaseUid); + return { + 'unansweredCount': unansweredCount, + if (question != null) 'question': question, + }; + } + /// Ensures an unanswered spelling word quiz exists when the source is enabled. Future maintainSpellingForUser(String firebaseUid) async { if (!await _isSpellingEnabled(firebaseUid)) { diff --git a/server/lib/questions_db.dart b/server/lib/questions_db.dart index 66ebf34..fd91ec0 100644 --- a/server/lib/questions_db.dart +++ b/server/lib/questions_db.dart @@ -106,6 +106,37 @@ class QuestionsDb { return rows.first; } + /// Deletes every spelling pipeline question row for [firebaseUid]. + Future deleteAllSpellingQuestionsForUser(String firebaseUid) async { + await _connection.execute( + Sql.named( + ''' + DELETE FROM questions + WHERE assigned_user_id = @uid + AND ( + pipeline_key = 'spelling' + OR source_tag LIKE 'spelling:%' + ) + ''', + ), + parameters: {'uid': firebaseUid}, + ); + } + + /// Clears spelling pipeline state when [assignedUserId] is on that branch. + Future clearSpellingPipelineState(String assignedUserId) async { + await _connection.execute( + Sql.named( + ''' + DELETE FROM user_pipeline_state + WHERE assigned_user_id = @uid + AND pipeline_key = 'spelling' + ''', + ), + parameters: {'uid': assignedUserId}, + ); + } + /// Deletes every guess-game question row for [firebaseUid]. /// /// Assignments and answer snapshots referencing these rows cascade away. @@ -712,6 +743,8 @@ class QuestionsDb { if (isPreparedSpelling) ...{ 'spokenWord': spokenWord, 'audioTitle': question['id'], + if (metadata['canonical_word'] is String) + 'canonicalWord': metadata['canonical_word'], }, }; } diff --git a/server/lib/spelling/spelling_question_links_db.dart b/server/lib/spelling/spelling_question_links_db.dart index f060533..ced46bf 100644 --- a/server/lib/spelling/spelling_question_links_db.dart +++ b/server/lib/spelling/spelling_question_links_db.dart @@ -31,6 +31,19 @@ class SpellingQuestionLinksDb { ); } + /// Removes every quiz-to-item link for [firebaseUid]. + Future deleteAllForUser(String firebaseUid) async { + await _connection.execute( + Sql.named( + ''' + DELETE FROM spelling_question_links + WHERE firebase_uid = @uid + ''', + ), + parameters: {'uid': firebaseUid}, + ); + } + Future hasLink(String questionId) async { final Result result = await _connection.execute( Sql.named( diff --git a/server/lib/spelling/user_spelling_progress_db.dart b/server/lib/spelling/user_spelling_progress_db.dart index 4a75443..17aaf4d 100644 --- a/server/lib/spelling/user_spelling_progress_db.dart +++ b/server/lib/spelling/user_spelling_progress_db.dart @@ -89,6 +89,19 @@ class UserSpellingProgressDb { ); } + /// Removes all per-user spelling familiarity rows (initial progress state). + Future deleteAllForUser(String firebaseUid) async { + await _connection.execute( + Sql.named( + ''' + DELETE FROM user_spelling_progress + WHERE firebase_uid = @uid + ''', + ), + parameters: {'uid': firebaseUid}, + ); + } + Future familiarityScore({ required String firebaseUid, required String spellingItemId, diff --git a/server/test/integration/spelling_pipeline_test.dart b/server/test/integration/spelling_pipeline_test.dart index 089e6db..39a21fb 100644 --- a/server/test/integration/spelling_pipeline_test.dart +++ b/server/test/integration/spelling_pipeline_test.dart @@ -4,6 +4,7 @@ library; import 'dart:convert'; import 'package:cyberhybridhub_server/pipeline/question_pipeline.dart'; +import 'package:cyberhybridhub_server/question_dispatcher.dart'; import 'package:postgres/postgres.dart'; import 'package:test/test.dart'; @@ -107,6 +108,7 @@ void main() { expect(prepared, isNotNull); expect(prepared!['spokenWord'], isNotEmpty); expect(prepared['audioTitle'], questionId); + expect(prepared['canonicalWord'], isNotEmpty); final Result link = await testDb!.connection.execute( Sql.named( @@ -250,4 +252,101 @@ void main() { expect(rows.last[0], PipelineSteps.spellingFollowUp); expect(rows.last[1], 'spelling:follow_up'); }); + + test('resetSpellingProgressForUser clears links and progress then re-seeds', + () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'spelling-reset-uid'; + await testDb!.seedUser(uid); + + final QuestionDispatcher dispatcher = testDb!.questionDispatcher(); + final QuestionPipeline pipeline = testDb!.questionPipeline(testMode: true); + pipeline.attachQuestionDispatcher(dispatcher); + await pipeline.maintainSpellingForUser(uid); + + final Result reserved = await testDb!.connection.execute( + Sql.named( + ''' + SELECT id + FROM questions + WHERE assigned_user_id = @uid + LIMIT 1 + ''', + ), + parameters: {'uid': uid}, + ); + final String questionId = reserved.first[0]!.toString(); + await pipeline.prepareSpellingQuestion( + firebaseUid: uid, + questionId: questionId, + ); + + final Result progressBefore = await testDb!.connection.execute( + Sql.named( + ''' + SELECT COUNT(*)::int + FROM user_spelling_progress + WHERE firebase_uid = @uid + ''', + ), + parameters: {'uid': uid}, + ); + final Result linksBefore = await testDb!.connection.execute( + Sql.named( + ''' + SELECT COUNT(*)::int + FROM spelling_question_links + WHERE firebase_uid = @uid + ''', + ), + parameters: {'uid': uid}, + ); + expect(progressBefore.first[0], greaterThan(0)); + expect(linksBefore.first[0], greaterThan(0)); + + final Map result = + await pipeline.resetSpellingProgressForUser(uid); + expect(result['unansweredCount'], greaterThanOrEqualTo(1)); + + final Result progressAfter = await testDb!.connection.execute( + Sql.named( + ''' + SELECT COUNT(*)::int + FROM user_spelling_progress + WHERE firebase_uid = @uid + ''', + ), + parameters: {'uid': uid}, + ); + final Result linksAfter = await testDb!.connection.execute( + Sql.named( + ''' + SELECT COUNT(*)::int + FROM spelling_question_links + WHERE firebase_uid = @uid + ''', + ), + parameters: {'uid': uid}, + ); + expect(progressAfter.first[0], 0); + expect(linksAfter.first[0], 0); + + final Result spellingQuestions = await testDb!.connection.execute( + Sql.named( + ''' + SELECT COUNT(*)::int + FROM questions + WHERE assigned_user_id = @uid + AND pipeline_key = 'spelling' + AND user_response IS NULL + ''', + ), + parameters: {'uid': uid}, + ); + expect(spellingQuestions.first[0], 1); + }); } diff --git a/test/question_source_settings_test.dart b/test/question_source_settings_test.dart index 6db7368..82baa34 100644 --- a/test/question_source_settings_test.dart +++ b/test/question_source_settings_test.dart @@ -62,6 +62,10 @@ void main() { find.byKey(const Key('question-source-edit-spelling')), findsOneWidget, ); + expect( + find.byKey(const Key('question-source-reset-spelling')), + findsOneWidget, + ); }); testWidgets('spelling edit opens spelling word list screen', ( diff --git a/test/widgets/question_presentation_router_test.dart b/test/widgets/question_presentation_router_test.dart index 18d5174..b2f84e3 100644 --- a/test/widgets/question_presentation_router_test.dart +++ b/test/widgets/question_presentation_router_test.dart @@ -14,6 +14,7 @@ Future _mockPrepare(String questionId) async { presentation: 'spelling_word', spokenWord: 'colour', audioTitle: questionId, + canonicalWord: 'colour', ); } diff --git a/test/widgets/spelling_question_tile_test.dart b/test/widgets/spelling_question_tile_test.dart new file mode 100644 index 0000000..03a95df --- /dev/null +++ b/test/widgets/spelling_question_tile_test.dart @@ -0,0 +1,75 @@ +import 'package:cyberhybridhub/theme/app_theme.dart'; +import 'package:cyberhybridhub/widgets/spelling_question_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('shows green canonical word then submits after one second', (tester) async { + var submitCount = 0; + + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + body: SpellingQuestionTile( + questionId: 'spell-1', + 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.tap(find.byKey(const Key('spelling-submit-answer'))); + await tester.pump(); + + expect(submitCount, 0); + final Text feedback = tester.widget( + find.byKey(const Key('spelling-feedback-word')), + ); + expect(feedback.data, 'colour'); + expect(feedback.style?.color, AppColors.success); + + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + + expect(submitCount, 1); + }); + + testWidgets('shows red canonical word when answer is wrong', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + body: SpellingQuestionTile( + questionId: 'spell-2', + questionText: 'Spell the word you hear.', + spokenWord: 'colour', + canonicalWord: 'colour', + onSubmitAnswer: (_) async {}, + onDefer: () async {}, + ), + ), + ), + ); + + await tester.enterText(find.byKey(const Key('spelling-answer-field')), 'color'); + await tester.tap(find.byKey(const Key('spelling-submit-answer'))); + await tester.pump(); + + final Text feedback = tester.widget( + find.byKey(const Key('spelling-feedback-word')), + ); + expect(feedback.data, 'colour'); + expect(feedback.style?.color, AppColors.error); + + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + }); +}