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; 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',

View File

@ -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),
),
); );
}, },
), ),

View File

@ -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;
}

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

View File

@ -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:

View File

@ -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:

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 { 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');

View File

@ -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);

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) { static num _readNumeric(Object? value) {
if (value is num) { if (value is num) {
return value; return value;

View File

@ -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',

View File

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

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