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