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
@@ -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('未核验,保存练习并继续'),
),
],
),
);