231 lines
7.1 KiB
Dart
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'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|