cyberhybridhub/lib/widgets/spelling_question_tile.dart
2026-06-08 01:14:01 +00:00

368 lines
12 KiB
Dart

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<void> Function(String answerText) onSubmitAnswer;
final Future<void> 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<IncomingQuestion>? onPrepared;
/// Visible for widget tests (defaults to API prepare).
final Future<IncomingQuestion?> Function(String questionId)? prepareQuestion;
@override
State<SpellingQuestionTile> createState() => _SpellingQuestionTileState();
}
class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
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<void> _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<void> _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<void> _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;
_awaitingNext = true;
});
_tileFocusNode.requestFocus();
}
Future<void> _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: <Widget>[
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 (_awaitingNext) ...<Widget>[
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,
),
),
),
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 ...<Widget>[
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'),
),
],
],
),
),
);
}
}