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;
|
||||
}
|
||||
|
||||
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',
|
||||
|
||||
@ -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,12 +105,12 @@ 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,
|
||||
);
|
||||
).then((FilePickerResult? result) {
|
||||
if (result == null || result.files.isEmpty) {
|
||||
return;
|
||||
}
|
||||
@ -114,7 +122,7 @@ class _SpellingListScreenState extends State<SpellingListScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
await _runBusy(() async {
|
||||
_runBusy(() async {
|
||||
try {
|
||||
final ({int added, int skipped}) stats = await _api.importText(text);
|
||||
await _load();
|
||||
@ -126,6 +134,9 @@ class _SpellingListScreenState extends State<SpellingListScreen> {
|
||||
_showSnack('Upload failed.');
|
||||
}
|
||||
});
|
||||
}).catchError((dynamic _) {
|
||||
_showSnack('Could not open file picker.');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onDownload() async {
|
||||
@ -133,7 +144,11 @@ 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(
|
||||
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,
|
||||
@ -143,10 +158,10 @@ class _SpellingListScreenState extends State<SpellingListScreen> {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (path == null) {
|
||||
return;
|
||||
}
|
||||
if (path != null) {
|
||||
_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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
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 '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();
|
||||
try {
|
||||
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));
|
||||
}
|
||||
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 (_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_submitting = false;
|
||||
_awaitingNext = false;
|
||||
_showingFeedback = false;
|
||||
_answerCorrect = null;
|
||||
});
|
||||
@ -217,14 +240,28 @@ 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(
|
||||
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,
|
||||
@ -272,7 +309,21 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
||||
),
|
||||
),
|
||||
)
|
||||
else ...<Widget>[
|
||||
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,
|
||||
@ -300,12 +351,12 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
||||
),
|
||||
),
|
||||
onSubmitted:
|
||||
canInteract ? (_) => unawaited(_submitAnswer()) : null,
|
||||
canInteract ? (_) => unawaited(_handleFeedbackShow()) : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
key: const Key('spelling-submit-answer'),
|
||||
onPressed: canInteract ? () => unawaited(_submitAnswer()) : null,
|
||||
onPressed: canInteract ? () => unawaited(_handleFeedbackShow()) : null,
|
||||
child: const Text('Submit'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@ -317,6 +368,7 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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')));
|
||||
});
|
||||
}
|
||||
|
||||
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/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);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user