all spelling

This commit is contained in:
nathanAndrinoid 2026-06-07 03:10:21 +00:00
parent 00840a80e4
commit 59b039a3c0
17 changed files with 656 additions and 171 deletions

File diff suppressed because one or more lines are too long

View File

@ -110,6 +110,27 @@ class SpellingWordsRemoteStore {
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) {
return <String, String>{
'Authorization': 'Bearer $token',

View File

@ -2,21 +2,28 @@ import 'dart:convert';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import '../data/remote/spelling_words_remote_store.dart';
import '../theme/app_theme.dart';
import '../utils/file_saver.dart';
/// Lists and manages canonical spelling words in the shared catalog.
class SpellingListScreen extends StatefulWidget {
const SpellingListScreen({super.key});
const SpellingListScreen({
super.key,
this.api,
});
final SpellingWordsRemoteStore? api;
@override
State<SpellingListScreen> createState() => _SpellingListScreenState();
}
class _SpellingListScreenState extends State<SpellingListScreen> {
final SpellingWordsRemoteStore _api = SpellingWordsRemoteStore();
late final SpellingWordsRemoteStore _api;
List<String> _words = <String>[];
bool _loading = true;
@ -26,6 +33,7 @@ class _SpellingListScreenState extends State<SpellingListScreen> {
@override
void initState() {
super.initState();
_api = widget.api ?? SpellingWordsRemoteStore();
_load();
}
@ -97,34 +105,37 @@ class _SpellingListScreenState extends State<SpellingListScreen> {
});
}
Future<void> _onUpload() async {
final FilePickerResult? result = await FilePicker.platform.pickFiles(
void _onUpload() {
FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: <String>['txt'],
withData: true,
);
if (result == null || result.files.isEmpty) {
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.');
).then((FilePickerResult? result) {
if (result == null || result.files.isEmpty) {
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;
}
_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 {
final String text = await _api.exportText();
final Uint8List bytes = Uint8List.fromList(utf8.encode(text));
final String? path = await FilePicker.platform.saveFile(
dialogTitle: 'Save spelling words',
fileName: 'spelling-words.txt',
type: FileType.custom,
allowedExtensions: <String>['txt'],
bytes: bytes,
);
if (!mounted) {
return;
if (kIsWeb) {
await saveFileBytes(bytes, 'spelling-words.txt');
_showSnack('Saved spelling word list');
} else {
final String? path = await FilePicker.saveFile(
dialogTitle: 'Save spelling words',
fileName: 'spelling-words.txt',
type: FileType.custom,
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 (_) {
_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
Widget build(BuildContext context) {
return Scaffold(
@ -273,6 +331,12 @@ class _SpellingListScreenState extends State<SpellingListScreen> {
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),
),
);
},
),

View File

@ -62,7 +62,14 @@ class _WebSpeechBackend implements SpellingTtsBackend {
}
}).toJS;
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) {
done.complete();
}
@ -100,3 +107,8 @@ class _WebSpeechBackend implements SpellingTtsBackend {
pending?.complete();
}
}
@JS()
extension type SpeechSynthesisErrorEvent._(JSObject _) implements JSObject {
external String get error;
}

View File

@ -0,0 +1,2 @@
export 'file_saver_stub.dart'
if (dart.library.html) 'file_saver_web.dart';

View 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');
}

View 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);
}

View File

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/incoming_question.dart';
import '../services/questions_api_service.dart';
@ -66,6 +67,9 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
bool _preparing = false;
bool _showingFeedback = false;
bool? _answerCorrect;
final FocusNode _tileFocusNode = FocusNode();
bool _awaitingNext = false;
bool _submitting = false;
@override
void initState() {
@ -85,6 +89,8 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
_audioTitle = widget.audioTitle ?? widget.questionId;
_canonicalWord = widget.canonicalWord;
_showingFeedback = false;
_awaitingNext = false;
_submitting = false;
_answerCorrect = null;
WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_prepareAndSpeak()));
}
@ -94,6 +100,7 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
void dispose() {
_answerController.dispose();
_answerFocusNode.dispose();
_tileFocusNode.dispose();
unawaited(_speech.stop());
super.dispose();
}
@ -103,7 +110,8 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
!_wordReady ||
widget.busy ||
_preparing ||
_showingFeedback) {
_showingFeedback ||
_awaitingNext) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
@ -120,7 +128,7 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
}
if (!widget.needsPrepare) {
await _speakWord();
unawaited(_speakWord());
_requestAnswerFieldFocus();
return;
}
@ -142,7 +150,7 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
});
widget.onPrepared?.call(prepared);
}
await _speakWord();
unawaited(_speakWord());
} finally {
if (mounted) {
setState(() => _preparing = false);
@ -173,19 +181,20 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
String? get _canonicalForGrading =>
_canonicalWord ?? widget.canonicalWord;
Future<void> _submitAnswer() async {
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 widget.onSubmitAnswer(answer);
await _triggerNextQuestion();
return;
}
@ -195,17 +204,31 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
_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 {
final String answer = _answerController.text.trim();
await widget.onSubmitAnswer(answer);
} 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) {
setState(() {
_submitting = false;
_awaitingNext = false;
_showingFeedback = false;
_answerCorrect = null;
});
@ -217,105 +240,134 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
@override
Widget build(BuildContext context) {
final bool canInteract =
_wordReady && !widget.busy && !_preparing && !_showingFeedback;
final bool canSpeak = _wordReady && !_showingFeedback;
_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 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 (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,
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),
),
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'),
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 (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'),
),
],
],
],
),
),
);
}

