all spelling
This commit is contained in:
parent
00840a80e4
commit
59b039a3c0
File diff suppressed because one or more lines are too long
@ -110,6 +110,27 @@ class SpellingWordsRemoteStore {
|
|||||||
return response.body;
|
return response.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<String>> deleteWord(String word) async {
|
||||||
|
final String? token = await AuthService.instance.getIdToken();
|
||||||
|
if (token == null) {
|
||||||
|
throw StateError('Not signed in');
|
||||||
|
}
|
||||||
|
|
||||||
|
final String encodedWord = Uri.encodeComponent(word);
|
||||||
|
final http.Response response = await _client.delete(
|
||||||
|
Uri.parse('$apiBaseUrl$_basePath/$encodedWord'),
|
||||||
|
headers: _authHeaders(token),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw SpellingWordsApiException(response.statusCode, response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> body =
|
||||||
|
jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final List<dynamic> words = body['words'] as List<dynamic>? ?? <dynamic>[];
|
||||||
|
return words.map((dynamic w) => w as String).toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, String> _authHeaders(String token) {
|
Map<String, String> _authHeaders(String token) {
|
||||||
return <String, String>{
|
return <String, String>{
|
||||||
'Authorization': 'Bearer $token',
|
'Authorization': 'Bearer $token',
|
||||||
|
|||||||
@ -2,21 +2,28 @@ import 'dart:convert';
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../data/remote/spelling_words_remote_store.dart';
|
import '../data/remote/spelling_words_remote_store.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
import '../utils/file_saver.dart';
|
||||||
|
|
||||||
/// Lists and manages canonical spelling words in the shared catalog.
|
/// Lists and manages canonical spelling words in the shared catalog.
|
||||||
class SpellingListScreen extends StatefulWidget {
|
class SpellingListScreen extends StatefulWidget {
|
||||||
const SpellingListScreen({super.key});
|
const SpellingListScreen({
|
||||||
|
super.key,
|
||||||
|
this.api,
|
||||||
|
});
|
||||||
|
|
||||||
|
final SpellingWordsRemoteStore? api;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SpellingListScreen> createState() => _SpellingListScreenState();
|
State<SpellingListScreen> createState() => _SpellingListScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SpellingListScreenState extends State<SpellingListScreen> {
|
class _SpellingListScreenState extends State<SpellingListScreen> {
|
||||||
final SpellingWordsRemoteStore _api = SpellingWordsRemoteStore();
|
late final SpellingWordsRemoteStore _api;
|
||||||
|
|
||||||
List<String> _words = <String>[];
|
List<String> _words = <String>[];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
@ -26,6 +33,7 @@ class _SpellingListScreenState extends State<SpellingListScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_api = widget.api ?? SpellingWordsRemoteStore();
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,34 +105,37 @@ class _SpellingListScreenState extends State<SpellingListScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onUpload() async {
|
void _onUpload() {
|
||||||
final FilePickerResult? result = await FilePicker.platform.pickFiles(
|
FilePicker.pickFiles(
|
||||||
type: FileType.custom,
|
type: FileType.custom,
|
||||||
allowedExtensions: <String>['txt'],
|
allowedExtensions: <String>['txt'],
|
||||||
withData: true,
|
withData: true,
|
||||||
);
|
).then((FilePickerResult? result) {
|
||||||
if (result == null || result.files.isEmpty) {
|
if (result == null || result.files.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
final PlatformFile file = result.files.first;
|
|
||||||
final String? text = _readFileText(file);
|
|
||||||
if (text == null || text.trim().isEmpty) {
|
|
||||||
_showSnack('The file is empty or could not be read.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _runBusy(() async {
|
|
||||||
try {
|
|
||||||
final ({int added, int skipped}) stats = await _api.importText(text);
|
|
||||||
await _load();
|
|
||||||
if (!mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_showSnack('Upload complete: ${stats.added} added, ${stats.skipped} skipped');
|
|
||||||
} catch (_) {
|
|
||||||
_showSnack('Upload failed.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final PlatformFile file = result.files.first;
|
||||||
|
final String? text = _readFileText(file);
|
||||||
|
if (text == null || text.trim().isEmpty) {
|
||||||
|
_showSnack('The file is empty or could not be read.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_runBusy(() async {
|
||||||
|
try {
|
||||||
|
final ({int added, int skipped}) stats = await _api.importText(text);
|
||||||
|
await _load();
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_showSnack('Upload complete: ${stats.added} added, ${stats.skipped} skipped');
|
||||||
|
} catch (_) {
|
||||||
|
_showSnack('Upload failed.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).catchError((dynamic _) {
|
||||||
|
_showSnack('Could not open file picker.');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,20 +144,24 @@ class _SpellingListScreenState extends State<SpellingListScreen> {
|
|||||||
try {
|
try {
|
||||||
final String text = await _api.exportText();
|
final String text = await _api.exportText();
|
||||||
final Uint8List bytes = Uint8List.fromList(utf8.encode(text));
|
final Uint8List bytes = Uint8List.fromList(utf8.encode(text));
|
||||||
final String? path = await FilePicker.platform.saveFile(
|
if (kIsWeb) {
|
||||||
dialogTitle: 'Save spelling words',
|
await saveFileBytes(bytes, 'spelling-words.txt');
|
||||||
fileName: 'spelling-words.txt',
|
_showSnack('Saved spelling word list');
|
||||||
type: FileType.custom,
|
} else {
|
||||||
allowedExtensions: <String>['txt'],
|
final String? path = await FilePicker.saveFile(
|
||||||
bytes: bytes,
|
dialogTitle: 'Save spelling words',
|
||||||
);
|
fileName: 'spelling-words.txt',
|
||||||
if (!mounted) {
|
type: FileType.custom,
|
||||||
return;
|
allowedExtensions: <String>['txt'],
|
||||||
|
bytes: bytes,
|
||||||
|
);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path != null) {
|
||||||
|
_showSnack('Saved spelling word list');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (path == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_showSnack('Saved spelling word list');
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_showSnack('Download failed.');
|
_showSnack('Download failed.');
|
||||||
}
|
}
|
||||||
@ -167,6 +182,49 @@ class _SpellingListScreenState extends State<SpellingListScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onDelete(String word) async {
|
||||||
|
final bool? confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
key: const Key('spelling-word-delete-confirm-dialog'),
|
||||||
|
title: const Text('Delete spelling word?'),
|
||||||
|
content: Text(
|
||||||
|
'This deletes "$word" from the spelling catalog, along with '
|
||||||
|
'all linked quiz history and progress. You cannot undo this.',
|
||||||
|
),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
key: const Key('spelling-word-delete-confirm-button'),
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: const Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _runBusy(() async {
|
||||||
|
try {
|
||||||
|
final List<String> words = await _api.deleteWord(word);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _words = words);
|
||||||
|
_showSnack('Deleted "$word"');
|
||||||
|
} catch (_) {
|
||||||
|
_showSnack('Could not delete word.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@ -273,6 +331,12 @@ class _SpellingListScreenState extends State<SpellingListScreen> {
|
|||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
key: Key('spelling-word-delete-$word'),
|
||||||
|
icon: const Icon(Icons.delete_outline, color: AppColors.error),
|
||||||
|
tooltip: 'Delete word',
|
||||||
|
onPressed: _busy ? null : () => _onDelete(word),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@ -62,7 +62,14 @@ class _WebSpeechBackend implements SpellingTtsBackend {
|
|||||||
}
|
}
|
||||||
}).toJS;
|
}).toJS;
|
||||||
utterance.onError = ((JSAny event) {
|
utterance.onError = ((JSAny event) {
|
||||||
debugPrint('Spelling web TTS error: $event');
|
String details = 'unknown';
|
||||||
|
if (event.isA<JSObject>()) {
|
||||||
|
try {
|
||||||
|
final errEvent = event as JSObject;
|
||||||
|
details = (errEvent as SpeechSynthesisErrorEvent).error;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
debugPrint('Spelling web TTS error: $details ($event)');
|
||||||
if (!done.isCompleted) {
|
if (!done.isCompleted) {
|
||||||
done.complete();
|
done.complete();
|
||||||
}
|
}
|
||||||
@ -100,3 +107,8 @@ class _WebSpeechBackend implements SpellingTtsBackend {
|
|||||||
pending?.complete();
|
pending?.complete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JS()
|
||||||
|
extension type SpeechSynthesisErrorEvent._(JSObject _) implements JSObject {
|
||||||
|
external String get error;
|
||||||
|
}
|
||||||
|
|||||||
2
lib/utils/file_saver.dart
Normal file
2
lib/utils/file_saver.dart
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export 'file_saver_stub.dart'
|
||||||
|
if (dart.library.html) 'file_saver_web.dart';
|
||||||
5
lib/utils/file_saver_stub.dart
Normal file
5
lib/utils/file_saver_stub.dart
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
Future<void> saveFileBytes(Uint8List bytes, String fileName) async {
|
||||||
|
throw UnsupportedError('saveFileBytes is only supported on web');
|
||||||
|
}
|
||||||
14
lib/utils/file_saver_web.dart
Normal file
14
lib/utils/file_saver_web.dart
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import 'dart:html' as html;
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
Future<void> saveFileBytes(Uint8List bytes, String fileName) async {
|
||||||
|
final blob = html.Blob([bytes], 'text/plain; charset=utf-8');
|
||||||
|
final url = html.Url.createObjectUrlFromBlob(blob);
|
||||||
|
final anchor = html.AnchorElement(href: url)
|
||||||
|
..setAttribute("download", fileName)
|
||||||
|
..style.display = "none";
|
||||||
|
html.document.body?.append(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
html.Url.revokeObjectUrl(url);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../models/incoming_question.dart';
|
import '../models/incoming_question.dart';
|
||||||
import '../services/questions_api_service.dart';
|
import '../services/questions_api_service.dart';
|
||||||
@ -66,6 +67,9 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
|||||||
bool _preparing = false;
|
bool _preparing = false;
|
||||||
bool _showingFeedback = false;
|
bool _showingFeedback = false;
|
||||||
bool? _answerCorrect;
|
bool? _answerCorrect;
|
||||||
|
final FocusNode _tileFocusNode = FocusNode();
|
||||||
|
bool _awaitingNext = false;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -85,6 +89,8 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
|||||||
_audioTitle = widget.audioTitle ?? widget.questionId;
|
_audioTitle = widget.audioTitle ?? widget.questionId;
|
||||||
_canonicalWord = widget.canonicalWord;
|
_canonicalWord = widget.canonicalWord;
|
||||||
_showingFeedback = false;
|
_showingFeedback = false;
|
||||||
|
_awaitingNext = false;
|
||||||
|
_submitting = false;
|
||||||
_answerCorrect = null;
|
_answerCorrect = null;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_prepareAndSpeak()));
|
WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_prepareAndSpeak()));
|
||||||
}
|
}
|
||||||
@ -94,6 +100,7 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_answerController.dispose();
|
_answerController.dispose();
|
||||||
_answerFocusNode.dispose();
|
_answerFocusNode.dispose();
|
||||||
|
_tileFocusNode.dispose();
|
||||||
unawaited(_speech.stop());
|
unawaited(_speech.stop());
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@ -103,7 +110,8 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
|||||||
!_wordReady ||
|
!_wordReady ||
|
||||||
widget.busy ||
|
widget.busy ||
|
||||||
_preparing ||
|
_preparing ||
|
||||||
_showingFeedback) {
|
_showingFeedback ||
|
||||||
|
_awaitingNext) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@ -120,7 +128,7 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!widget.needsPrepare) {
|
if (!widget.needsPrepare) {
|
||||||
await _speakWord();
|
unawaited(_speakWord());
|
||||||
_requestAnswerFieldFocus();
|
_requestAnswerFieldFocus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -142,7 +150,7 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
|||||||
});
|
});
|
||||||
widget.onPrepared?.call(prepared);
|
widget.onPrepared?.call(prepared);
|
||||||
}
|
}
|
||||||
await _speakWord();
|
unawaited(_speakWord());
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _preparing = false);
|
setState(() => _preparing = false);
|
||||||
@ -173,19 +181,20 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
|||||||
String? get _canonicalForGrading =>
|
String? get _canonicalForGrading =>
|
||||||
_canonicalWord ?? widget.canonicalWord;
|
_canonicalWord ?? widget.canonicalWord;
|
||||||
|
|
||||||
Future<void> _submitAnswer() async {
|
Future<void> _handleFeedbackShow() async {
|
||||||
final String answer = _answerController.text.trim();
|
final String answer = _answerController.text.trim();
|
||||||
if (answer.isEmpty ||
|
if (answer.isEmpty ||
|
||||||
widget.busy ||
|
widget.busy ||
|
||||||
_preparing ||
|
_preparing ||
|
||||||
_showingFeedback ||
|
_showingFeedback ||
|
||||||
|
_awaitingNext ||
|
||||||
!_wordReady) {
|
!_wordReady) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final String? canonical = _canonicalForGrading;
|
final String? canonical = _canonicalForGrading;
|
||||||
if (canonical == null || canonical.isEmpty) {
|
if (canonical == null || canonical.isEmpty) {
|
||||||
await widget.onSubmitAnswer(answer);
|
await _triggerNextQuestion();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -195,17 +204,31 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
|||||||
_showingFeedback = true;
|
_showingFeedback = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
final DateTime startTime = DateTime.now();
|
await Future<void>.delayed(const Duration(seconds: 1));
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_showingFeedback = false;
|
||||||
|
_awaitingNext = true;
|
||||||
|
});
|
||||||
|
_tileFocusNode.requestFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _triggerNextQuestion() async {
|
||||||
|
if (_submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_submitting = true;
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
|
final String answer = _answerController.text.trim();
|
||||||
await widget.onSubmitAnswer(answer);
|
await widget.onSubmitAnswer(answer);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
final int elapsedMs = DateTime.now().difference(startTime).inMilliseconds;
|
|
||||||
final int remainingMs = 1000 - elapsedMs;
|
|
||||||
if (remainingMs > 0) {
|
|
||||||
await Future<void>.delayed(Duration(milliseconds: remainingMs));
|
|
||||||
}
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_submitting = false;
|
||||||
|
_awaitingNext = false;
|
||||||
_showingFeedback = false;
|
_showingFeedback = false;
|
||||||
_answerCorrect = null;
|
_answerCorrect = null;
|
||||||
});
|
});
|
||||||
@ -217,105 +240,134 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final bool canInteract =
|
final bool canInteract =
|
||||||
_wordReady && !widget.busy && !_preparing && !_showingFeedback;
|
_wordReady && !widget.busy && !_preparing && !_showingFeedback && !_awaitingNext;
|
||||||
final bool canSpeak = _wordReady && !_showingFeedback;
|
final bool canSpeak = _wordReady && !_showingFeedback && !_awaitingNext;
|
||||||
final String? feedbackWord = _showingFeedback ? _canonicalForGrading : null;
|
final String? feedbackWord = _showingFeedback ? _canonicalForGrading : null;
|
||||||
final Color? feedbackColor = _answerCorrect == null
|
final Color? feedbackColor = _answerCorrect == null
|
||||||
? null
|
? null
|
||||||
: (_answerCorrect! ? AppColors.success : AppColors.error);
|
: (_answerCorrect! ? AppColors.success : AppColors.error);
|
||||||
|
|
||||||
return Padding(
|
return Focus(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
focusNode: _tileFocusNode,
|
||||||
child: Column(
|
autofocus: true,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
onKeyEvent: (FocusNode node, KeyEvent event) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
if (event is KeyDownEvent &&
|
||||||
children: <Widget>[
|
(event.logicalKey == LogicalKeyboardKey.enter ||
|
||||||
if (_preparing)
|
event.logicalKey == LogicalKeyboardKey.numpadEnter)) {
|
||||||
const Padding(
|
if (_awaitingNext && !_submitting) {
|
||||||
padding: EdgeInsets.only(bottom: 24),
|
unawaited(_triggerNextQuestion());
|
||||||
child: Center(
|
return KeyEventResult.handled;
|
||||||
child: SizedBox(
|
}
|
||||||
width: 28,
|
}
|
||||||
height: 28,
|
return KeyEventResult.ignored;
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
},
|
||||||
),
|
child: Padding(
|
||||||
),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
),
|
child: Column(
|
||||||
Center(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
child: IconButton.filled(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
key: const Key('spelling-speak-word'),
|
children: <Widget>[
|
||||||
onPressed: canSpeak && !widget.busy
|
if (_preparing)
|
||||||
? () => unawaited(_speakWord())
|
const Padding(
|
||||||
: null,
|
padding: EdgeInsets.only(bottom: 24),
|
||||||
tooltip: 'Hear the word',
|
child: Center(
|
||||||
iconSize: 36,
|
child: SizedBox(
|
||||||
style: IconButton.styleFrom(
|
width: 28,
|
||||||
backgroundColor: AppColors.surfaceElevated,
|
height: 28,
|
||||||
foregroundColor: canSpeak ? AppColors.accent : AppColors.textSecondary,
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
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 ...<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:
|
Center(
|
||||||
canInteract ? (_) => unawaited(_submitAnswer()) : null,
|
child: IconButton.filled(
|
||||||
),
|
key: const Key('spelling-speak-word'),
|
||||||
const SizedBox(height: 16),
|
onPressed: canSpeak && !widget.busy
|
||||||
FilledButton(
|
? () => unawaited(_speakWord())
|
||||||
key: const Key('spelling-submit-answer'),
|
: null,
|
||||||
onPressed: canInteract ? () => unawaited(_submitAnswer()) : null,
|
tooltip: 'Hear the word',
|
||||||
child: const Text('Submit'),
|
iconSize: 36,
|
||||||
),
|
style: IconButton.styleFrom(
|
||||||
const SizedBox(height: 8),
|
backgroundColor: AppColors.surfaceElevated,
|
||||||
TextButton(
|
foregroundColor: canSpeak ? AppColors.accent : AppColors.textSecondary,
|
||||||
key: const Key('spelling-skip-question'),
|
disabledForegroundColor: AppColors.textSecondary,
|
||||||
onPressed: widget.busy || _preparing ? null : widget.onDefer,
|
minimumSize: const Size(72, 72),
|
||||||
child: const Text('Skip for now'),
|
),
|
||||||
|
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) ...<Widget>[
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -285,10 +285,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: file_picker
|
name: file_picker
|
||||||
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
|
sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.3.7"
|
version: "11.0.2"
|
||||||
firebase_auth:
|
firebase_auth:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -47,7 +47,7 @@ dependencies:
|
|||||||
connectivity_plus: ^6.1.4
|
connectivity_plus: ^6.1.4
|
||||||
signalr_netcore: ^1.4.4
|
signalr_netcore: ^1.4.4
|
||||||
logging: ^1.3.0
|
logging: ^1.3.0
|
||||||
file_picker: ^8.3.7
|
file_picker: ^11.0.2
|
||||||
flutter_tts: ^4.2.2
|
flutter_tts: ^4.2.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|||||||
@ -99,6 +99,28 @@ Handler spellingWordsHandler({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.delete('$spellingWordsBasePath/<word>', (Request request, String word) async {
|
||||||
|
final String? firebaseUid = await _verify(auth, request);
|
||||||
|
if (firebaseUid == null) {
|
||||||
|
return _jsonResponse(401, <String, dynamic>{'error': 'Unauthorized'});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final String decodedWord = Uri.decodeComponent(word);
|
||||||
|
final bool deleted = await spellingItemsDb.deleteCanonicalWord(decodedWord);
|
||||||
|
if (!deleted) {
|
||||||
|
return _jsonResponse(404, <String, dynamic>{'error': 'Word not found'});
|
||||||
|
}
|
||||||
|
final List<String> words = await spellingItemsDb.listCanonicalWords();
|
||||||
|
return _jsonResponse(200, <String, dynamic>{
|
||||||
|
'word': decodedWord.trim().toLowerCase(),
|
||||||
|
'words': words,
|
||||||
|
});
|
||||||
|
} catch (e, st) {
|
||||||
|
stderr.writeln('Delete spelling word error: $e\n$st');
|
||||||
|
return _jsonResponse(500, <String, dynamic>{'error': 'Internal error'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (Request request) async {
|
return (Request request) async {
|
||||||
if (request.method == 'OPTIONS' &&
|
if (request.method == 'OPTIONS' &&
|
||||||
request.requestedUri.path.startsWith(spellingWordsBasePath)) {
|
request.requestedUri.path.startsWith(spellingWordsBasePath)) {
|
||||||
@ -120,7 +142,7 @@ Future<String?> _verify(FirebaseAuthVerifier auth, Request request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, String> _jsonCorsHeaders() =>
|
Map<String, String> _jsonCorsHeaders() =>
|
||||||
apiCorsHeaders(methods: 'GET, POST, OPTIONS');
|
apiCorsHeaders(methods: 'GET, POST, DELETE, OPTIONS');
|
||||||
|
|
||||||
Map<String, String> _textCorsHeaders() =>
|
Map<String, String> _textCorsHeaders() =>
|
||||||
apiCorsHeaders(methods: 'GET, OPTIONS');
|
apiCorsHeaders(methods: 'GET, OPTIONS');
|
||||||
|
|||||||
@ -820,28 +820,7 @@ class QuestionPipeline {
|
|||||||
correct: outcome == BranchOutcome.match,
|
correct: outcome == BranchOutcome.match,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final String followUp = switch (outcome) {
|
await _startSpellingPipeline(firebaseUid, force: true);
|
||||||
BranchOutcome.match =>
|
|
||||||
"Correct — '$canonicalWord' is the proper spelling. "
|
|
||||||
'Enter +1 for another word, -1 to pause.',
|
|
||||||
_ =>
|
|
||||||
"The proper spelling is '$canonicalWord'. "
|
|
||||||
'Enter +1 to try another word, -1 to pause.',
|
|
||||||
};
|
|
||||||
await _questionsDb.upsertPipelineState(
|
|
||||||
assignedUserId: firebaseUid,
|
|
||||||
pipelineKey: PipelineKeys.spelling,
|
|
||||||
step: PipelineSteps.spellingFollowUp,
|
|
||||||
context: context,
|
|
||||||
);
|
|
||||||
await _deliverPipelineQuestion(
|
|
||||||
firebaseUid: firebaseUid,
|
|
||||||
questionText: followUp,
|
|
||||||
correctAnswer: 1,
|
|
||||||
sourceTag: 'spelling:follow_up',
|
|
||||||
pipelineKey: PipelineKeys.spelling,
|
|
||||||
pipelineStep: PipelineSteps.spellingFollowUp,
|
|
||||||
);
|
|
||||||
case PipelineSteps.spellingFollowUp:
|
case PipelineSteps.spellingFollowUp:
|
||||||
if (userResponse > 0) {
|
if (userResponse > 0) {
|
||||||
await _startSpellingPipeline(firebaseUid, force: true);
|
await _startSpellingPipeline(firebaseUid, force: true);
|
||||||
|
|||||||
@ -238,6 +238,60 @@ class SpellingItemsDb {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deletes a canonical word, its items, user progress, links, and associated questions.
|
||||||
|
Future<bool> deleteCanonicalWord(String rawWord) async {
|
||||||
|
final String canonical = _normalizeCanonical(rawWord);
|
||||||
|
if (canonical.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Result wordRow = await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT id FROM spelling_words
|
||||||
|
WHERE canonical_word = @canonical
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'canonical': canonical},
|
||||||
|
);
|
||||||
|
if (wordRow.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final String wordId = wordRow.first[0]!.toString();
|
||||||
|
|
||||||
|
// 1. Delete associated questions in the questions table.
|
||||||
|
await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
DELETE FROM questions
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT question_id
|
||||||
|
FROM spelling_question_links
|
||||||
|
WHERE spelling_item_id IN (
|
||||||
|
SELECT id FROM spelling_items
|
||||||
|
WHERE spelling_word_id = @wordId::uuid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'wordId': wordId},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Delete the spelling word (ON DELETE CASCADE will clean up spelling_items,
|
||||||
|
// user_spelling_progress, and spelling_question_links).
|
||||||
|
final Result deleteResult = await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
DELETE FROM spelling_words
|
||||||
|
WHERE id = @wordId::uuid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'wordId': wordId},
|
||||||
|
);
|
||||||
|
|
||||||
|
return deleteResult.affectedRows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
static num _readNumeric(Object? value) {
|
static num _readNumeric(Object? value) {
|
||||||
if (value is num) {
|
if (value is num) {
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@ -254,8 +254,8 @@ void main() {
|
|||||||
parameters: <String, dynamic>{'uid': uid},
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
);
|
);
|
||||||
expect(rows.length, greaterThanOrEqualTo(2));
|
expect(rows.length, greaterThanOrEqualTo(2));
|
||||||
expect(rows.last[0], PipelineSteps.spellingFollowUp);
|
expect(rows.last[0], PipelineSteps.spellingWordQuiz);
|
||||||
expect(rows.last[1], 'spelling:follow_up');
|
expect(rows.last[1], 'spelling:word_quiz');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resetSpellingProgressForUser clears links and progress then re-seeds',
|
test('resetSpellingProgressForUser clears links and progress then re-seeds',
|
||||||
|
|||||||
@ -61,4 +61,24 @@ void main() {
|
|||||||
expect(words, contains('alpha'));
|
expect(words, contains('alpha'));
|
||||||
expect(words, contains('beta'));
|
expect(words, contains('beta'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deleteCanonicalWord deletes word and cascades progress and links', () async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final SpellingItemsDb db = testDb!.spellingItemsDb;
|
||||||
|
final bool added = await db.addCanonicalWord('deleteme');
|
||||||
|
expect(added, isTrue);
|
||||||
|
|
||||||
|
final List<String> wordsBefore = await db.listCanonicalWords();
|
||||||
|
expect(wordsBefore, contains('deleteme'));
|
||||||
|
|
||||||
|
final bool deleted = await db.deleteCanonicalWord('deleteme');
|
||||||
|
expect(deleted, isTrue);
|
||||||
|
|
||||||
|
final List<String> wordsAfter = await db.listCanonicalWords();
|
||||||
|
expect(wordsAfter, isNot(contains('deleteme')));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
190
test/widgets/spelling_list_screen_test.dart
Normal file
190
test/widgets/spelling_list_screen_test.dart
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import 'package:cyberhybridhub/data/remote/spelling_words_remote_store.dart';
|
||||||
|
import 'package:cyberhybridhub/screens/spelling_list_screen.dart';
|
||||||
|
import 'package:cyberhybridhub/theme/app_theme.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
class MockSpellingWordsRemoteStore extends SpellingWordsRemoteStore {
|
||||||
|
MockSpellingWordsRemoteStore({
|
||||||
|
this.words = const <String>[],
|
||||||
|
this.fetchWordsResult,
|
||||||
|
this.deleteWordResult,
|
||||||
|
this.deleteWordError,
|
||||||
|
});
|
||||||
|
|
||||||
|
List<String> words;
|
||||||
|
List<String>? fetchWordsResult;
|
||||||
|
List<String>? deleteWordResult;
|
||||||
|
Object? deleteWordError;
|
||||||
|
|
||||||
|
final List<String> deletedWords = <String>[];
|
||||||
|
int fetchCount = 0;
|
||||||
|
int deleteCount = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> fetchWords() async {
|
||||||
|
fetchCount++;
|
||||||
|
if (fetchWordsResult != null) {
|
||||||
|
return fetchWordsResult!;
|
||||||
|
}
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> deleteWord(String word) async {
|
||||||
|
deleteCount++;
|
||||||
|
deletedWords.add(word);
|
||||||
|
if (deleteWordError != null) {
|
||||||
|
throw deleteWordError!;
|
||||||
|
}
|
||||||
|
if (deleteWordResult != null) {
|
||||||
|
return deleteWordResult!;
|
||||||
|
}
|
||||||
|
final List<String> newWords = List<String>.from(words)..remove(word);
|
||||||
|
return newWords;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('lists words loaded from remote store', (WidgetTester tester) async {
|
||||||
|
final mockStore = MockSpellingWordsRemoteStore(
|
||||||
|
words: ['apple', 'banana', 'cherry'],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: SpellingListScreen(api: mockStore),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Shows loading first
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify loading is gone and words are displayed
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||||
|
expect(find.text('apple'), findsOneWidget);
|
||||||
|
expect(find.text('banana'), findsOneWidget);
|
||||||
|
expect(find.text('cherry'), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('spelling-word-delete-apple')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('spelling-word-delete-banana')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('spelling-word-delete-cherry')), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('delete button cancel does not call delete API and leaves list intact',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
final mockStore = MockSpellingWordsRemoteStore(
|
||||||
|
words: ['apple', 'banana'],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: SpellingListScreen(api: mockStore),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap delete button for apple
|
||||||
|
await tester.tap(find.byKey(const Key('spelling-word-delete-apple')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify confirmation dialog is visible
|
||||||
|
expect(find.byKey(const Key('spelling-word-delete-confirm-dialog')), findsOneWidget);
|
||||||
|
expect(find.text('Delete spelling word?'), findsOneWidget);
|
||||||
|
expect(find.textContaining('This deletes "apple"'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap Cancel
|
||||||
|
await tester.tap(find.text('Cancel'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Dialog should be gone
|
||||||
|
expect(find.byKey(const Key('spelling-word-delete-confirm-dialog')), findsNothing);
|
||||||
|
|
||||||
|
// Store is not called
|
||||||
|
expect(mockStore.deleteCount, 0);
|
||||||
|
expect(mockStore.deletedWords, isEmpty);
|
||||||
|
|
||||||
|
// Words still exist
|
||||||
|
expect(find.text('apple'), findsOneWidget);
|
||||||
|
expect(find.text('banana'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('delete button confirm calls delete API, updates UI list, and shows snackbar',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
final mockStore = MockSpellingWordsRemoteStore(
|
||||||
|
words: ['apple', 'banana'],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: SpellingListScreen(api: mockStore),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap delete button for apple
|
||||||
|
await tester.tap(find.byKey(const Key('spelling-word-delete-apple')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap Delete inside dialog
|
||||||
|
await tester.tap(find.byKey(const Key('spelling-word-delete-confirm-button')));
|
||||||
|
await tester.pump(); // Start async action
|
||||||
|
|
||||||
|
// Verify mockStore is called
|
||||||
|
expect(mockStore.deleteCount, 1);
|
||||||
|
expect(mockStore.deletedWords, ['apple']);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Dialog should be gone
|
||||||
|
expect(find.byKey(const Key('spelling-word-delete-confirm-dialog')), findsNothing);
|
||||||
|
|
||||||
|
// List is updated
|
||||||
|
expect(find.text('apple'), findsNothing);
|
||||||
|
expect(find.text('banana'), findsOneWidget);
|
||||||
|
|
||||||
|
// Snackbar is shown
|
||||||
|
expect(find.text('Deleted "apple"'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('delete failure shows error snackbar and retains word in list',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
final mockStore = MockSpellingWordsRemoteStore(
|
||||||
|
words: ['apple', 'banana'],
|
||||||
|
deleteWordError: Exception('Failed to delete'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: SpellingListScreen(api: mockStore),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap delete button for apple
|
||||||
|
await tester.tap(find.byKey(const Key('spelling-word-delete-apple')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap Delete inside dialog
|
||||||
|
await tester.tap(find.byKey(const Key('spelling-word-delete-confirm-button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Dialog is gone
|
||||||
|
expect(find.byKey(const Key('spelling-word-delete-confirm-dialog')), findsNothing);
|
||||||
|
|
||||||
|
// Store was called
|
||||||
|
expect(mockStore.deleteCount, 1);
|
||||||
|
|
||||||
|
// List is NOT updated, apple is still there
|
||||||
|
expect(find.text('apple'), findsOneWidget);
|
||||||
|
expect(find.text('banana'), findsOneWidget);
|
||||||
|
|
||||||
|
// Error snackbar is shown
|
||||||
|
expect(find.text('Could not delete word.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,10 +1,11 @@
|
|||||||
import 'package:cyberhybridhub/theme/app_theme.dart';
|
import 'package:cyberhybridhub/theme/app_theme.dart';
|
||||||
import 'package:cyberhybridhub/widgets/spelling_question_tile.dart';
|
import 'package:cyberhybridhub/widgets/spelling_question_tile.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('shows green canonical word then submits after one second', (tester) async {
|
testWidgets('shows green canonical word then shows Next button and submits when clicked', (tester) async {
|
||||||
var submitCount = 0;
|
var submitCount = 0;
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@ -29,7 +30,7 @@ void main() {
|
|||||||
await tester.tap(find.byKey(const Key('spelling-submit-answer')));
|
await tester.tap(find.byKey(const Key('spelling-submit-answer')));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(submitCount, 1);
|
expect(submitCount, 0);
|
||||||
final Text feedback = tester.widget<Text>(
|
final Text feedback = tester.widget<Text>(
|
||||||
find.byKey(const Key('spelling-feedback-word')),
|
find.byKey(const Key('spelling-feedback-word')),
|
||||||
);
|
);
|
||||||
@ -39,6 +40,13 @@ void main() {
|
|||||||
await tester.pump(const Duration(seconds: 1));
|
await tester.pump(const Duration(seconds: 1));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('spelling-feedback-word')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('spelling-next-question')), findsOneWidget);
|
||||||
|
expect(submitCount, 0);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('spelling-next-question')));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
expect(submitCount, 1);
|
expect(submitCount, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -71,5 +79,47 @@ void main() {
|
|||||||
|
|
||||||
await tester.pump(const Duration(seconds: 1));
|
await tester.pump(const Duration(seconds: 1));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('spelling-next-question')), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('submits answer when pressing Enter a second time', (tester) async {
|
||||||
|
var submitCount = 0;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: SpellingQuestionTile(
|
||||||
|
questionId: 'spell-3',
|
||||||
|
questionText: 'Spell the word you hear.',
|
||||||
|
spokenWord: 'colour',
|
||||||
|
canonicalWord: 'colour',
|
||||||
|
onSubmitAnswer: (_) async {
|
||||||
|
submitCount++;
|
||||||
|
},
|
||||||
|
onDefer: () async {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.enterText(find.byKey(const Key('spelling-answer-field')), 'colour');
|
||||||
|
await tester.showKeyboard(find.byKey(const Key('spelling-answer-field')));
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(submitCount, 0);
|
||||||
|
expect(find.byKey(const Key('spelling-feedback-word')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('spelling-next-question')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(submitCount, 1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user