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
@@ -44,11 +44,7 @@ class _LessonFlowState extends State<LessonFlow> {
final independentController = TextEditingController();
int selectedAnswer = -1;
/// Set once a wrong meaning is picked, so only a first-try choice counts as
/// recognition evidence.
bool listeningMissed = false;
int previewIndex = 0;
bool listeningAudioPlayed = false;
bool showWritingHelp = true;
bool showIndependentHelp = false;
LessonSegment get segment {
@@ -56,11 +52,9 @@ class _LessonFlowState extends State<LessonFlow> {
return lesson.segments[widget.state.activeSegmentIndexFor(lesson.id)];
}
LessonActivity get activity =>
activityBySegmentId(segment.id);
LessonActivity get activity => activityBySegmentId(segment.id);
List<VocabularyItem> get previewItems =>
vocabularyBySegmentId(segment.id);
List<VocabularyItem> get previewItems => vocabularyBySegmentId(segment.id);
@override
void initState() {
@@ -122,17 +116,21 @@ class _LessonFlowState extends State<LessonFlow> {
options: listeningOptions,
correctAnswer: activity.answers.first,
selectedAnswer: selectedAnswer,
audioPlayed: listeningAudioPlayed,
missed: listeningMissed,
onSelected: (value) => setState(() {
selectedAnswer = value;
audioPlayed: widget.state.listeningAudioPlayed,
audioUnavailable: widget.state.listeningAudioUnavailable,
support: widget.state.listeningSupport,
missed: widget.state.listeningAttempts > 0,
onSelected: (value) {
setState(() => selectedAnswer = value);
if (listeningOptions[value] != activity.answers.first) {
listeningMissed = true;
widget.state.recordListeningMiss();
}
}),
onPlayed: () => setState(() => listeningAudioPlayed = true),
},
onPlayed: () => widget.state.recordListeningPlayback(),
onAudioFailed: widget.state.recordListeningAudioUnavailable,
onSlowPlayed: () => widget.state.recordListeningPlayback(slow: true),
onLookup: () {
setState(() => listeningMissed = true);
widget.state.revealListeningTranscript();
showLexiconLookup(
context,
state: widget.state,
@@ -142,8 +140,7 @@ class _LessonFlowState extends State<LessonFlow> {
onContinue:
selectedAnswer >= 0 &&
listeningOptions[selectedAnswer] == activity.answers.first
? () =>
widget.state.completeListening(recognized: !listeningMissed)
? () => widget.state.completeListening(recognized: true)
: null,
);
case LessonStep.speaking:
@@ -152,8 +149,13 @@ class _LessonFlowState extends State<LessonFlow> {
segmentId: segment.id,
text: activity.speaking,
tip: activity.speakingTip,
chunks: activity.speakingChunks,
examples: activity.speakingExamples,
buildIndex: widget.state.sentenceBuildIndex,
onBuildProgress: widget.state.recordSentenceBuildProgress,
keepRecording: widget.state.keepRecordings,
onContinue: widget.state.completeSpeaking,
onContinue: (practiced) =>
widget.state.completeSpeaking(practiced: practiced),
);
case LessonStep.reading:
content = _ReadingStep(
@@ -191,8 +193,11 @@ class _LessonFlowState extends State<LessonFlow> {
},
onToggleHelp: () =>
setState(() => showWritingHelp = !showWritingHelp),
onContinue: (usedAiFeedback) => widget.state.completeWriting(
assisted: showWritingHelp || usedAiFeedback,
onContinue: (verified) => widget.state.completeWriting(
// The example is visible when this guided step opens. Hiding it
// later cannot turn the same answer into an independent attempt.
assisted: true,
verified: verified,
rawAnswer: writingController.text.trim(),
),
);
@@ -221,9 +226,10 @@ class _LessonFlowState extends State<LessonFlow> {
initialText: activity.independentPrompt,
);
},
onContinue: (spoken, recordingPath, aiCorrected) =>
onContinue: (spoken, recordingPath, aiCorrected, verified) =>
widget.state.completeIndependentAttempt(
assisted: showIndependentHelp || aiCorrected,
verified: verified,
spoken: spoken,
rawAnswer: independentController.text.trim(),
recordingPath: recordingPath,
@@ -234,6 +240,7 @@ class _LessonFlowState extends State<LessonFlow> {
case LessonStep.complete:
content = _CompletionStep(
assisted: widget.state.independentAttemptAssisted,
pending: widget.state.independentAttemptPending,
isFinalSegment:
widget.state.activeSegmentIndexFor(lesson.id) ==
lesson.segments.length - 1,
@@ -343,11 +350,13 @@ class _DialoguePendingStep extends StatelessWidget {
class _CompletionStep extends StatelessWidget {
const _CompletionStep({
required this.assisted,
required this.pending,
required this.isFinalSegment,
required this.nextSegmentNumber,
required this.onFinish,
});
final bool assisted;
final bool pending;
final bool isFinalSegment;
final int nextSegmentNumber;
final VoidCallback onFinish;
@@ -362,7 +371,13 @@ class _CompletionStep extends StatelessWidget {
isFinalSegment ? '你完成了这节课的练习。' : '你完成了当前小段的练习。',
style: Theme.of(context).textTheme.headlineMedium,
),
Text(assisted ? '独立尝试使用了帮助,系统会安排不同题再练一次。' : '这次独立尝试已记录,后续会在不同情境中复练。'),
Text(
pending
? '练习已保存,但答案未经 AI 核验,不计入独立掌握。后续会安排复练。'
: assisted
? '独立尝试使用了帮助,系统会安排不同题再练一次。'
: '这次独立尝试已记录,后续会在不同情境中复练。',
),
if (!isFinalSegment) Text('回到首页后,下次从第 $nextSegmentNumber 段继续。'),
PrimaryButton(label: '回到首页', onPressed: onFinish),
],
@@ -371,20 +386,28 @@ class _CompletionStep extends StatelessWidget {
}
class _AudioRow extends StatelessWidget {
const _AudioRow({required this.label, this.speech, this.onPlayed});
const _AudioRow({
required this.label,
this.speech,
this.onPlayed,
this.onFailed,
this.showSlow = true,
});
final String label;
final String? speech;
final VoidCallback? onPlayed;
final VoidCallback? onFailed;
final bool showSlow;
@override
Widget build(BuildContext context) => Row(
children: [
IconButton.filled(
onPressed: () async {
try {
await VoiceService.instance.speak(speech ?? label);
} finally {
if (await VoiceService.instance.speak(speech ?? label)) {
onPlayed?.call();
} else {
onFailed?.call();
}
},
icon: const Icon(Icons.play_arrow),
@@ -393,10 +416,10 @@ class _AudioRow extends StatelessWidget {
Expanded(
child: InkWell(
onTap: () async {
try {
await VoiceService.instance.speak(speech ?? label);
} finally {
if (await VoiceService.instance.speak(speech ?? label)) {
onPlayed?.call();
} else {
onFailed?.call();
}
},
child: Padding(
@@ -405,16 +428,18 @@ class _AudioRow extends StatelessWidget {
),
),
),
TextButton(
onPressed: () async {
try {
await VoiceService.instance.speak(speech ?? label, slow: true);
} finally {
onPlayed?.call();
}
},
child: const Text('慢速'),
),
if (showSlow)
TextButton(
onPressed: () async {
if (await VoiceService.instance.speak(
speech ?? label,
slow: true,
)) {
onPlayed?.call();
}
},
child: const Text('慢速'),
),
],
);
}
@@ -27,7 +27,12 @@ class _IndependentStep extends StatefulWidget {
final VoidCallback onChanged;
final VoidCallback onNeedHelp;
final VoidCallback onLookup;
final void Function(bool spoken, String? recordingPath, bool aiCorrected)
final void Function(
bool spoken,
String? recordingPath,
bool aiCorrected,
bool verified,
)
onContinue;
final VoidCallback onLater;
@@ -57,7 +62,8 @@ class _IndependentStepState extends State<_IndependentStep>
super.dispose();
}
void _submit() {
Future<void> _submit() async {
if (!widget.canContinue || checkingWithAi) return;
final shortfall = independentShortfall(
widget.segmentId,
widget.controller.text,
@@ -66,12 +72,20 @@ class _IndependentStepState extends State<_IndependentStep>
setState(() => validationError = shortfall);
return;
}
if (widget.state.aiProvider == AiProviderType.mock) {
_finish(verified: false);
return;
}
await _checkWithAi();
if (!mounted) return;
if (aiCheck?.verdict == 'accepted') _finish(verified: true);
}
void _finish({required bool verified}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
aiCheck?.verdict == 'accepted'
? '通过:用上了本段要练的表达,AI 也没发现拼写或语法问题。'
: '通过:用上了本段要练的表达。',
verified ? '通过:用上了本段要练的表达,AI 也没发现拼写或语法问题。' : '练习已保存;答案未经核验,不计入独立掌握。',
),
),
);
@@ -79,6 +93,7 @@ class _IndependentStepState extends State<_IndependentStep>
usedVoice && !transcriptEdited,
widget.keepRecording ? recordingPath : null,
aiCorrected,
verified,
);
}
@@ -90,13 +105,9 @@ class _IndependentStepState extends State<_IndependentStep>
Future<void> _checkWithAi() async {
final answer = widget.controller.text.trim();
if (answer.isEmpty || checkingWithAi) return;
if (widget.state.aiProvider == AiProviderType.mock) {
setState(() => aiCheckError = '请先在“我的”配置 AI 服务;本地检查仍可继续学习。');
return;
}
setState(() {
checkingWithAi = true;
aiCheck = null;
aiCheckError = null;
});
final feedback = await AiService.instance.capabilities.evaluation
@@ -119,9 +130,11 @@ class _IndependentStepState extends State<_IndependentStep>
checkingWithAi = false;
aiCheck = feedback;
aiCheckError = feedback == null
? '暂时无法获得 AI 检查结果。你的答案保留在这里,可稍后重试或直接提交。'
? '暂时无法核验答案。可重试,或仅保存练习继续。'
: feedback.verdict == 'uncertain'
? 'AI 无法确定答案是否正确。可重试,或仅保存练习继续。'
: null;
if (feedback != null && feedback.verdict != 'accepted') {
if (feedback?.verdict == 'rewrite') {
aiCorrected = true;
}
});
@@ -191,6 +204,11 @@ class _IndependentStepState extends State<_IndependentStep>
const Eyebrow('试着自己写 / 说一次 · 约 1 分钟'),
Text('现在不看句框。', style: Theme.of(context).textTheme.headlineMedium),
Text(widget.activity.independentPrompt),
if (widget.state.aiProvider == AiProviderType.mock)
SectionCard(
tint: AppColors.warm,
child: const Text('尚未配置 AI 服务。本次可继续练习,但开放回答不会计入独立掌握。'),
),
if (widget.showHelp)
SectionCard(
tint: AppColors.warm,
@@ -255,19 +273,6 @@ class _IndependentStepState extends State<_IndependentStep>
),
),
),
OutlinedButton.icon(
onPressed: widget.canContinue && !checkingWithAi
? _checkWithAi
: null,
icon: checkingWithAi
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.spellcheck),
label: Text(checkingWithAi ? '正在检查…' : '提交前 AI 检查语法和拼写(可选)'),
),
if (aiCheck != null)
SectionCard(
tint: aiCheck!.verdict == 'accepted'
@@ -282,8 +287,10 @@ class _IndependentStepState extends State<_IndependentStep>
Text('参考改正:${aiCheck!.suggestion}'),
Text(
aiCheck!.verdict == 'accepted'
? 'AI 仅作参考;是否完成仍由本地检查判断。'
: '看过改正后再提交,本次会记为带提示完成。',
? '本地目标与 AI 检查均通过。'
: aiCheck!.verdict == 'rewrite'
? '修改后再次提交;看过改正,本次会记为辅助练习。'
: '答案尚未核验,可重试或仅保存练习。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
],
@@ -322,9 +329,14 @@ class _IndependentStepState extends State<_IndependentStep>
else
TextButton(onPressed: widget.onLookup, child: const Text('查词或短语')),
PrimaryButton(
label: widget.showHelp || aiCorrected ? '带帮助完成' : '独立完成',
onPressed: widget.canContinue ? _submit : null,
label: checkingWithAi ? '正在自动检查…' : '提交独立尝试',
onPressed: widget.canContinue && !checkingWithAi ? _submit : null,
),
if (aiCheckError != null)
TextButton(
onPressed: () => _finish(verified: false),
child: const Text('未核验,保存练习并继续'),
),
TextButton(onPressed: widget.onLater, child: const Text('稍后继续')),
],
),
@@ -8,9 +8,13 @@ class _ListeningStep extends StatelessWidget {
required this.correctAnswer,
required this.selectedAnswer,
required this.audioPlayed,
required this.audioUnavailable,
required this.support,
required this.missed,
required this.onSelected,
required this.onPlayed,
required this.onAudioFailed,
required this.onSlowPlayed,
required this.onLookup,
required this.onContinue,
});
@@ -22,11 +26,15 @@ class _ListeningStep extends StatelessWidget {
final int selectedAnswer;
final bool audioPlayed;
final bool audioUnavailable;
final ListeningSupport support;
/// 选错过或查看过原文;此后可以看原文,但本题不再算独立听辨。
final bool missed;
final ValueChanged<int> onSelected;
final VoidCallback onPlayed;
final VoidCallback onAudioFailed;
final VoidCallback onSlowPlayed;
final VoidCallback onLookup;
final VoidCallback? onContinue;
@@ -35,7 +43,8 @@ class _ListeningStep extends StatelessWidget {
final answers = options;
final answeredCorrectly =
selectedAnswer >= 0 && answers[selectedAnswer] == correctAnswer;
final revealText = answeredCorrectly || missed;
final revealText =
answeredCorrectly || support == ListeningSupport.transcript;
return _LessonScaffold(
step: 2,
child: SpacedColumn(
@@ -52,13 +61,32 @@ class _ListeningStep extends StatelessWidget {
label: audioPlayed ? '再播放一次' : '播放问题',
speech: activity.listening,
onPlayed: onPlayed,
onFailed: onAudioFailed,
showSlow: false,
),
),
if (!audioPlayed)
if (audioPlayed && !revealText)
TextButton.icon(
onPressed: () async {
final played = await VoiceService.instance.speak(
activity.listening,
slow: true,
);
if (played) onSlowPlayed();
},
icon: const Icon(Icons.slow_motion_video),
label: Text(support == ListeningSupport.slow ? '再慢听一次' : '慢一点再听'),
),
if (!audioPlayed && !audioUnavailable)
Text(
'先播放音频,听完再选择。原文会在作答后显示。',
style: TextStyle(color: AppColors.muted),
),
if (audioUnavailable)
SectionCard(
tint: AppColors.warm,
child: const Text('当前设备无法播放语音。下面显示原文,你仍可完成理解练习;本题不会计为独立听辨。'),
),
if (revealText)
SectionCard(child: LexiconText(activity.listening, state: state))
else if (audioPlayed)
@@ -70,7 +98,9 @@ class _ListeningStep extends StatelessWidget {
for (var index = 0; index < answers.length; index++)
SectionCard(
tint: selectedAnswer == index ? AppColors.softGreen : null,
onTap: audioPlayed ? () => onSelected(index) : null,
onTap: audioPlayed || audioUnavailable
? () => onSelected(index)
: null,
child: Row(
children: [
Icon(
@@ -84,7 +114,7 @@ class _ListeningStep extends StatelessWidget {
const SizedBox(width: 10),
Text(
answers[index],
style: audioPlayed
style: audioPlayed || audioUnavailable
? null
: TextStyle(color: AppColors.muted),
),
@@ -94,14 +124,11 @@ class _ListeningStep extends StatelessWidget {
PrimaryButton(
label: selectedAnswer >= 0
? '检查并继续'
: (audioPlayed ? '请选择答案' : '先播放音频'),
: (audioPlayed || audioUnavailable ? '请选择答案' : '先播放音频'),
onPressed: onContinue,
),
if (selectedAnswer >= 0 && answers[selectedAnswer] != correctAnswer)
Text(
'再听一次,选择正确答案。',
style: TextStyle(color: AppColors.warmInk),
),
Text('再听一次,选择正确答案。', style: TextStyle(color: AppColors.warmInk)),
],
),
);
@@ -6,15 +6,23 @@ class _SpeakingStep extends StatefulWidget {
required this.segmentId,
required this.text,
this.tip = '',
this.chunks = const [],
this.examples = const [],
required this.buildIndex,
required this.onBuildProgress,
required this.keepRecording,
required this.onContinue,
});
final String segmentId;
final String text;
final String tip;
final List<String> chunks;
final List<String> examples;
final int buildIndex;
final ValueChanged<int> onBuildProgress;
final AppState state;
final bool keepRecording;
final VoidCallback onContinue;
final ValueChanged<bool> onContinue;
@override
State<_SpeakingStep> createState() => _SpeakingStepState();
@@ -89,12 +97,57 @@ class _SpeakingStepState extends State<_SpeakingStep>
if (widget.tip.isNotEmpty)
SectionCard(
tint: AppColors.warm,
child: Text(
widget.tip,
style: TextStyle(color: AppColors.warmInk),
),
child: Text(widget.tip, style: TextStyle(color: AppColors.warmInk)),
),
SegmentUsage(segmentId: widget.segmentId, state: widget.state),
if (widget.chunks.length >= 2)
SectionCard(
tint: AppColors.surfaceMuted,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('拆开练(可选)'),
const SizedBox(height: 8),
Text(
widget.buildIndex == 0
? '先听词块,再逐步合成整句。'
: widget.chunks.take(widget.buildIndex).join(' '),
),
TextButton.icon(
onPressed: () async {
final next = widget.buildIndex < widget.chunks.length
? widget.buildIndex + 1
: 1;
await VoiceService.instance.speak(
widget.chunks.take(next).join(' '),
);
widget.onBuildProgress(next);
},
icon: const Icon(Icons.play_arrow),
label: Text(
widget.buildIndex == widget.chunks.length
? '从头再练'
: '听下一段并跟读',
),
),
],
),
),
if (widget.examples.isNotEmpty)
ExpansionTile(
title: Text('更多示范句(${widget.examples.length})'),
children: [
for (final example in widget.examples)
ListTile(
title: LexiconText(example, state: widget.state),
trailing: IconButton(
tooltip: '播放示范句',
icon: const Icon(Icons.volume_up_outlined),
onPressed: () => VoiceService.instance.speak(example),
),
),
],
),
SecondaryButton(
label: transcribing
? '正在 AI 识别发音…'
@@ -146,8 +199,10 @@ class _SpeakingStepState extends State<_SpeakingStep>
),
),
PrimaryButton(
label: transcript.isEmpty ? '我已跟读,继续' : '确认并继续',
onPressed: widget.onContinue,
label: recordingPath == null ? '跳过跟读,继续' : '录音完成,继续',
onPressed: (listening || transcribing)
? null
: () => widget.onContinue(recordingPath != null),
),
],
),
@@ -34,23 +34,18 @@ class _WritingStepState extends State<_WritingStep> {
String? aiFeedbackError;
bool requestingAiFeedback = false;
void _checkOrContinue() {
if (result?.complete == true) {
widget.onContinue(aiFeedback != null);
return;
}
setState(
() => result = WritingFeedback.check(
widget.segmentId,
widget.controller.text,
),
);
}
Future<void> _requestAiFeedback() async {
if (widget.controller.text.trim().isEmpty || requestingAiFeedback) return;
Future<void> _submit() async {
if (!widget.canContinue || requestingAiFeedback) return;
final answer = widget.controller.text.trim();
final local = WritingFeedback.check(widget.segmentId, answer);
setState(() {
result = local;
aiFeedback = null;
aiFeedbackError = null;
});
if (!local.complete) return;
if (widget.state.aiProvider == AiProviderType.mock) {
setState(() => aiFeedbackError = '请先在“我的”配置 AI 服务;本地检查仍可继续学习。');
widget.onContinue(false);
return;
}
setState(() {
@@ -64,19 +59,28 @@ class _WritingStepState extends State<_WritingStep> {
model: widget.state.aiModel,
lessonId: widget.lessonId,
taskPrompt: widget.activity.writingPrompt,
answer: widget.controller.text.trim(),
answer: answer,
level: lessonLevel(widget.lessonId),
);
if (!mounted) return;
if (!mounted || widget.controller.text.trim() != answer) {
if (mounted) setState(() => requestingAiFeedback = false);
return;
}
if (feedback?.verdict == 'accepted') {
widget.onContinue(true);
return;
}
setState(() {
requestingAiFeedback = false;
aiFeedback = feedback;
aiFeedbackError = feedback == null
? '暂时无法获得 AI 反馈。你的答案保留在这里,可稍后重试或继续本地练习。'
aiFeedbackError = feedback == null || feedback.verdict == 'uncertain'
? '答案暂时无法核验。可以重试,或仅保存这次练习继续学习。'
: null;
});
}
void _continueUnverified() => widget.onContinue(false);
@override
Widget build(BuildContext context) => _LessonScaffold(
step: 5,
@@ -87,6 +91,11 @@ class _WritingStepState extends State<_WritingStep> {
widget.activity.writingPrompt,
style: Theme.of(context).textTheme.headlineMedium,
),
if (widget.state.aiProvider == AiProviderType.mock)
SectionCard(
tint: AppColors.warm,
child: const Text('尚未配置 AI 服务。本次可继续练习,但开放回答不会计入独立掌握。'),
),
SectionCard(
tint: AppColors.surfaceMuted,
child: Text('小提示:${grammarNoteForSegment(widget.segmentId)}'),
@@ -126,19 +135,6 @@ class _WritingStepState extends State<_WritingStep> {
onPressed: widget.onToggleHelp,
child: Text(widget.showHelp ? '收起示例,自己试一次' : '需要帮助,查看示例'),
),
OutlinedButton.icon(
onPressed: widget.canContinue && !requestingAiFeedback
? _requestAiFeedback
: null,
icon: requestingAiFeedback
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.auto_awesome_outlined),
label: Text(requestingAiFeedback ? '正在获取反馈…' : '获取 AI 写作建议(可选)'),
),
if (aiFeedback != null)
SectionCard(
tint: aiFeedback!.verdict == 'accepted'
@@ -153,7 +149,9 @@ class _WritingStepState extends State<_WritingStep> {
if (aiFeedback!.suggestion != null)
Text('可参考改写:${aiFeedback!.suggestion}'),
Text(
'这是学习帮助;请按自己的意思重写后再检查,系统不会仅凭 AI 建议记为掌握。',
aiFeedback!.verdict == 'rewrite'
? '请按自己的意思修改,再提交检查。看过改正后,本次只算辅助练习。'
: '答案尚未核验;可以重试检查或仅保存练习。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
],
@@ -167,20 +165,25 @@ class _WritingStepState extends State<_WritingStep> {
style: TextStyle(color: AppColors.warmInk),
),
),
if (result != null)
if (result != null && !result!.complete)
SectionCard(
tint: result!.complete ? AppColors.softGreen : AppColors.warm,
tint: AppColors.warm,
child: Text(
result!.message,
style: TextStyle(
color: result!.complete ? AppColors.green : AppColors.warmInk,
),
style: TextStyle(color: AppColors.warmInk),
),
),
PrimaryButton(
label: result?.complete == true ? '进入课程对话' : '检查句子',
onPressed: widget.canContinue ? _checkOrContinue : null,
label: requestingAiFeedback ? '正在自动检查…' : '提交并继续',
onPressed: widget.canContinue && !requestingAiFeedback
? _submit
: null,
),
if (aiFeedbackError != null)
TextButton(
onPressed: _continueUnverified,
child: const Text('未核验,保存练习并继续'),
),
],
),
);