feat: 完善听力口语辅助状态记录、练习分块与复习抽查间隔机制

This commit is contained in:
shen
2026-09-23 18:33:22 -07:00
parent 6b42b7abc3
commit d740a9e19c
106 changed files with 2933 additions and 845 deletions
@@ -34,6 +34,7 @@ class _ReviewPageState extends State<ReviewPage>
final controller = TextEditingController();
bool showHint = false;
bool usedHelp = false;
int answerChecks = 0;
String? validationMessage;
bool generatingVariant = false;
bool generatingLesson = false;
@@ -50,6 +51,7 @@ class _ReviewPageState extends State<ReviewPage>
/// The answer just accepted, shown before moving to the next item.
({String answer, String reference, String? audio, bool assisted})? lastResult;
bool dictationPlayed = false;
ListeningSupport dictationSupport = ListeningSupport.none;
/// Items whose recognition question has been answered in this session. The
/// gate is asked once per item; the production task follows immediately.
@@ -77,21 +79,23 @@ class _ReviewPageState extends State<ReviewPage>
VoiceService.instance.deleteRecording(recordingPath);
showHint = false;
usedHelp = false;
answerChecks = 0;
validationMessage = null;
dictationDiff = null;
transcript = null;
recordingPath = null;
dictationPlayed = false;
dictationSupport = ListeningSupport.none;
aiFeedback = null;
aiFeedbackError = null;
}
Future<void> _checkWithAi(ReviewItem item) async {
Future<WritingAiFeedback?> _checkWithAi(ReviewItem item) async {
final answer = controller.text.trim();
if (answer.isEmpty || checkingWithAi) return;
if (answer.isEmpty || checkingWithAi) return null;
if (widget.state.aiProvider == AiProviderType.mock) {
setState(() => aiFeedbackError = '请先在“我的”配置 AI 服务;本地检查仍可继续复习。');
return;
setState(() => aiFeedbackError = '未配置 AI 服务;本次只记为练习,不增加独立掌握。');
return null;
}
setState(() {
checkingWithAi = true;
@@ -111,28 +115,46 @@ class _ReviewPageState extends State<ReviewPage>
// The learner may have moved on or kept typing while the request ran.
if (!mounted || controller.text.trim() != answer) {
if (mounted) setState(() => checkingWithAi = false);
return;
return null;
}
setState(() {
checkingWithAi = false;
aiFeedback = feedback;
aiFeedbackError = feedback == null
? '暂时无法获得 AI 检查结果。你的答案保留在这里,可稍后重试或直接提交。'
? '暂时无法获得 AI 检查结果;本次只记为练习,不增加独立掌握。'
: null;
// A shown correction is help: finishing afterwards counts as assisted.
if (feedback != null && feedback.verdict != 'accepted') usedHelp = true;
});
return feedback;
}
void _next(ReviewItem item, {required bool assisted}) {
Future<void> _next(ReviewItem item, {required bool assisted}) async {
if (checkingWithAi) return;
answerChecks++;
final result = ReviewFeedback.check(item, controller.text);
if (!result.complete) {
setState(() {
usedHelp = true;
validationMessage = result.message;
dictationDiff = result.dictation;
});
return;
}
if (item.skill != dictationSkill) {
final answerBeforeCheck = controller.text.trim();
final feedback = await _checkWithAi(item);
if (!mounted || controller.text.trim() != answerBeforeCheck) return;
if (feedback?.verdict == 'rewrite') {
setState(() {
usedHelp = true;
validationMessage = '请根据 AI 反馈修改句子,再提交。';
});
return;
}
if (feedback?.verdict != 'accepted') assisted = true;
}
assisted = assisted || usedHelp || answerChecks > 1;
final answer = controller.text.trim();
final dictation = item.skill == dictationSkill
? coreDictationSentences[item.id]
@@ -142,6 +164,9 @@ class _ReviewPageState extends State<ReviewPage>
widget.state.completeReview(
item,
assisted: assisted,
firstTry: answerChecks == 1,
attemptCount: answerChecks,
listeningSupport: dictation == null ? null : dictationSupport,
rawAnswer: answer,
inputMode: spoken && transcript == answer ? 'speechToText' : 'text',
originalTranscript: transcript,
@@ -221,7 +246,7 @@ class _ReviewPageState extends State<ReviewPage>
children: [
Text(english, style: const TextStyle(fontSize: 22)),
Row(
children: [
children: [
IconButton.filled(
tooltip: '播放',
onPressed: () => _play(audio),
@@ -269,7 +294,11 @@ class _ReviewPageState extends State<ReviewPage>
// Being told the answer is the moment to say what the item is for. A
// learner who could not produce `What's your name?` often knows the words
// and not the situation.
if (coreContrastQuestion(item.id, isTaught: widget.state.mastery.containsKey) != null) {
if (coreContrastQuestion(
item.id,
isTaught: widget.state.mastery.containsKey,
) !=
null) {
setState(() {
_resetAnswer();
contrastFor = item;
@@ -298,14 +327,14 @@ class _ReviewPageState extends State<ReviewPage>
child: SpacedColumn(
children: [
const Eyebrow('先弄清楚什么时候用'),
Text(
'这句话用在这种情况:',
style: Theme.of(context).textTheme.headlineMedium,
),
Text('这句话用在这种情况:', style: Theme.of(context).textTheme.headlineMedium),
UsageCard(itemId: item.id, state: widget.state),
SectionCard(
tint: AppColors.surfaceMuted,
child: Text(question.situation, style: const TextStyle(fontSize: 17)),
child: Text(
question.situation,
style: const TextStyle(fontSize: 17),
),
),
const Text('这时候该说哪一句?'),
for (final option in question.options)
@@ -378,7 +407,7 @@ class _ReviewPageState extends State<ReviewPage>
const SizedBox(width: 10),
Expanded(
child: Text(
result.assisted ? '带提示完成' : '答对了!',
result.assisted ? '完成练习(未计独立掌握)' : '答对了!',
style: Theme.of(context).textTheme.headlineMedium,
),
),
@@ -439,11 +468,8 @@ class _ReviewPageState extends State<ReviewPage>
}
Future<void> _play(String text, {bool slow = false}) async {
try {
await VoiceService.instance.speak(text, slow: slow);
} finally {
if (mounted) setState(() => dictationPlayed = true);
}
final played = await VoiceService.instance.speak(text, slow: slow);
if (played && mounted) setState(() => dictationPlayed = true);
}
Future<void> _generateVariant(ReviewItem item) async {
@@ -629,7 +655,21 @@ class _ReviewPageState extends State<ReviewPage>
const SizedBox(width: 10),
Expanded(child: Text(dictationPlayed ? '再听一次' : '播放句子')),
TextButton(
onPressed: () => _play(dictation.sentence, slow: true),
onPressed: dictationPlayed
? () async {
final played = await VoiceService.instance
.speak(dictation.sentence, slow: true);
if (played && mounted) {
setState(() {
if (dictationSupport !=
ListeningSupport.transcript) {
dictationSupport = ListeningSupport.slow;
}
usedHelp = true;
});
}
}
: null,
child: const Text('慢速'),
),
],
@@ -682,6 +722,9 @@ class _ReviewPageState extends State<ReviewPage>
onPressed: () => setState(() {
showHint = true;
usedHelp = true;
if (dictation != null) {
dictationSupport = ListeningSupport.transcript;
}
validationMessage = null;
}),
),
@@ -729,6 +772,7 @@ class _ReviewPageState extends State<ReviewPage>
: '参考:${item.hint}\n目标:${item.target}',
style: TextStyle(color: AppColors.warmInk),
),
if (dictation != null) Text('原文:${dictation.sentence}'),
if (audioText != null && dictation == null)
TextButton.icon(
onPressed: () => _play(audioText),
@@ -739,20 +783,12 @@ class _ReviewPageState extends State<ReviewPage>
],
),
),
// Dictation is already compared word by word against the sentence.
if (dictation == null)
OutlinedButton.icon(
onPressed: controller.text.trim().isEmpty || checkingWithAi
? null
: () => _checkWithAi(item),
icon: checkingWithAi
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.spellcheck),
label: Text(checkingWithAi ? '正在检查…' : 'AI 检查语法和拼写(可选)'),
if (checkingWithAi) const Center(child: CircularProgressIndicator()),
if (dictation == null &&
widget.state.aiProvider == AiProviderType.mock)
SectionCard(
tint: AppColors.warm,
child: const Text('尚未配置 AI 服务。可继续复习,但开放回答不会计入独立掌握。'),
),
if (aiFeedback != null)
SectionCard(
@@ -768,7 +804,7 @@ class _ReviewPageState extends State<ReviewPage>
Text('参考改正:${aiFeedback!.suggestion}'),
Text(
aiFeedback!.verdict == 'accepted'
? 'AI 仅作参考;是否完成仍由本地检查判断。'
? 'AI 已检查;本地规则也会检查目标表达。'
: '看过改正后再提交,本次会记为带提示完成。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
@@ -790,8 +826,8 @@ class _ReviewPageState extends State<ReviewPage>
),
if (dictationDiff case final diff?) _DictationDiffCard(diff: diff),
PrimaryButton(
label: usedHelp ? '带提示完成' : '我能独立回答',
onPressed: controller.text.trim().isEmpty
label: usedHelp ? '带提示完成' : '检查并提交',
onPressed: controller.text.trim().isEmpty || checkingWithAi
? null
: () => _next(item, assisted: usedHelp),
),
@@ -872,6 +908,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
/// The answer passed the local check; the next tap records it.
bool answerAccepted = false;
bool checkingWithAi = false;
bool aiVerified = false;
WritingAiFeedback? aiCheck;
String? aiCheckError;
@@ -925,7 +962,10 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
);
}
void _advance(GeneratedLesson lesson, GeneratedLessonTask task) {
Future<void> _advance(
GeneratedLesson lesson,
GeneratedLessonTask task,
) async {
final shortfall = adaptiveAnswerShortfall(task, controller.text);
final correct = shortfall == null;
if (!showReference && !correct) {
@@ -934,6 +974,18 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
}
// Show the verdict before moving on, so the learner sees it.
if (!answerAccepted) {
if (correct &&
!showReference &&
(task.skill == 'writing' || task.skill == 'speaking')) {
final answerBeforeCheck = controller.text.trim();
final feedback = await _checkWithAi(task);
if (!mounted || controller.text.trim() != answerBeforeCheck) return;
if (feedback?.verdict == 'rewrite') {
setState(() => answerFeedback = '请根据 AI 反馈修改句子,再检查。');
return;
}
aiVerified = feedback?.verdict == 'accepted';
}
setState(() {
answerAccepted = true;
answerFeedback = correct ? null : '$shortfall 已查看参考,本项按教学练习继续。';
@@ -944,7 +996,11 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
lesson: lesson,
task: task,
rawAnswer: controller.text.trim(),
assisted: showReference || aiCorrected,
assisted:
showReference ||
aiCorrected ||
((task.skill == 'writing' || task.skill == 'speaking') &&
!aiVerified),
correct: correct,
inputMode: usedVoice && !transcriptEdited && transcriptConfirmed
? 'speechToText'
@@ -988,16 +1044,17 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
void _clearChecks() {
answerAccepted = false;
aiVerified = false;
aiCheck = null;
aiCheckError = null;
}
Future<void> _checkWithAi(GeneratedLessonTask task) async {
Future<WritingAiFeedback?> _checkWithAi(GeneratedLessonTask task) async {
final answer = controller.text.trim();
if (answer.isEmpty || checkingWithAi) return;
if (answer.isEmpty || checkingWithAi) return null;
if (widget.state.aiProvider == AiProviderType.mock) {
setState(() => aiCheckError = '请先在“我的”配置 AI 服务;本地检查仍可继续补练。');
return;
setState(() => aiCheckError = '未配置 AI 服务;本次仅作教学练习。');
return null;
}
setState(() {
checkingWithAi = true;
@@ -1016,18 +1073,17 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
// The learner may have kept typing or moved on while the request ran.
if (!mounted || controller.text.trim() != answer) {
if (mounted) setState(() => checkingWithAi = false);
return;
return null;
}
setState(() {
checkingWithAi = false;
aiCheck = feedback;
aiCheckError = feedback == null
? '暂时无法获得 AI 检查结果。你的答案保留在这里,可稍后重试或直接继续。'
: null;
aiCheckError = feedback == null ? '暂时无法获得 AI 检查结果;本次仅作教学练习。' : null;
if (feedback != null && feedback.verdict != 'accepted') {
aiCorrected = true;
}
});
return feedback;
}
@override
@@ -1248,20 +1304,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
],
),
),
if (task.skill == 'writing' || task.skill == 'speaking')
OutlinedButton.icon(
onPressed: controller.text.trim().isEmpty || checkingWithAi
? null
: () => _checkWithAi(task),
icon: checkingWithAi
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.spellcheck),
label: Text(checkingWithAi ? '正在检查…' : 'AI 检查语法和拼写(可选)'),
),
if (checkingWithAi) const Center(child: CircularProgressIndicator()),
if (aiCheck != null)
SectionCard(
tint: aiCheck!.verdict == 'accepted'
@@ -1276,7 +1319,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
Text('参考改正:${aiCheck!.suggestion}'),
Text(
aiCheck!.verdict == 'accepted'
? 'AI 仅作参考;是否正确仍由本地检查判断。'
? 'AI 已检查;本地规则也会检查目标表达。'
: '看过改正后再继续,本项会记为带提示完成。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
@@ -1304,7 +1347,13 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
Icon(Icons.check_circle, color: AppColors.green),
const SizedBox(width: 8),
Text(
showReference || aiCorrected ? '正确(带提示完成)' : '答对了!',
showReference ||
aiCorrected ||
((task.skill == 'writing' ||
task.skill == 'speaking') &&
!aiVerified)
? '完成练习(不计独立掌握)'
: '答对了!',
style: TextStyle(
color: AppColors.green,
fontWeight: FontWeight.w600,
@@ -1346,7 +1395,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
: index == lesson.tasks.length - 1
? '完成补练'
: '下一项',
onPressed: controller.text.trim().isNotEmpty
onPressed: controller.text.trim().isNotEmpty && !checkingWithAi
? () => _advance(lesson, task)
: null,
),