整个课程和数据UI重新设计版本
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user