feat: 完善听力口语辅助状态记录、练习分块与复习抽查间隔机制
This commit is contained in:
@@ -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,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user