整个课程和数据UI重新设计版本
This commit is contained in:
@@ -7,7 +7,8 @@ import '../../core/ai_service.dart';
|
||||
import '../../core/generated_content.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../core/review_feedback.dart';
|
||||
import '../../core/a0_core.dart';
|
||||
import '../../core/courses/courses.dart';
|
||||
import '../../core/speech_compare.dart';
|
||||
import '../../core/voice_service.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
import '../../widgets/voice_answer.dart';
|
||||
@@ -35,9 +36,18 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
String? validationMessage;
|
||||
bool generatingVariant = false;
|
||||
bool generatingLesson = false;
|
||||
bool checkingWithAi = false;
|
||||
WritingAiFeedback? aiFeedback;
|
||||
String? aiFeedbackError;
|
||||
|
||||
/// The untouched transcript of a spoken answer; null for typed answers.
|
||||
String? transcript;
|
||||
|
||||
/// The failed dictation check, shown word by word.
|
||||
SpokenComparison? dictationDiff;
|
||||
|
||||
/// The answer just accepted, shown before moving to the next item.
|
||||
({String answer, String reference, String? audio, bool assisted})? lastResult;
|
||||
bool dictationPlayed = false;
|
||||
|
||||
@override
|
||||
@@ -58,18 +68,65 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
showHint = false;
|
||||
usedHelp = false;
|
||||
validationMessage = null;
|
||||
dictationDiff = null;
|
||||
transcript = null;
|
||||
recordingPath = null;
|
||||
dictationPlayed = false;
|
||||
aiFeedback = null;
|
||||
aiFeedbackError = null;
|
||||
}
|
||||
|
||||
Future<void> _checkWithAi(ReviewItem item) async {
|
||||
final answer = controller.text.trim();
|
||||
if (answer.isEmpty || checkingWithAi) return;
|
||||
if (widget.state.aiProvider == AiProviderType.mock) {
|
||||
setState(() => aiFeedbackError = '请先在“我的”配置 AI 服务;本地检查仍可继续复习。');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
checkingWithAi = true;
|
||||
aiFeedbackError = null;
|
||||
});
|
||||
final feedback = await AiService.instance.answerFeedback(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
answerId: item.id,
|
||||
target: item.target,
|
||||
taskPrompt: item.prompt,
|
||||
answer: answer,
|
||||
level: itemLevel(item.id),
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
setState(() {
|
||||
checkingWithAi = false;
|
||||
aiFeedback = feedback;
|
||||
aiFeedbackError = feedback == null
|
||||
? '暂时无法获得 AI 检查结果。你的答案保留在这里,可稍后重试或直接提交。'
|
||||
: null;
|
||||
// A shown correction is help: finishing afterwards counts as assisted.
|
||||
if (feedback != null && feedback.verdict != 'accepted') usedHelp = true;
|
||||
});
|
||||
}
|
||||
|
||||
void _next(ReviewItem item, {required bool assisted}) {
|
||||
final result = ReviewFeedback.check(item, controller.text);
|
||||
if (!result.complete) {
|
||||
setState(() => validationMessage = result.message);
|
||||
setState(() {
|
||||
validationMessage = result.message;
|
||||
dictationDiff = result.dictation;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final answer = controller.text.trim();
|
||||
final dictation = item.skill == dictationSkill
|
||||
? coreDictationSentences[item.id]
|
||||
: null;
|
||||
final hint = item.hint.trim();
|
||||
final spoken = transcript != null;
|
||||
widget.state.completeReview(
|
||||
item,
|
||||
@@ -83,7 +140,79 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
);
|
||||
// A kept recording now belongs to the evidence row; don't delete it.
|
||||
if (spoken && widget.state.keepRecordings) recordingPath = null;
|
||||
setState(_resetAnswer);
|
||||
setState(() {
|
||||
_resetAnswer();
|
||||
lastResult = (
|
||||
answer: answer,
|
||||
reference:
|
||||
dictation?.sentence ??
|
||||
(hint.isNotEmpty && hint != item.target
|
||||
? '${item.target}\n例:$hint'
|
||||
: item.target),
|
||||
audio: dictation?.sentence ?? reviewAudioText(item.id, item.target),
|
||||
assisted: assisted,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _resultView(
|
||||
({String answer, String reference, String? audio, bool assisted}) result,
|
||||
) {
|
||||
final remaining = widget.state.dueReviews.length;
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Eyebrow(remaining > 0 ? '还有 $remaining 项待复习' : '今天的复习项目已全部完成'),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
result.assisted ? Icons.lightbulb_outline : Icons.check_circle,
|
||||
color: result.assisted ? AppColors.warmInk : AppColors.green,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
result.assisted ? '带提示完成' : '答对了!',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SectionCard(
|
||||
child: SpacedColumn(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text('你的回答:${result.answer}'),
|
||||
Text(
|
||||
'参考说法:${result.reference}',
|
||||
style: TextStyle(
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (result.audio != null)
|
||||
TextButton.icon(
|
||||
onPressed: () => VoiceService.instance.speak(result.audio!),
|
||||
icon: const Icon(Icons.volume_up_outlined),
|
||||
label: const Text('听参考说法'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
result.assisted
|
||||
? '用过提示的项目会在明天换题再练一次。'
|
||||
: '本地检查确认用上了目标表达;下次会在更长的间隔后再检查。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: remaining > 0 ? '下一题' : '完成今天的复习',
|
||||
onPressed: () => setState(() => lastResult = null),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleVoice() async {
|
||||
@@ -94,6 +223,8 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
controller.text = text;
|
||||
transcript = text;
|
||||
validationMessage = null;
|
||||
aiFeedback = null;
|
||||
aiFeedbackError = null;
|
||||
},
|
||||
);
|
||||
return;
|
||||
@@ -134,8 +265,8 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
}
|
||||
|
||||
Future<void> _generateAdaptiveLesson(ReviewItem item) async {
|
||||
final label = a0CoreItems[item.id];
|
||||
if (label == null) return;
|
||||
if (!isCoreItem(item.id)) return;
|
||||
final label = coreItemEnglish(item.id);
|
||||
setState(() => generatingLesson = true);
|
||||
final lesson = await AiService.instance.generateAdaptiveLesson(
|
||||
provider: widget.state.aiProvider,
|
||||
@@ -176,6 +307,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (lastResult case final result?) return _resultView(result);
|
||||
final item = widget.state.dueReviews.isEmpty
|
||||
? null
|
||||
: widget.state.dueReviews.first;
|
||||
@@ -195,7 +327,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
);
|
||||
}
|
||||
final dictation = item.skill == dictationSkill
|
||||
? a0DictationSentences[item.id]
|
||||
? coreDictationSentences[item.id]
|
||||
: null;
|
||||
final spokenTask = item.skill == spokenRecallSkill;
|
||||
final audioText = reviewAudioText(item.id, item.target);
|
||||
@@ -277,7 +409,11 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
enabled: dictation == null || dictationPlayed,
|
||||
onChanged: (_) => setState(() {}),
|
||||
onChanged: (_) => setState(() {
|
||||
aiFeedback = null;
|
||||
aiFeedbackError = null;
|
||||
dictationDiff = null;
|
||||
}),
|
||||
decoration: InputDecoration(
|
||||
hintText: dictation != null
|
||||
? (dictationPlayed ? '写下你听到的英文' : '先播放句子')
|
||||
@@ -336,7 +472,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
? null
|
||||
: () => _generateVariant(item),
|
||||
),
|
||||
if (a0CoreItems.containsKey(item.id))
|
||||
if (isCoreItem(item.id))
|
||||
ActionChip(
|
||||
label: Text(generatingLesson ? '审核补练中…' : '生成四技能补练'),
|
||||
onPressed: generatingLesson
|
||||
@@ -372,11 +508,56 @@ 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 (aiFeedback != null)
|
||||
SectionCard(
|
||||
tint: aiFeedback!.verdict == 'accepted'
|
||||
? AppColors.softGreen
|
||||
: AppColors.warm,
|
||||
child: SpacedColumn(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Text('AI 检查:${aiFeedback!.feedback}'),
|
||||
for (final note in aiFeedback!.missing) Text('· $note'),
|
||||
if (aiFeedback!.suggestion != null)
|
||||
Text('参考改正:${aiFeedback!.suggestion}'),
|
||||
Text(
|
||||
aiFeedback!.verdict == 'accepted'
|
||||
? 'AI 仅作参考;是否完成仍由本地检查判断。'
|
||||
: '看过改正后再提交,本次会记为带提示完成。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (aiFeedbackError != null)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
aiFeedbackError!,
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
if (validationMessage != null)
|
||||
Text(
|
||||
validationMessage!,
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
if (dictationDiff case final diff?) _DictationDiffCard(diff: diff),
|
||||
PrimaryButton(
|
||||
label: usedHelp ? '带提示完成' : '我能独立回答',
|
||||
onPressed: controller.text.trim().isEmpty
|
||||
@@ -393,6 +574,47 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows which dictated words were caught without giving the missed ones
|
||||
/// away: missed words become blanks and wrong words are listed.
|
||||
class _DictationDiffCard extends StatelessWidget {
|
||||
const _DictationDiffCard({required this.diff});
|
||||
final SpokenComparison diff;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: SpacedColumn(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
'写对 ${diff.heardCount}/${diff.total} 个词;空格处是漏写或写错的词:',
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final word in diff.words)
|
||||
Text(
|
||||
word.heard ? word.text : '__',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: word.heard ? AppColors.green : AppColors.warmInk,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (diff.extraWords.isNotEmpty)
|
||||
Text(
|
||||
'句子里没有这些词(可能拼错了):${diff.extraWords.join('、')}',
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Displays an audited AI-authored reinforcement lesson. It remains a
|
||||
/// teaching activity: the existing local review and assessment systems own
|
||||
/// all mastery decisions.
|
||||
@@ -415,6 +637,15 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
int index = 0;
|
||||
bool showReference = false;
|
||||
String? answerFeedback;
|
||||
|
||||
/// The answer passed the local check; the next tap records it.
|
||||
bool answerAccepted = false;
|
||||
bool checkingWithAi = false;
|
||||
WritingAiFeedback? aiCheck;
|
||||
String? aiCheckError;
|
||||
|
||||
/// A shown AI correction is help, so this task is recorded as assisted.
|
||||
bool aiCorrected = false;
|
||||
bool usedVoice = false;
|
||||
bool transcriptEdited = false;
|
||||
bool transcriptConfirmed = false;
|
||||
@@ -464,16 +695,25 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
}
|
||||
|
||||
void _advance(GeneratedLesson lesson, GeneratedLessonTask task) {
|
||||
final correct = matchesAdaptiveLessonAnswer(task, controller.text);
|
||||
final shortfall = adaptiveAnswerShortfall(task, controller.text);
|
||||
final correct = shortfall == null;
|
||||
if (!showReference && !correct) {
|
||||
setState(() => answerFeedback = '还缺少目标表达中的关键信息。可以重试,或查看参考后以教学模式继续。');
|
||||
setState(() => answerFeedback = '$shortfall 可以重试,或查看参考后以教学模式继续。');
|
||||
return;
|
||||
}
|
||||
// Show the verdict before moving on, so the learner sees it.
|
||||
if (!answerAccepted) {
|
||||
setState(() {
|
||||
answerAccepted = true;
|
||||
answerFeedback = correct ? null : '$shortfall 已查看参考,本项按教学练习继续。';
|
||||
});
|
||||
return;
|
||||
}
|
||||
widget.state.recordAdaptiveLessonTask(
|
||||
lesson: lesson,
|
||||
task: task,
|
||||
rawAnswer: controller.text.trim(),
|
||||
assisted: showReference,
|
||||
assisted: showReference || aiCorrected,
|
||||
correct: correct,
|
||||
inputMode: usedVoice && !transcriptEdited && transcriptConfirmed
|
||||
? 'speechToText'
|
||||
@@ -504,6 +744,8 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
controller.clear();
|
||||
showReference = false;
|
||||
answerFeedback = null;
|
||||
_clearChecks();
|
||||
aiCorrected = false;
|
||||
usedVoice = false;
|
||||
transcriptEdited = false;
|
||||
transcriptConfirmed = false;
|
||||
@@ -513,6 +755,49 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
});
|
||||
}
|
||||
|
||||
void _clearChecks() {
|
||||
answerAccepted = false;
|
||||
aiCheck = null;
|
||||
aiCheckError = null;
|
||||
}
|
||||
|
||||
Future<void> _checkWithAi(GeneratedLessonTask task) async {
|
||||
final answer = controller.text.trim();
|
||||
if (answer.isEmpty || checkingWithAi) return;
|
||||
if (widget.state.aiProvider == AiProviderType.mock) {
|
||||
setState(() => aiCheckError = '请先在“我的”配置 AI 服务;本地检查仍可继续补练。');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
checkingWithAi = true;
|
||||
aiCheckError = null;
|
||||
});
|
||||
final feedback = await AiService.instance.answerFeedback(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
answerId: task.taskId,
|
||||
target: task.answer,
|
||||
taskPrompt: '${task.prompt} ${task.stimulus}',
|
||||
answer: answer,
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
setState(() {
|
||||
checkingWithAi = false;
|
||||
aiCheck = feedback;
|
||||
aiCheckError = feedback == null
|
||||
? '暂时无法获得 AI 检查结果。你的答案保留在这里,可稍后重试或直接继续。'
|
||||
: null;
|
||||
if (feedback != null && feedback.verdict != 'accepted') {
|
||||
aiCorrected = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
AppState get voiceState => widget.state;
|
||||
|
||||
@@ -526,6 +811,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
transcriptEdited = false;
|
||||
transcriptConfirmed = false;
|
||||
lastTranscript = text;
|
||||
_clearChecks();
|
||||
},
|
||||
afterTranscribe: () {
|
||||
final lesson = widget.state.cachedAdaptiveLesson;
|
||||
@@ -654,7 +940,10 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
transcriptConfirmed = false;
|
||||
}
|
||||
_saveDraft(lesson);
|
||||
setState(() {});
|
||||
setState(() {
|
||||
_clearChecks();
|
||||
answerFeedback = null;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入或说出你的答案后,再继续',
|
||||
@@ -727,10 +1016,73 @@ 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 (aiCheck != null)
|
||||
SectionCard(
|
||||
tint: aiCheck!.verdict == 'accepted'
|
||||
? AppColors.softGreen
|
||||
: AppColors.warm,
|
||||
child: SpacedColumn(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Text('AI 检查:${aiCheck!.feedback}'),
|
||||
for (final note in aiCheck!.missing) Text('· $note'),
|
||||
if (aiCheck!.suggestion != null)
|
||||
Text('参考改正:${aiCheck!.suggestion}'),
|
||||
Text(
|
||||
aiCheck!.verdict == 'accepted'
|
||||
? 'AI 仅作参考;是否正确仍由本地检查判断。'
|
||||
: '看过改正后再继续,本项会记为带提示完成。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (aiCheckError != null)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
aiCheckError!,
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
if (answerFeedback != null)
|
||||
Text(
|
||||
answerFeedback!,
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
Text(answerFeedback!, style: TextStyle(color: AppColors.warmInk)),
|
||||
if (answerAccepted && answerFeedback == null)
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: SpacedColumn(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: AppColors.green),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
showReference || aiCorrected ? '正确(带提示完成)' : '答对了!',
|
||||
style: TextStyle(
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text('参考表达:${task.answer}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showReference)
|
||||
SectionCard(
|
||||
@@ -757,7 +1109,11 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
label: const Text('内容有问题'),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: index == lesson.tasks.length - 1 ? '完成补练' : '下一项',
|
||||
label: !answerAccepted
|
||||
? '检查答案'
|
||||
: index == lesson.tasks.length - 1
|
||||
? '完成补练'
|
||||
: '下一项',
|
||||
onPressed: controller.text.trim().isNotEmpty
|
||||
? () => _advance(lesson, task)
|
||||
: null,
|
||||
|
||||
Reference in New Issue
Block a user