整个课程和数据UI重新设计版本

This commit is contained in:
shenlei
2026-09-18 17:55:44 +09:00
parent febfd30f49
commit 0a4f97d105
154 changed files with 12366 additions and 4784 deletions
@@ -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,