import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../models/incoming_question.dart'; import '../services/questions_api_service.dart'; import '../services/spelling_text_to_speech.dart'; import '../theme/app_theme.dart'; import '../utils/spelling_answer_policy.dart'; import '../utils/spelling_spoken_word.dart'; /// Spelling quiz: hear the word, type your spelling, and submit. class SpellingQuestionTile extends StatefulWidget { const SpellingQuestionTile({ super.key, required this.questionId, required this.questionText, required this.onSubmitAnswer, required this.onDefer, this.busy = false, this.spokenWord, this.audioTitle, this.canonicalWord, this.needsPrepare = false, this.onPrepared, this.prepareQuestion, }); final String questionId; final String questionText; final Future Function(String answerText) onSubmitAnswer; final Future Function() onDefer; final bool busy; /// Word read aloud; filled by [prepareQuestion] when omitted. final String? spokenWord; /// Question id used as the TTS utterance title. final String? audioTitle; /// 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; /// Called when prepare returns an updated question payload. final ValueChanged? onPrepared; /// Visible for widget tests (defaults to API prepare). final Future Function(String questionId)? prepareQuestion; @override State createState() => _SpellingQuestionTileState(); } class _SpellingQuestionTileState extends State { final SpellingTextToSpeech _speech = SpellingTextToSpeech(); final QuestionsApiService _api = QuestionsApiService(); final TextEditingController _answerController = TextEditingController(); final FocusNode _answerFocusNode = FocusNode(); String? _spokenWord; String? _audioTitle; String? _canonicalWord; bool _preparing = false; bool _showingFeedback = false; bool? _answerCorrect; final FocusNode _tileFocusNode = FocusNode(); bool _awaitingNext = false; bool _submitting = false; @override void initState() { super.initState(); _spokenWord = widget.spokenWord; _audioTitle = widget.audioTitle ?? widget.questionId; _canonicalWord = widget.canonicalWord; WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_prepareAndSpeak())); } @override void didUpdateWidget(SpellingQuestionTile oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.questionId != widget.questionId) { _answerController.clear(); _spokenWord = widget.spokenWord; _audioTitle = widget.audioTitle ?? widget.questionId; _canonicalWord = widget.canonicalWord; _showingFeedback = false; _awaitingNext = false; _submitting = false; _answerCorrect = null; WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_prepareAndSpeak())); } } @override void dispose() { _answerController.dispose(); _answerFocusNode.dispose(); _tileFocusNode.dispose(); unawaited(_speech.stop()); super.dispose(); } void _requestAnswerFieldFocus() { if (!mounted || !_wordReady || widget.busy || _preparing || _showingFeedback || _awaitingNext) { return; } WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; } _answerFocusNode.requestFocus(); }); } Future _prepareAndSpeak() async { if (_preparing) { return; } if (!widget.needsPrepare) { unawaited(_speakWord()); _requestAnswerFieldFocus(); return; } _preparing = true; setState(() {}); try { final IncomingQuestion? prepared = widget.prepareQuestion != null ? await widget.prepareQuestion!(widget.questionId) : await _api.prepareSpellingQuestion(questionId: widget.questionId); if (!mounted) { return; } if (prepared != null) { setState(() { _spokenWord = prepared.spokenWord ?? _spokenWord; _audioTitle = prepared.audioTitle ?? prepared.id; _canonicalWord = prepared.canonicalWord ?? _canonicalWord; }); widget.onPrepared?.call(prepared); } unawaited(_speakWord()); } finally { if (mounted) { setState(() => _preparing = false); _requestAnswerFieldFocus(); } } } String? get _wordToSpeak => _spokenWord ?? spellingWordToSpeak(widget.questionText); bool get _wordReady { final String? word = _wordToSpeak; return word != null && word.isNotEmpty; } Future _speakWord() async { final String? word = _wordToSpeak; if (word == null || word.isEmpty) { return; } await _speech.speak( word, title: _audioTitle ?? widget.questionId, ); } String? get _canonicalForGrading => _canonicalWord ?? widget.canonicalWord; 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 _triggerNextQuestion(); return; } final bool correct = SpellingAnswerPolicy.matches(answer, canonical); setState(() { _answerCorrect = correct; _showingFeedback = true; }); 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 (_) { if (mounted) { setState(() { _submitting = false; _awaitingNext = false; _showingFeedback = false; _answerCorrect = null; }); } rethrow; } } @override Widget build(BuildContext context) { final bool canInteract = _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 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), ), ), ), 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'), ), ], ], ), ), ); } }