import 'dart:async'; import 'package:flutter/material.dart'; import '../models/incoming_question.dart'; import '../services/questions_api_service.dart'; import '../services/spelling_text_to_speech.dart'; import '../theme/app_theme.dart'; import '../utils/spelling_spoken_word.dart'; /// Spelling quiz: hear the word, type your spelling, and submit. class SpellingQuestionTile extends StatefulWidget { const SpellingQuestionTile({ super.key, required this.questionId, required this.questionText, required this.onSubmitAnswer, required this.onDefer, this.busy = false, this.spokenWord, this.audioTitle, this.needsPrepare = false, this.onPrepared, this.prepareQuestion, }); final String questionId; final String questionText; final Future Function(String answerText) onSubmitAnswer; final Future Function() onDefer; final bool busy; /// Word read aloud; filled by [prepareQuestion] when omitted. final String? spokenWord; /// Question id used as the TTS utterance title. final String? audioTitle; /// When true, calls the prepare API to select a word at tile load. final bool needsPrepare; /// Called when prepare returns an updated question payload. final ValueChanged? onPrepared; /// Visible for widget tests (defaults to API prepare). final Future Function(String questionId)? prepareQuestion; @override State createState() => _SpellingQuestionTileState(); } class _SpellingQuestionTileState extends State { final SpellingTextToSpeech _speech = SpellingTextToSpeech(); final QuestionsApiService _api = QuestionsApiService(); final TextEditingController _answerController = TextEditingController(); String? _spokenWord; String? _audioTitle; bool _preparing = false; @override void initState() { super.initState(); _spokenWord = widget.spokenWord; _audioTitle = widget.audioTitle ?? widget.questionId; WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_prepareAndSpeak())); } @override void didUpdateWidget(SpellingQuestionTile oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.questionId != widget.questionId) { _answerController.clear(); _spokenWord = widget.spokenWord; _audioTitle = widget.audioTitle ?? widget.questionId; WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_prepareAndSpeak())); } } @override void dispose() { _answerController.dispose(); unawaited(_speech.stop()); super.dispose(); } Future _prepareAndSpeak() async { if (_preparing) { return; } if (!widget.needsPrepare) { await _speakWord(); return; } _preparing = true; setState(() {}); try { final IncomingQuestion? prepared = widget.prepareQuestion != null ? await widget.prepareQuestion!(widget.questionId) : await _api.prepareSpellingQuestion(questionId: widget.questionId); if (!mounted) { return; } if (prepared != null) { setState(() { _spokenWord = prepared.spokenWord ?? _spokenWord; _audioTitle = prepared.audioTitle ?? prepared.id; }); widget.onPrepared?.call(prepared); } await _speakWord(); } finally { if (mounted) { setState(() => _preparing = false); } } } String? get _wordToSpeak => _spokenWord ?? spellingWordToSpeak(widget.questionText); bool get _wordReady { final String? word = _wordToSpeak; return word != null && word.isNotEmpty; } Future _speakWord() async { final String? word = _wordToSpeak; if (word == null || word.isEmpty) { return; } await _speech.speak( word, title: _audioTitle ?? widget.questionId, ); } Future _submitAnswer() async { final String answer = _answerController.text.trim(); if (answer.isEmpty || widget.busy || _preparing || !_wordReady) { return; } await widget.onSubmitAnswer(answer); } @override Widget build(BuildContext context) { final bool canInteract = _wordReady && !widget.busy && !_preparing; final bool canSpeak = _wordReady; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (_preparing) const Padding( padding: EdgeInsets.only(bottom: 24), child: Center( child: SizedBox( width: 28, height: 28, child: CircularProgressIndicator(strokeWidth: 2), ), ), ), Center( child: IconButton.filled( key: const Key('spelling-speak-word'), onPressed: canSpeak && !widget.busy ? () => unawaited(_speakWord()) : null, tooltip: 'Hear the word', iconSize: 36, style: IconButton.styleFrom( backgroundColor: AppColors.surfaceElevated, foregroundColor: canSpeak ? AppColors.accent : AppColors.textSecondary, disabledForegroundColor: AppColors.textSecondary, minimumSize: const Size(72, 72), ), icon: const Icon(Icons.volume_up_rounded), ), ), const SizedBox(height: 28), TextField( key: const Key('spelling-answer-field'), controller: _answerController, enabled: canInteract, textCapitalization: TextCapitalization.none, autocorrect: false, enableSuggestions: false, style: Theme.of(context).textTheme.titleLarge?.copyWith( color: AppColors.textPrimary, ), decoration: InputDecoration( hintText: canInteract ? 'Type the spelling' : 'Loading word…', hintStyle: const TextStyle(color: AppColors.textSecondary), filled: true, fillColor: AppColors.surfaceElevated, border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, ), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 14, ), ), onSubmitted: canInteract ? (_) => unawaited(_submitAnswer()) : null, ), const SizedBox(height: 16), FilledButton( key: const Key('spelling-submit-answer'), onPressed: canInteract ? () => unawaited(_submitAnswer()) : null, child: const Text('Submit'), ), const SizedBox(height: 8), TextButton( key: const Key('spelling-skip-question'), onPressed: widget.busy || _preparing ? null : widget.onDefer, child: const Text('Skip for now'), ), ], ), ); } }