cyberhybridhub/lib/widgets/spelling_question_tile.dart
nathanAndrinoid 4459852f83
Some checks failed
Admin portal tests / test (push) Has been cancelled
spelling
2026-06-05 03:22:35 +00:00

231 lines
7.1 KiB
Dart

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<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;
/// 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();
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<void> _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<void> _speakWord() async {
final String? word = _wordToSpeak;
if (word == null || word.isEmpty) {
return;
}
await _speech.speak(
word,
title: _audioTitle ?? widget.questionId,
);
}
Future<void> _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: <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),
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'),
),
],
),
);
}
}