better spelling
This commit is contained in:
parent
4459852f83
commit
be57c17c15
@ -9,6 +9,7 @@ class IncomingQuestion {
|
|||||||
this.presentation = defaultPresentation,
|
this.presentation = defaultPresentation,
|
||||||
this.spokenWord,
|
this.spokenWord,
|
||||||
this.audioTitle,
|
this.audioTitle,
|
||||||
|
this.canonicalWord,
|
||||||
this.needsPrepare = false,
|
this.needsPrepare = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -34,6 +35,9 @@ class IncomingQuestion {
|
|||||||
/// Question id referenced as the audio utterance title.
|
/// Question id referenced as the audio utterance title.
|
||||||
final String? audioTitle;
|
final String? audioTitle;
|
||||||
|
|
||||||
|
/// Proper spelling used to grade the typed answer.
|
||||||
|
final String? canonicalWord;
|
||||||
|
|
||||||
/// True when the server still needs tile-load word selection.
|
/// True when the server still needs tile-load word selection.
|
||||||
final bool needsPrepare;
|
final bool needsPrepare;
|
||||||
|
|
||||||
@ -77,6 +81,7 @@ class IncomingQuestion {
|
|||||||
presentation: json['presentation'] as String? ?? defaultPresentation,
|
presentation: json['presentation'] as String? ?? defaultPresentation,
|
||||||
spokenWord: json['spokenWord'] as String?,
|
spokenWord: json['spokenWord'] as String?,
|
||||||
audioTitle: json['audioTitle'] as String?,
|
audioTitle: json['audioTitle'] as String?,
|
||||||
|
canonicalWord: json['canonicalWord'] as String?,
|
||||||
needsPrepare: json['needsPrepare'] as bool? ?? false,
|
needsPrepare: json['needsPrepare'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -85,6 +90,7 @@ class IncomingQuestion {
|
|||||||
String? text,
|
String? text,
|
||||||
String? spokenWord,
|
String? spokenWord,
|
||||||
String? audioTitle,
|
String? audioTitle,
|
||||||
|
String? canonicalWord,
|
||||||
int? unansweredCount,
|
int? unansweredCount,
|
||||||
bool? needsPrepare,
|
bool? needsPrepare,
|
||||||
}) {
|
}) {
|
||||||
@ -97,6 +103,7 @@ class IncomingQuestion {
|
|||||||
presentation: presentation,
|
presentation: presentation,
|
||||||
spokenWord: spokenWord ?? this.spokenWord,
|
spokenWord: spokenWord ?? this.spokenWord,
|
||||||
audioTitle: audioTitle ?? this.audioTitle,
|
audioTitle: audioTitle ?? this.audioTitle,
|
||||||
|
canonicalWord: canonicalWord ?? this.canonicalWord,
|
||||||
needsPrepare: needsPrepare ?? this.needsPrepare,
|
needsPrepare: needsPrepare ?? this.needsPrepare,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
12
lib/models/spelling_progress_reset_result.dart
Normal file
12
lib/models/spelling_progress_reset_result.dart
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import 'incoming_question.dart';
|
||||||
|
|
||||||
|
/// Outcome of resetting per-user spelling links and familiarity progress.
|
||||||
|
class SpellingProgressResetResult {
|
||||||
|
const SpellingProgressResetResult({
|
||||||
|
required this.unansweredCount,
|
||||||
|
this.question,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int unansweredCount;
|
||||||
|
final IncomingQuestion? question;
|
||||||
|
}
|
||||||
@ -386,13 +386,21 @@ class HomeScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
ListenableBuilder(
|
ListenableBuilder(
|
||||||
listenable: QuestionsHubService.instance.scoreResetBusy,
|
listenable: Listenable.merge(<Listenable>[
|
||||||
|
QuestionsHubService.instance.scoreResetBusy,
|
||||||
|
QuestionsHubService.instance.spellingResetBusy,
|
||||||
|
]),
|
||||||
builder: (BuildContext context, Widget? child) {
|
builder: (BuildContext context, Widget? child) {
|
||||||
if (!QuestionsHubService.instance.scoreResetBusy.value) {
|
final bool blocking =
|
||||||
|
QuestionsHubService.instance.scoreResetBusy.value ||
|
||||||
|
QuestionsHubService.instance.spellingResetBusy.value;
|
||||||
|
if (!blocking) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
return Stack(
|
return Stack(
|
||||||
key: const Key('guess-score-reset-blocking-overlay'),
|
key: QuestionsHubService.instance.spellingResetBusy.value
|
||||||
|
? const Key('spelling-progress-reset-blocking-overlay')
|
||||||
|
: const Key('guess-score-reset-blocking-overlay'),
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
ModalBarrier(
|
ModalBarrier(
|
||||||
dismissible: false,
|
dismissible: false,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import '../config/api_config.dart';
|
|||||||
import '../models/guess_score_reset_result.dart';
|
import '../models/guess_score_reset_result.dart';
|
||||||
import '../models/guess_score_summary.dart';
|
import '../models/guess_score_summary.dart';
|
||||||
import '../models/incoming_question.dart';
|
import '../models/incoming_question.dart';
|
||||||
|
import '../models/spelling_progress_reset_result.dart';
|
||||||
import '../models/question_defer_result.dart';
|
import '../models/question_defer_result.dart';
|
||||||
import '../models/question_submit_result.dart';
|
import '../models/question_submit_result.dart';
|
||||||
import 'auth_service.dart';
|
import 'auth_service.dart';
|
||||||
@ -160,6 +161,36 @@ class QuestionsApiService {
|
|||||||
return IncomingQuestion.fromJson(questionJson);
|
return IncomingQuestion.fromJson(questionJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clears spelling links and familiarity progress, then re-seeds when enabled.
|
||||||
|
Future<SpellingProgressResetResult?> resetSpellingProgress() async {
|
||||||
|
final String? token = await AuthService.instance.getIdToken();
|
||||||
|
if (token == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final http.Response response = await _client.post(
|
||||||
|
Uri.parse('$apiBaseUrl/v1/me/questions/spelling/reset'),
|
||||||
|
headers: _authHeaders(token),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
debugPrint(
|
||||||
|
'resetSpellingProgress failed: ${response.statusCode} ${response.body}',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> body =
|
||||||
|
jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final Map<String, dynamic>? questionJson =
|
||||||
|
body['question'] as Map<String, dynamic>?;
|
||||||
|
return SpellingProgressResetResult(
|
||||||
|
unansweredCount: (body['unansweredCount'] as num?)?.toInt() ?? 0,
|
||||||
|
question: questionJson == null
|
||||||
|
? null
|
||||||
|
: IncomingQuestion.fromJson(questionJson),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<QuestionDeferResult?> deferQuestion({required String questionId}) async {
|
Future<QuestionDeferResult?> deferQuestion({required String questionId}) async {
|
||||||
final String? token = await AuthService.instance.getIdToken();
|
final String? token = await AuthService.instance.getIdToken();
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
|
|||||||
@ -31,6 +31,7 @@ class QuestionsHubService {
|
|||||||
ValueNotifier<List<IncomingQuestion>>(<IncomingQuestion>[]);
|
ValueNotifier<List<IncomingQuestion>>(<IncomingQuestion>[]);
|
||||||
final ValueNotifier<bool> questionActionBusy = ValueNotifier<bool>(false);
|
final ValueNotifier<bool> questionActionBusy = ValueNotifier<bool>(false);
|
||||||
final ValueNotifier<bool> scoreResetBusy = ValueNotifier<bool>(false);
|
final ValueNotifier<bool> scoreResetBusy = ValueNotifier<bool>(false);
|
||||||
|
final ValueNotifier<bool> spellingResetBusy = ValueNotifier<bool>(false);
|
||||||
final ValueNotifier<GuessScoreSummary> guessScoreSummary =
|
final ValueNotifier<GuessScoreSummary> guessScoreSummary =
|
||||||
ValueNotifier<GuessScoreSummary>(GuessScoreSummary.empty);
|
ValueNotifier<GuessScoreSummary>(GuessScoreSummary.empty);
|
||||||
|
|
||||||
@ -418,6 +419,36 @@ class QuestionsHubService {
|
|||||||
guessScoreSummary.value = score;
|
guessScoreSummary.value = score;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clears spelling links, familiarity progress, and spelling questions on the server.
|
||||||
|
Future<bool> resetSpellingProgress() async {
|
||||||
|
spellingResetBusy.value = true;
|
||||||
|
try {
|
||||||
|
final result = await _api.resetSpellingProgress();
|
||||||
|
if (result == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<IncomingQuestion> refreshed = await _api.fetchUnanswered();
|
||||||
|
final List<IncomingQuestion> active =
|
||||||
|
refreshed.where(_acceptsQuestionSource).toList(growable: false);
|
||||||
|
if (active.isNotEmpty) {
|
||||||
|
pendingQuestion.value = active.first;
|
||||||
|
pendingQuestionCount.value = active.length;
|
||||||
|
hasPendingQuestion.value = true;
|
||||||
|
if (questionPanelOpen.value) {
|
||||||
|
questionQueue.value = active;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pendingQuestion.value = null;
|
||||||
|
questionQueue.value = <IncomingQuestion>[];
|
||||||
|
hasPendingQuestion.value = _shouldKeepPanelOpenWithoutQuestion();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
spellingResetBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Clears cumulative guess score and answer statistics on the server.
|
/// Clears cumulative guess score and answer statistics on the server.
|
||||||
Future<bool> resetGuessScoreSummary() async {
|
Future<bool> resetGuessScoreSummary() async {
|
||||||
scoreResetBusy.value = true;
|
scoreResetBusy.value = true;
|
||||||
|
|||||||
@ -9,6 +9,7 @@ abstract final class AppColors {
|
|||||||
static const Color textPrimary = Color(0xFFF8FAFC);
|
static const Color textPrimary = Color(0xFFF8FAFC);
|
||||||
static const Color textSecondary = Color(0xFF94A3B8);
|
static const Color textSecondary = Color(0xFF94A3B8);
|
||||||
static const Color success = Color(0xFF34D399);
|
static const Color success = Color(0xFF34D399);
|
||||||
|
static const Color error = Color(0xFFF87171);
|
||||||
}
|
}
|
||||||
|
|
||||||
ThemeData buildAppTheme() {
|
ThemeData buildAppTheme() {
|
||||||
|
|||||||
6
lib/utils/spelling_answer_policy.dart
Normal file
6
lib/utils/spelling_answer_policy.dart
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/// Grading rules for typed spelling answers (mirrors server policy).
|
||||||
|
abstract final class SpellingAnswerPolicy {
|
||||||
|
static bool matches(String typed, String canonical) {
|
||||||
|
return typed.trim().toLowerCase() == canonical.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,6 +32,7 @@ class QuestionPresentationRouter extends StatelessWidget {
|
|||||||
questionText: question.text,
|
questionText: question.text,
|
||||||
spokenWord: question.spokenWord,
|
spokenWord: question.spokenWord,
|
||||||
audioTitle: question.audioTitle,
|
audioTitle: question.audioTitle,
|
||||||
|
canonicalWord: question.canonicalWord,
|
||||||
needsPrepare: question.needsPrepare,
|
needsPrepare: question.needsPrepare,
|
||||||
busy: busy,
|
busy: busy,
|
||||||
onSubmitAnswer: onAnswerText ?? (_) async {},
|
onSubmitAnswer: onAnswerText ?? (_) async {},
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../models/question_source_preference.dart';
|
import '../models/question_source_preference.dart';
|
||||||
import '../repositories/question_source_config_repository.dart';
|
import '../repositories/question_source_config_repository.dart';
|
||||||
import '../routes/app_routes.dart';
|
import '../routes/app_routes.dart';
|
||||||
import '../screens/spelling_list_screen.dart';
|
import '../services/questions_hub_service.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
/// Configure which question API sources are active and their relative mix.
|
/// Configure which question API sources are active and their relative mix.
|
||||||
@ -43,6 +43,7 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
|
|||||||
late List<QuestionSourcePreference> _draft;
|
late List<QuestionSourcePreference> _draft;
|
||||||
String? _validationMessage;
|
String? _validationMessage;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
bool _resettingSpelling = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -63,6 +64,53 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
|
|||||||
Navigator.of(context).pushNamed(AppRoutes.spellingWordList);
|
Navigator.of(context).pushNamed(AppRoutes.spellingWordList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmResetSpellingProgress() async {
|
||||||
|
final bool? confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext dialogContext) {
|
||||||
|
return AlertDialog(
|
||||||
|
key: const Key('spelling-progress-reset-confirm-dialog'),
|
||||||
|
title: const Text('Reset spelling progress?'),
|
||||||
|
content: const Text(
|
||||||
|
'This deletes your spelling question links and familiarity scores '
|
||||||
|
'and returns spelling to its initial state. You cannot undo this.',
|
||||||
|
),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
key: const Key('spelling-progress-reset-confirm-button'),
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
child: const Text('Reset'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_resettingSpelling = true;
|
||||||
|
_validationMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool ok =
|
||||||
|
await QuestionsHubService.instance.resetSpellingProgress();
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_resettingSpelling = false;
|
||||||
|
if (!ok) {
|
||||||
|
_validationMessage = 'Could not reset spelling progress.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
final bool hasEnabled =
|
final bool hasEnabled =
|
||||||
_draft.any((QuestionSourcePreference source) => source.enabled);
|
_draft.any((QuestionSourcePreference source) => source.enabled);
|
||||||
@ -103,7 +151,9 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
|
|||||||
final int enabledCount =
|
final int enabledCount =
|
||||||
_draft.where((QuestionSourcePreference s) => s.enabled).length;
|
_draft.where((QuestionSourcePreference s) => s.enabled).length;
|
||||||
|
|
||||||
return Padding(
|
return Stack(
|
||||||
|
children: <Widget>[
|
||||||
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 16),
|
padding: const EdgeInsets.fromLTRB(16, 12, 8, 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@ -126,7 +176,9 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
|
|||||||
IconButton(
|
IconButton(
|
||||||
key: const Key('question-source-settings-close'),
|
key: const Key('question-source-settings-close'),
|
||||||
tooltip: 'Close',
|
tooltip: 'Close',
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: _resettingSpelling || _saving
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(context).pop(),
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -150,6 +202,10 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
|
|||||||
onEditSpelling: _draft[index].id == 'spelling'
|
onEditSpelling: _draft[index].id == 'spelling'
|
||||||
? () => _openSpellingWordList(context)
|
? () => _openSpellingWordList(context)
|
||||||
: null,
|
: null,
|
||||||
|
onResetSpelling: _draft[index].id == 'spelling'
|
||||||
|
? _confirmResetSpellingProgress
|
||||||
|
: null,
|
||||||
|
resetSpellingBusy: _resettingSpelling,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -182,13 +238,15 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
|
|||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: _resettingSpelling || _saving
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(context).pop(),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
key: const Key('question-source-settings-save'),
|
key: const Key('question-source-settings-save'),
|
||||||
onPressed: _saving ? null : _save,
|
onPressed: _saving || _resettingSpelling ? null : _save,
|
||||||
child: _saving
|
child: _saving
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 18,
|
width: 18,
|
||||||
@ -201,6 +259,24 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
if (_resettingSpelling)
|
||||||
|
Positioned.fill(
|
||||||
|
child: ColoredBox(
|
||||||
|
color: Colors.black.withValues(alpha: 0.45),
|
||||||
|
child: const Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -212,12 +288,16 @@ class _QuestionSourceRow extends StatelessWidget {
|
|||||||
required this.effectivePercent,
|
required this.effectivePercent,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
this.onEditSpelling,
|
this.onEditSpelling,
|
||||||
|
this.onResetSpelling,
|
||||||
|
this.resetSpellingBusy = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final QuestionSourcePreference source;
|
final QuestionSourcePreference source;
|
||||||
final int? effectivePercent;
|
final int? effectivePercent;
|
||||||
final ValueChanged<QuestionSourcePreference> onChanged;
|
final ValueChanged<QuestionSourcePreference> onChanged;
|
||||||
final VoidCallback? onEditSpelling;
|
final VoidCallback? onEditSpelling;
|
||||||
|
final VoidCallback? onResetSpelling;
|
||||||
|
final bool resetSpellingBusy;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -288,9 +368,16 @@ class _QuestionSourceRow extends StatelessWidget {
|
|||||||
IconButton(
|
IconButton(
|
||||||
key: const Key('question-source-edit-spelling'),
|
key: const Key('question-source-edit-spelling'),
|
||||||
tooltip: 'Edit spelling word list',
|
tooltip: 'Edit spelling word list',
|
||||||
onPressed: onEditSpelling,
|
onPressed: resetSpellingBusy ? null : onEditSpelling,
|
||||||
icon: const Icon(Icons.edit, size: 20),
|
icon: const Icon(Icons.edit, size: 20),
|
||||||
),
|
),
|
||||||
|
if (onResetSpelling != null)
|
||||||
|
IconButton(
|
||||||
|
key: const Key('question-source-reset-spelling'),
|
||||||
|
tooltip: 'Reset spelling progress',
|
||||||
|
onPressed: resetSpellingBusy ? null : onResetSpelling,
|
||||||
|
icon: const Icon(Icons.restart_alt, size: 20),
|
||||||
|
),
|
||||||
Switch(
|
Switch(
|
||||||
key: Key('question-source-enabled-${source.id}'),
|
key: Key('question-source-enabled-${source.id}'),
|
||||||
value: source.enabled,
|
value: source.enabled,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import '../models/incoming_question.dart';
|
|||||||
import '../services/questions_api_service.dart';
|
import '../services/questions_api_service.dart';
|
||||||
import '../services/spelling_text_to_speech.dart';
|
import '../services/spelling_text_to_speech.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
import '../utils/spelling_answer_policy.dart';
|
||||||
import '../utils/spelling_spoken_word.dart';
|
import '../utils/spelling_spoken_word.dart';
|
||||||
|
|
||||||
/// Spelling quiz: hear the word, type your spelling, and submit.
|
/// Spelling quiz: hear the word, type your spelling, and submit.
|
||||||
@ -19,6 +20,7 @@ class SpellingQuestionTile extends StatefulWidget {
|
|||||||
this.busy = false,
|
this.busy = false,
|
||||||
this.spokenWord,
|
this.spokenWord,
|
||||||
this.audioTitle,
|
this.audioTitle,
|
||||||
|
this.canonicalWord,
|
||||||
this.needsPrepare = false,
|
this.needsPrepare = false,
|
||||||
this.onPrepared,
|
this.onPrepared,
|
||||||
this.prepareQuestion,
|
this.prepareQuestion,
|
||||||
@ -36,6 +38,9 @@ class SpellingQuestionTile extends StatefulWidget {
|
|||||||
/// Question id used as the TTS utterance title.
|
/// Question id used as the TTS utterance title.
|
||||||
final String? audioTitle;
|
final String? audioTitle;
|
||||||
|
|
||||||
|
/// Proper spelling shown after submit for green/red feedback.
|
||||||
|
final String? canonicalWord;
|
||||||
|
|
||||||
/// When true, calls the prepare API to select a word at tile load.
|
/// When true, calls the prepare API to select a word at tile load.
|
||||||
final bool needsPrepare;
|
final bool needsPrepare;
|
||||||
|
|
||||||
@ -53,16 +58,21 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
|||||||
final SpellingTextToSpeech _speech = SpellingTextToSpeech();
|
final SpellingTextToSpeech _speech = SpellingTextToSpeech();
|
||||||
final QuestionsApiService _api = QuestionsApiService();
|
final QuestionsApiService _api = QuestionsApiService();
|
||||||
final TextEditingController _answerController = TextEditingController();
|
final TextEditingController _answerController = TextEditingController();
|
||||||
|
final FocusNode _answerFocusNode = FocusNode();
|
||||||
|
|
||||||
String? _spokenWord;
|
String? _spokenWord;
|
||||||
String? _audioTitle;
|
String? _audioTitle;
|
||||||
|
String? _canonicalWord;
|
||||||
bool _preparing = false;
|
bool _preparing = false;
|
||||||
|
bool _showingFeedback = false;
|
||||||
|
bool? _answerCorrect;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_spokenWord = widget.spokenWord;
|
_spokenWord = widget.spokenWord;
|
||||||
_audioTitle = widget.audioTitle ?? widget.questionId;
|
_audioTitle = widget.audioTitle ?? widget.questionId;
|
||||||
|
_canonicalWord = widget.canonicalWord;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_prepareAndSpeak()));
|
WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_prepareAndSpeak()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,6 +83,9 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
|||||||
_answerController.clear();
|
_answerController.clear();
|
||||||
_spokenWord = widget.spokenWord;
|
_spokenWord = widget.spokenWord;
|
||||||
_audioTitle = widget.audioTitle ?? widget.questionId;
|
_audioTitle = widget.audioTitle ?? widget.questionId;
|
||||||
|
_canonicalWord = widget.canonicalWord;
|
||||||
|
_showingFeedback = false;
|
||||||
|
_answerCorrect = null;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_prepareAndSpeak()));
|
WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_prepareAndSpeak()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -80,10 +93,27 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_answerController.dispose();
|
_answerController.dispose();
|
||||||
|
_answerFocusNode.dispose();
|
||||||
unawaited(_speech.stop());
|
unawaited(_speech.stop());
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _requestAnswerFieldFocus() {
|
||||||
|
if (!mounted ||
|
||||||
|
!_wordReady ||
|
||||||
|
widget.busy ||
|
||||||
|
_preparing ||
|
||||||
|
_showingFeedback) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_answerFocusNode.requestFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _prepareAndSpeak() async {
|
Future<void> _prepareAndSpeak() async {
|
||||||
if (_preparing) {
|
if (_preparing) {
|
||||||
return;
|
return;
|
||||||
@ -91,6 +121,7 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
|||||||
|
|
||||||
if (!widget.needsPrepare) {
|
if (!widget.needsPrepare) {
|
||||||
await _speakWord();
|
await _speakWord();
|
||||||
|
_requestAnswerFieldFocus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,6 +138,7 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_spokenWord = prepared.spokenWord ?? _spokenWord;
|
_spokenWord = prepared.spokenWord ?? _spokenWord;
|
||||||
_audioTitle = prepared.audioTitle ?? prepared.id;
|
_audioTitle = prepared.audioTitle ?? prepared.id;
|
||||||
|
_canonicalWord = prepared.canonicalWord ?? _canonicalWord;
|
||||||
});
|
});
|
||||||
widget.onPrepared?.call(prepared);
|
widget.onPrepared?.call(prepared);
|
||||||
}
|
}
|
||||||
@ -114,6 +146,7 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
|||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _preparing = false);
|
setState(() => _preparing = false);
|
||||||
|
_requestAnswerFieldFocus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -137,18 +170,48 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? get _canonicalForGrading =>
|
||||||
|
_canonicalWord ?? widget.canonicalWord;
|
||||||
|
|
||||||
Future<void> _submitAnswer() async {
|
Future<void> _submitAnswer() async {
|
||||||
final String answer = _answerController.text.trim();
|
final String answer = _answerController.text.trim();
|
||||||
if (answer.isEmpty || widget.busy || _preparing || !_wordReady) {
|
if (answer.isEmpty ||
|
||||||
|
widget.busy ||
|
||||||
|
_preparing ||
|
||||||
|
_showingFeedback ||
|
||||||
|
!_wordReady) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final String? canonical = _canonicalForGrading;
|
||||||
|
if (canonical == null || canonical.isEmpty) {
|
||||||
|
await widget.onSubmitAnswer(answer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool correct = SpellingAnswerPolicy.matches(answer, canonical);
|
||||||
|
setState(() {
|
||||||
|
_answerCorrect = correct;
|
||||||
|
_showingFeedback = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(seconds: 1));
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await widget.onSubmitAnswer(answer);
|
await widget.onSubmitAnswer(answer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final bool canInteract = _wordReady && !widget.busy && !_preparing;
|
final bool canInteract =
|
||||||
final bool canSpeak = _wordReady;
|
_wordReady && !widget.busy && !_preparing && !_showingFeedback;
|
||||||
|
final bool canSpeak = _wordReady && !_showingFeedback;
|
||||||
|
final String? feedbackWord = _showingFeedback ? _canonicalForGrading : null;
|
||||||
|
final Color? feedbackColor = _answerCorrect == null
|
||||||
|
? null
|
||||||
|
: (_answerCorrect! ? AppColors.success : AppColors.error);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
@ -185,9 +248,25 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
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(
|
TextField(
|
||||||
key: const Key('spelling-answer-field'),
|
key: const Key('spelling-answer-field'),
|
||||||
controller: _answerController,
|
controller: _answerController,
|
||||||
|
focusNode: _answerFocusNode,
|
||||||
|
autofocus: canInteract,
|
||||||
enabled: canInteract,
|
enabled: canInteract,
|
||||||
textCapitalization: TextCapitalization.none,
|
textCapitalization: TextCapitalization.none,
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
@ -209,7 +288,8 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
|||||||
vertical: 14,
|
vertical: 14,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onSubmitted: canInteract ? (_) => unawaited(_submitAnswer()) : null,
|
onSubmitted:
|
||||||
|
canInteract ? (_) => unawaited(_submitAnswer()) : null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
@ -224,6 +304,7 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
|||||||
child: const Text('Skip for now'),
|
child: const Text('Skip for now'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -165,6 +165,24 @@ Handler questionsHandler({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.post('$questionsBasePath/spelling/reset', (Request request) async {
|
||||||
|
final String? firebaseUid = await _verify(auth, request);
|
||||||
|
if (firebaseUid == null) {
|
||||||
|
return _jsonResponse(401, <String, dynamic>{'error': 'Unauthorized'});
|
||||||
|
}
|
||||||
|
if (questionPipeline == null) {
|
||||||
|
return _jsonResponse(503, <String, dynamic>{'error': 'Unavailable'});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic> result =
|
||||||
|
await questionPipeline.resetSpellingProgressForUser(firebaseUid);
|
||||||
|
return _jsonResponse(200, result);
|
||||||
|
} catch (e, st) {
|
||||||
|
stderr.writeln('Reset spelling progress error: $e\n$st');
|
||||||
|
return _jsonResponse(500, <String, dynamic>{'error': 'Internal error'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'$questionsBasePath/<id>/spelling/prepare',
|
'$questionsBasePath/<id>/spelling/prepare',
|
||||||
(Request request, String id) async {
|
(Request request, String id) async {
|
||||||
|
|||||||
@ -459,6 +459,26 @@ class QuestionPipeline {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clears spelling links, progress, questions, and pipeline state; re-seeds when enabled.
|
||||||
|
Future<Map<String, dynamic>> resetSpellingProgressForUser(
|
||||||
|
String firebaseUid,
|
||||||
|
) async {
|
||||||
|
await _spellingQuestionLinksDb.deleteAllForUser(firebaseUid);
|
||||||
|
await _spellingProgressDb.deleteAllForUser(firebaseUid);
|
||||||
|
await _questionsDb.deleteAllSpellingQuestionsForUser(firebaseUid);
|
||||||
|
await _questionsDb.clearSpellingPipelineState(firebaseUid);
|
||||||
|
await maintainSpellingForUser(firebaseUid);
|
||||||
|
|
||||||
|
final Map<String, dynamic>? question =
|
||||||
|
await _questionDispatcher?.dispatchNextQuestion(firebaseUid);
|
||||||
|
final int unansweredCount =
|
||||||
|
await _questionsDb.countUnansweredQuestions(firebaseUid);
|
||||||
|
return <String, dynamic>{
|
||||||
|
'unansweredCount': unansweredCount,
|
||||||
|
if (question != null) 'question': question,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// Ensures an unanswered spelling word quiz exists when the source is enabled.
|
/// Ensures an unanswered spelling word quiz exists when the source is enabled.
|
||||||
Future<void> maintainSpellingForUser(String firebaseUid) async {
|
Future<void> maintainSpellingForUser(String firebaseUid) async {
|
||||||
if (!await _isSpellingEnabled(firebaseUid)) {
|
if (!await _isSpellingEnabled(firebaseUid)) {
|
||||||
|
|||||||
@ -106,6 +106,37 @@ class QuestionsDb {
|
|||||||
return rows.first;
|
return rows.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deletes every spelling pipeline question row for [firebaseUid].
|
||||||
|
Future<void> deleteAllSpellingQuestionsForUser(String firebaseUid) async {
|
||||||
|
await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
DELETE FROM questions
|
||||||
|
WHERE assigned_user_id = @uid
|
||||||
|
AND (
|
||||||
|
pipeline_key = 'spelling'
|
||||||
|
OR source_tag LIKE 'spelling:%'
|
||||||
|
)
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': firebaseUid},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears spelling pipeline state when [assignedUserId] is on that branch.
|
||||||
|
Future<void> clearSpellingPipelineState(String assignedUserId) async {
|
||||||
|
await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
DELETE FROM user_pipeline_state
|
||||||
|
WHERE assigned_user_id = @uid
|
||||||
|
AND pipeline_key = 'spelling'
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': assignedUserId},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Deletes every guess-game question row for [firebaseUid].
|
/// Deletes every guess-game question row for [firebaseUid].
|
||||||
///
|
///
|
||||||
/// Assignments and answer snapshots referencing these rows cascade away.
|
/// Assignments and answer snapshots referencing these rows cascade away.
|
||||||
@ -712,6 +743,8 @@ class QuestionsDb {
|
|||||||
if (isPreparedSpelling) ...<String, dynamic>{
|
if (isPreparedSpelling) ...<String, dynamic>{
|
||||||
'spokenWord': spokenWord,
|
'spokenWord': spokenWord,
|
||||||
'audioTitle': question['id'],
|
'audioTitle': question['id'],
|
||||||
|
if (metadata['canonical_word'] is String)
|
||||||
|
'canonicalWord': metadata['canonical_word'],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,19 @@ class SpellingQuestionLinksDb {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes every quiz-to-item link for [firebaseUid].
|
||||||
|
Future<void> deleteAllForUser(String firebaseUid) async {
|
||||||
|
await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
DELETE FROM spelling_question_links
|
||||||
|
WHERE firebase_uid = @uid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': firebaseUid},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> hasLink(String questionId) async {
|
Future<bool> hasLink(String questionId) async {
|
||||||
final Result result = await _connection.execute(
|
final Result result = await _connection.execute(
|
||||||
Sql.named(
|
Sql.named(
|
||||||
|
|||||||
@ -89,6 +89,19 @@ class UserSpellingProgressDb {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes all per-user spelling familiarity rows (initial progress state).
|
||||||
|
Future<void> deleteAllForUser(String firebaseUid) async {
|
||||||
|
await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
DELETE FROM user_spelling_progress
|
||||||
|
WHERE firebase_uid = @uid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': firebaseUid},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<int?> familiarityScore({
|
Future<int?> familiarityScore({
|
||||||
required String firebaseUid,
|
required String firebaseUid,
|
||||||
required String spellingItemId,
|
required String spellingItemId,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ library;
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:cyberhybridhub_server/pipeline/question_pipeline.dart';
|
import 'package:cyberhybridhub_server/pipeline/question_pipeline.dart';
|
||||||
|
import 'package:cyberhybridhub_server/question_dispatcher.dart';
|
||||||
import 'package:postgres/postgres.dart';
|
import 'package:postgres/postgres.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
@ -107,6 +108,7 @@ void main() {
|
|||||||
expect(prepared, isNotNull);
|
expect(prepared, isNotNull);
|
||||||
expect(prepared!['spokenWord'], isNotEmpty);
|
expect(prepared!['spokenWord'], isNotEmpty);
|
||||||
expect(prepared['audioTitle'], questionId);
|
expect(prepared['audioTitle'], questionId);
|
||||||
|
expect(prepared['canonicalWord'], isNotEmpty);
|
||||||
|
|
||||||
final Result link = await testDb!.connection.execute(
|
final Result link = await testDb!.connection.execute(
|
||||||
Sql.named(
|
Sql.named(
|
||||||
@ -250,4 +252,101 @@ void main() {
|
|||||||
expect(rows.last[0], PipelineSteps.spellingFollowUp);
|
expect(rows.last[0], PipelineSteps.spellingFollowUp);
|
||||||
expect(rows.last[1], 'spelling:follow_up');
|
expect(rows.last[1], 'spelling:follow_up');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resetSpellingProgressForUser clears links and progress then re-seeds',
|
||||||
|
() async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String uid = 'spelling-reset-uid';
|
||||||
|
await testDb!.seedUser(uid);
|
||||||
|
|
||||||
|
final QuestionDispatcher dispatcher = testDb!.questionDispatcher();
|
||||||
|
final QuestionPipeline pipeline = testDb!.questionPipeline(testMode: true);
|
||||||
|
pipeline.attachQuestionDispatcher(dispatcher);
|
||||||
|
await pipeline.maintainSpellingForUser(uid);
|
||||||
|
|
||||||
|
final Result reserved = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT id
|
||||||
|
FROM questions
|
||||||
|
WHERE assigned_user_id = @uid
|
||||||
|
LIMIT 1
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
|
);
|
||||||
|
final String questionId = reserved.first[0]!.toString();
|
||||||
|
await pipeline.prepareSpellingQuestion(
|
||||||
|
firebaseUid: uid,
|
||||||
|
questionId: questionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
final Result progressBefore = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT COUNT(*)::int
|
||||||
|
FROM user_spelling_progress
|
||||||
|
WHERE firebase_uid = @uid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
|
);
|
||||||
|
final Result linksBefore = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT COUNT(*)::int
|
||||||
|
FROM spelling_question_links
|
||||||
|
WHERE firebase_uid = @uid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
|
);
|
||||||
|
expect(progressBefore.first[0], greaterThan(0));
|
||||||
|
expect(linksBefore.first[0], greaterThan(0));
|
||||||
|
|
||||||
|
final Map<String, dynamic> result =
|
||||||
|
await pipeline.resetSpellingProgressForUser(uid);
|
||||||
|
expect(result['unansweredCount'], greaterThanOrEqualTo(1));
|
||||||
|
|
||||||
|
final Result progressAfter = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT COUNT(*)::int
|
||||||
|
FROM user_spelling_progress
|
||||||
|
WHERE firebase_uid = @uid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
|
);
|
||||||
|
final Result linksAfter = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT COUNT(*)::int
|
||||||
|
FROM spelling_question_links
|
||||||
|
WHERE firebase_uid = @uid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
|
);
|
||||||
|
expect(progressAfter.first[0], 0);
|
||||||
|
expect(linksAfter.first[0], 0);
|
||||||
|
|
||||||
|
final Result spellingQuestions = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT COUNT(*)::int
|
||||||
|
FROM questions
|
||||||
|
WHERE assigned_user_id = @uid
|
||||||
|
AND pipeline_key = 'spelling'
|
||||||
|
AND user_response IS NULL
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
|
);
|
||||||
|
expect(spellingQuestions.first[0], 1);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,6 +62,10 @@ void main() {
|
|||||||
find.byKey(const Key('question-source-edit-spelling')),
|
find.byKey(const Key('question-source-edit-spelling')),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('question-source-reset-spelling')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('spelling edit opens spelling word list screen', (
|
testWidgets('spelling edit opens spelling word list screen', (
|
||||||
|
|||||||
@ -14,6 +14,7 @@ Future<IncomingQuestion?> _mockPrepare(String questionId) async {
|
|||||||
presentation: 'spelling_word',
|
presentation: 'spelling_word',
|
||||||
spokenWord: 'colour',
|
spokenWord: 'colour',
|
||||||
audioTitle: questionId,
|
audioTitle: questionId,
|
||||||
|
canonicalWord: 'colour',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
75
test/widgets/spelling_question_tile_test.dart
Normal file
75
test/widgets/spelling_question_tile_test.dart
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import 'package:cyberhybridhub/theme/app_theme.dart';
|
||||||
|
import 'package:cyberhybridhub/widgets/spelling_question_tile.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('shows green canonical word then submits after one second', (tester) async {
|
||||||
|
var submitCount = 0;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: SpellingQuestionTile(
|
||||||
|
questionId: 'spell-1',
|
||||||
|
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.tap(find.byKey(const Key('spelling-submit-answer')));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(submitCount, 0);
|
||||||
|
final Text feedback = tester.widget<Text>(
|
||||||
|
find.byKey(const Key('spelling-feedback-word')),
|
||||||
|
);
|
||||||
|
expect(feedback.data, 'colour');
|
||||||
|
expect(feedback.style?.color, AppColors.success);
|
||||||
|
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(submitCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows red canonical word when answer is wrong', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: SpellingQuestionTile(
|
||||||
|
questionId: 'spell-2',
|
||||||
|
questionText: 'Spell the word you hear.',
|
||||||
|
spokenWord: 'colour',
|
||||||
|
canonicalWord: 'colour',
|
||||||
|
onSubmitAnswer: (_) async {},
|
||||||
|
onDefer: () async {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.enterText(find.byKey(const Key('spelling-answer-field')), 'color');
|
||||||
|
await tester.tap(find.byKey(const Key('spelling-submit-answer')));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final Text feedback = tester.widget<Text>(
|
||||||
|
find.byKey(const Key('spelling-feedback-word')),
|
||||||
|
);
|
||||||
|
expect(feedback.data, 'colour');
|
||||||
|
expect(feedback.style?.color, AppColors.error);
|
||||||
|
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
await tester.pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user