View File

@ -285,10 +285,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387
url: "https://pub.dev"
source: hosted
version: "8.3.7"
version: "11.0.2"
firebase_auth:
dependency: "direct main"
description:

View File

@ -47,7 +47,7 @@ dependencies:
connectivity_plus: ^6.1.4
signalr_netcore: ^1.4.4
logging: ^1.3.0
file_picker: ^8.3.7
file_picker: ^11.0.2
flutter_tts: ^4.2.2
dev_dependencies:

View File

@ -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 {
if (request.method == 'OPTIONS' &&
request.requestedUri.path.startsWith(spellingWordsBasePath)) {
@ -120,7 +142,7 @@ Future<String?> _verify(FirebaseAuthVerifier auth, Request request) {
}
Map<String, String> _jsonCorsHeaders() =>
apiCorsHeaders(methods: 'GET, POST, OPTIONS');
apiCorsHeaders(methods: 'GET, POST, DELETE, OPTIONS');
Map<String, String> _textCorsHeaders() =>
apiCorsHeaders(methods: 'GET, OPTIONS');

View File

@ -820,28 +820,7 @@ class QuestionPipeline {
correct: outcome == BranchOutcome.match,
);
}
final String followUp = switch (outcome) {
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,
);
await _startSpellingPipeline(firebaseUid, force: true);
case PipelineSteps.spellingFollowUp:
if (userResponse > 0) {
await _startSpellingPipeline(firebaseUid, force: true);

View File

@ -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) {
if (value is num) {
return value;

View File

@ -254,8 +254,8 @@ void main() {
parameters: <String, dynamic>{'uid': uid},
);
expect(rows.length, greaterThanOrEqualTo(2));
expect(rows.last[0], PipelineSteps.spellingFollowUp);
expect(rows.last[1], 'spelling:follow_up');
expect(rows.last[0], PipelineSteps.spellingWordQuiz);
expect(rows.last[1], 'spelling:word_quiz');
});
test('resetSpellingProgressForUser clears links and progress then re-seeds',

View File

@ -61,4 +61,24 @@ void main() {
expect(words, contains('alpha'));
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')));
});
}

View 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);
});
}

View File

@ -1,10 +1,11 @@
import 'package:cyberhybridhub/theme/app_theme.dart';
import 'package:cyberhybridhub/widgets/spelling_question_tile.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
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;
await tester.pumpWidget(
@ -29,7 +30,7 @@ void main() {
await tester.tap(find.byKey(const Key('spelling-submit-answer')));
await tester.pump();
expect(submitCount, 1);
expect(submitCount, 0);
final Text feedback = tester.widget<Text>(
find.byKey(const Key('spelling-feedback-word')),
);
@ -39,6 +40,13 @@ void main() {
await tester.pump(const Duration(seconds: 1));
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);
});
@ -71,5 +79,47 @@ void main() {
await tester.pump(const Duration(seconds: 1));
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);
});
}