整个课程和数据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
@@ -4,7 +4,7 @@ import '../../core/app_state.dart';
import '../../core/ai_service.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../core/seed_courses.dart';
import '../../core/courses/courses.dart';
import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart';
import '../../widgets/lexicon_lookup.dart';
@@ -46,7 +46,7 @@ class DialogueScenePage extends StatelessWidget {
'每轮 4 次回答,完成明确任务后结束。',
style: Theme.of(context).textTheme.bodyMedium,
),
for (final scene in a0Scenes)
for (final scene in allScenes)
if (state.isSceneUnlocked(scene))
_OpenScene(
scene: scene,
@@ -158,6 +158,9 @@ class _DialoguePageState extends State<DialoguePage>
String lastTranscript = '';
bool waitingForReply = false;
String? validationError;
bool checkingWithAi = false;
WritingAiFeedback? aiCheck;
String? aiCheckError;
/// Shown when the reply on screen came from the built-in script instead of
/// the AI, so a canned line is never mistaken for a real answer.
@@ -169,13 +172,11 @@ class _DialoguePageState extends State<DialoguePage>
DialogueScene get _scene => sceneById(widget.sceneId);
/// A free scene may use everything the learner has finished so far.
String get _furthestCompletedLessonId => a0SeedLessons
.lastWhere(
(lesson) => widget.state.completedLessonIds.contains(lesson.id),
orElse: () => a0SeedLessons.first,
)
.id;
/// The lesson whose language the AI partner speaks: the active lesson, or
/// the unit a free scene belongs to. Null for the always-open A0 scene.
String? get _languageLessonId => widget.isLessonDialogue
? widget.state.activeLessonId
: _scene.unlockAfterLessonId;
/// Each free scene stores its draft under its own id.
String get _sceneDraftId => 'scene-${_scene.id}';
@@ -194,7 +195,6 @@ class _DialoguePageState extends State<DialoguePage>
widget.state.activeLessonId,
)]
.id,
widget.state.activeLessonId,
)
: _scene.script;
@@ -211,17 +211,11 @@ class _DialoguePageState extends State<DialoguePage>
}
}
}
// 2. Check the free scene script
for (var i = 0; i < a0MeetDialogue.prompts.length; i++) {
if (a0MeetDialogue.prompts[i].trim().toLowerCase() ==
cleanText.toLowerCase()) {
if (i < a0MeetDialogue.translations.length) {
return a0MeetDialogue.translations[i];
}
}
}
// 3. Check all lesson dialogues
for (final dialogue in a0Dialogues.values) {
// 2. Check every free scene and lesson dialogue
for (final dialogue in [
for (final scene in allScenes) scene.script,
...segmentDialogues.values,
]) {
for (var i = 0; i < dialogue.prompts.length; i++) {
if (dialogue.prompts[i].trim().toLowerCase() ==
cleanText.toLowerCase()) {
@@ -231,18 +225,7 @@ class _DialoguePageState extends State<DialoguePage>
}
}
}
// 4. Check all segment dialogues
for (final dialogue in a0SegmentDialogues.values) {
for (var i = 0; i < dialogue.prompts.length; i++) {
if (dialogue.prompts[i].trim().toLowerCase() ==
cleanText.toLowerCase()) {
if (i < dialogue.translations.length) {
return dialogue.translations[i];
}
}
}
}
// 5. Common fallback phrases
// 3. Common fallback phrases
if (cleanText == _closingLine) return _closingTranslation;
if (cleanText.toLowerCase().contains("wonderful") &&
cleanText.toLowerCase().contains("nice meeting you")) {
@@ -360,6 +343,8 @@ class _DialoguePageState extends State<DialoguePage>
waitingForReply = true;
hint = null;
validationError = null;
aiCheck = null;
aiCheckError = null;
});
_saveDraft();
_scrollToBottom();
@@ -373,12 +358,11 @@ class _DialoguePageState extends State<DialoguePage>
learnerTask: nextStage < script.hints.length
? 'answer with something like "${script.hints[nextStage]}"'
: 'nothing more, the conversation is finished',
allowedLanguage: widget.isLessonDialogue
? taughtLanguageUpTo(widget.state.activeLessonId)
: _scene.unlockAfterLessonId == null
// The always-open scene mixes every A0 topic, as its script does.
? allTaughtLanguage
: taughtLanguageUpTo(_furthestCompletedLessonId),
level: _languageLessonId == null ? 'A0' : lessonLevel(_languageLessonId!),
// The always-open scene mixes every A0 topic, as its script does.
allowedLanguage: _languageLessonId == null
? a0TaughtLanguage
: taughtLanguageUpTo(_languageLessonId!),
history: turns
.map(
(turn) => <String, String>{
@@ -440,6 +424,54 @@ class _DialoguePageState extends State<DialoguePage>
String _currentTaskLabel() => dialogueTaskLabel(script, stage);
/// Optional spelling and grammar check of the turn before it is sent. It
/// never decides whether the turn passes; [_matchesCurrentTask] does.
Future<void> _checkWithAi() async {
final answer = controller.text.trim();
if (answer.isEmpty || checkingWithAi || stage >= script.prompts.length) {
return;
}
if (widget.state.aiProvider == AiProviderType.mock) {
setState(() => aiCheckError = '请先在“我的”配置 AI 服务;本地检查仍可继续对话。');
return;
}
final checkedStage = stage;
setState(() {
checkingWithAi = true;
aiCheckError = null;
});
final partnerLine = turns.where((turn) => !turn.isLearner).lastOrNull;
final feedback = await AiService.instance.answerFeedback(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
answerId: 'dialogue-$checkedStage',
target: checkedStage < script.hints.length
? script.hints[checkedStage]
: _currentTaskLabel(),
taskPrompt: '对方说:${partnerLine?.text ?? ''} 本轮任务:${_currentTaskLabel()}',
answer: answer,
level: _languageLessonId == null ? 'A0' : lessonLevel(_languageLessonId!),
);
if (!mounted) return;
// Drop a result for a turn already sent or an answer since changed.
if (stage != checkedStage || controller.text.trim() != answer) {
setState(() => checkingWithAi = false);
return;
}
setState(() {
checkingWithAi = false;
aiCheck = feedback;
aiCheckError = feedback == null
? '暂时无法获得 AI 检查结果。你的回答保留在这里,可稍后重试或直接发送。'
: null;
// A shown correction is help, like the hint and translation chips.
if (feedback != null && feedback.verdict != 'accepted') usedHelp = true;
});
_saveDraft();
_scrollToBottom();
}
bool _matchesCurrentTask(String response) {
// Every dialogue now checks the language the turn is teaching. The old
// whole-lesson branch matched bare keywords such as 'it' anywhere in the
@@ -603,6 +635,8 @@ class _DialoguePageState extends State<DialoguePage>
usedVoice = true;
lastTranscript = text;
transcriptEdited = false;
aiCheck = null;
aiCheckError = null;
},
afterTranscribe: _scrollToBottom,
);
@@ -684,10 +718,7 @@ class _DialoguePageState extends State<DialoguePage>
if (hint != null)
SectionCard(
tint: AppColors.warm,
child: Text(
hint!,
style: TextStyle(color: AppColors.warmInk),
),
child: Text(hint!, style: TextStyle(color: AppColors.warmInk)),
),
if (aiNotice != null)
SectionCard(
@@ -723,6 +754,8 @@ class _DialoguePageState extends State<DialoguePage>
if (usedVoice && value != lastTranscript) {
transcriptEdited = true;
}
aiCheck = null;
aiCheckError = null;
}),
onSubmitted: (_) => send(),
decoration: InputDecoration(
@@ -752,6 +785,51 @@ class _DialoguePageState extends State<DialoguePage>
border: const OutlineInputBorder(),
),
),
OutlinedButton.icon(
onPressed:
controller.text.trim().isEmpty ||
checkingWithAi ||
waitingForReply
? null
: _checkWithAi,
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 (usedVoice)
Text(
transcriptEdited