- 独立尝试按课程要求校验内容,不再任意输入即算独立成功 - 复习节点改为学后第 1/3/7/14 天,之后每 30 天抽查;复习轮换情境 - 掌握状态按作答记录推导:需认识、可回忆、跨情境使用及最近两次无帮助 - 课程证据按实际用到的目标记录;跟读与课程对话只算辅助练习 - 复习成功不再降低已有状态 - 学完课程后进入下一节未完成课程;全部学完后引导巩固与阶段评估 - 新增 3 题基础定位,按结果设置起始课程 - 新增按课程开放的对话场景,首页推荐最少练习的场景 - 切换课程时清空上一课的步骤进度 - AI 返回 JSON 格式并按 JSON 回放历史;拼写校验忽略大小写与连字符;移除学习目标选择 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1041 lines
33 KiB
Dart
1041 lines
33 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
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/voice_service.dart';
|
|
import '../../widgets/app_widgets.dart';
|
|
import '../../widgets/lexicon_lookup.dart';
|
|
import '../../widgets/voice_answer.dart';
|
|
|
|
class DialogueScenePage extends StatelessWidget {
|
|
const DialogueScenePage({
|
|
super.key,
|
|
required this.state,
|
|
required this.onStart,
|
|
this.onBack,
|
|
});
|
|
final AppState state;
|
|
final ValueChanged<String> onStart;
|
|
final VoidCallback? onBack;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final recommended = state.recommendedScene;
|
|
return AppPage(
|
|
appBar: onBack != null
|
|
? AppBar(
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
tooltip: "返回",
|
|
onPressed: onBack,
|
|
),
|
|
title: const Text("AI 情境对话"),
|
|
)
|
|
: null,
|
|
child: SpacedColumn(
|
|
children: [
|
|
const Eyebrow('按当前水平推荐'),
|
|
Text(
|
|
'选一个场景,开口练习。',
|
|
style: Theme.of(context).textTheme.headlineMedium,
|
|
),
|
|
Text(
|
|
'每轮 4 次回答,完成明确任务后结束。',
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
for (final scene in a0Scenes)
|
|
if (state.isSceneUnlocked(scene))
|
|
_OpenScene(
|
|
scene: scene,
|
|
recommended: scene.id == recommended.id,
|
|
onStart: () => onStart(scene.id),
|
|
)
|
|
else
|
|
_LockedScene(
|
|
title: scene.title,
|
|
note:
|
|
'A0 · 学完第 ${lessonById(scene.unlockAfterLessonId!).number} 课后开放',
|
|
),
|
|
const _LockedScene(title: '咖啡店点单', note: 'A1 · 后续版本开放'),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _OpenScene extends StatelessWidget {
|
|
const _OpenScene({
|
|
required this.scene,
|
|
required this.recommended,
|
|
required this.onStart,
|
|
});
|
|
final DialogueScene scene;
|
|
final bool recommended;
|
|
final VoidCallback onStart;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => SectionCard(
|
|
tint: recommended ? AppColors.softGreen : null,
|
|
child: SpacedColumn(
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
scene.title,
|
|
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
|
),
|
|
Text(
|
|
recommended ? 'A0 · 推荐' : 'A0',
|
|
style: const TextStyle(color: AppColors.green),
|
|
),
|
|
],
|
|
),
|
|
Text(scene.summary, style: Theme.of(context).textTheme.bodyMedium),
|
|
recommended
|
|
? PrimaryButton(label: '开始对话', onPressed: onStart)
|
|
: SecondaryButton(label: '开始对话', onPressed: onStart),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class _LockedScene extends StatelessWidget {
|
|
const _LockedScene({required this.title, required this.note});
|
|
final String title;
|
|
final String note;
|
|
@override
|
|
Widget build(BuildContext context) => SectionCard(
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.lock_outline, color: AppColors.muted),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
|
|
Text(note, style: Theme.of(context).textTheme.bodyMedium),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class DialoguePage extends StatefulWidget {
|
|
const DialoguePage({
|
|
super.key,
|
|
required this.state,
|
|
required this.onFinished,
|
|
this.isLessonDialogue = false,
|
|
this.sceneId = 'a0-meet',
|
|
});
|
|
final AppState state;
|
|
final ValueChanged<DialogueSummaryData?> onFinished;
|
|
final bool isLessonDialogue;
|
|
|
|
/// The free scene to run when this is not a lesson dialogue.
|
|
final String sceneId;
|
|
@override
|
|
State<DialoguePage> createState() => _DialoguePageState();
|
|
}
|
|
|
|
class _DialoguePageState extends State<DialoguePage>
|
|
with VoiceAnswerMixin<DialoguePage> {
|
|
final controller = TextEditingController();
|
|
final ScrollController _scrollController = ScrollController();
|
|
final List<DialogueTurn> turns = [];
|
|
int stage = 0;
|
|
bool usedHelp = false;
|
|
String? hint;
|
|
bool usedVoice = false;
|
|
bool transcriptEdited = false;
|
|
String lastTranscript = '';
|
|
bool waitingForReply = false;
|
|
String? validationError;
|
|
|
|
/// 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.
|
|
String? aiNotice;
|
|
|
|
/// The AI's short Chinese comment on the learner's English. It is kept until
|
|
/// the dialogue ends: the spec forbids interrupting a beginner turn by turn.
|
|
String? latestFeedback;
|
|
|
|
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;
|
|
|
|
/// Each free scene stores its draft under its own id.
|
|
String get _sceneDraftId => 'scene-${_scene.id}';
|
|
|
|
/// Closing line for the turn after the last scripted prompt. It stays inside
|
|
/// taught A0 language instead of the old "Wonderful — nice meeting you!".
|
|
static const _closingLine = 'Bye! Nice to meet you.';
|
|
static const _closingTranslation = '再见!很高兴认识你。';
|
|
|
|
final Set<int> _shownTranslations = <int>{};
|
|
|
|
LessonDialogue get script => widget.isLessonDialogue
|
|
? dialogueBySegmentId(
|
|
lessonById(widget.state.activeLessonId)
|
|
.segments[widget.state.activeSegmentIndexFor(
|
|
widget.state.activeLessonId,
|
|
)]
|
|
.id,
|
|
widget.state.activeLessonId,
|
|
)
|
|
: _scene.script;
|
|
|
|
/// Only exact sentence matches are trusted. The old positional fallback
|
|
/// attached `script.translations[stage]` to whatever the AI happened to say,
|
|
/// which produced Chinese that did not match the English on screen.
|
|
String? _resolveTranslationFor(String text) {
|
|
final cleanText = text.trim();
|
|
// 1. Check current script prompts
|
|
for (var i = 0; i < script.prompts.length; i++) {
|
|
if (script.prompts[i].trim().toLowerCase() == cleanText.toLowerCase()) {
|
|
if (i < script.translations.length) {
|
|
return script.translations[i];
|
|
}
|
|
}
|
|
}
|
|
// 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) {
|
|
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];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// 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
|
|
if (cleanText == _closingLine) return _closingTranslation;
|
|
if (cleanText.toLowerCase().contains("wonderful") &&
|
|
cleanText.toLowerCase().contains("nice meeting you")) {
|
|
return "太棒了 — 很高兴认识你!";
|
|
}
|
|
if (cleanText.toLowerCase().contains("goodbye") ||
|
|
cleanText.toLowerCase().contains("bye")) {
|
|
return "再见!";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final draft = widget.isLessonDialogue
|
|
? widget.state.dialogueDraft
|
|
: widget.state.sceneDialogueDraft;
|
|
final expectedDraftId = widget.isLessonDialogue
|
|
? widget.state.activeLessonId
|
|
: _sceneDraftId;
|
|
final canRestore =
|
|
draft != null &&
|
|
draft.lessonId == expectedDraftId &&
|
|
draft.stage >= 0 &&
|
|
draft.stage <= script.prompts.length &&
|
|
draft.turns.isNotEmpty;
|
|
if (canRestore) {
|
|
stage = draft.stage;
|
|
usedHelp = draft.usedHelp;
|
|
for (var i = 0; i < draft.turns.length; i++) {
|
|
final t = draft.turns[i];
|
|
if (!t.isLearner && (t.translation == null || t.translation!.isEmpty)) {
|
|
final trans = _resolveTranslationFor(t.text);
|
|
turns.add(t.copyWith(translation: trans));
|
|
} else {
|
|
turns.add(t);
|
|
}
|
|
}
|
|
} else {
|
|
final initialPrompt = script.prompts.first;
|
|
final initialTranslation =
|
|
script.translations.firstOrNull ??
|
|
_resolveTranslationFor(initialPrompt);
|
|
turns.add(
|
|
DialogueTurn(
|
|
text: initialPrompt,
|
|
isLearner: false,
|
|
translation: initialTranslation,
|
|
),
|
|
);
|
|
}
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
_playLatestAi(slow: false);
|
|
_scrollToBottom();
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
VoiceService.instance.stopSpeaking();
|
|
VoiceService.instance.stopListening();
|
|
disposeVoiceAnswer(keepRecording: widget.state.keepRecordings);
|
|
_scrollController.dispose();
|
|
controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _scrollToBottom() {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (_scrollController.hasClients) {
|
|
_scrollController.animateTo(
|
|
_scrollController.position.maxScrollExtent,
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> send() async {
|
|
final text = controller.text.trim();
|
|
if (text.isEmpty || stage >= script.prompts.length || waitingForReply) {
|
|
return;
|
|
}
|
|
if (!_matchesCurrentTask(text)) {
|
|
setState(
|
|
() => validationError =
|
|
'这一轮要“${_currentTaskLabel()}”,这句还没做到。'
|
|
'可以点“提示”看示范,再补充一次。',
|
|
);
|
|
return;
|
|
}
|
|
widget.state.recordDialogueAttempt(
|
|
taskId: widget.isLessonDialogue
|
|
? 'dialogue-$_lessonSegmentId-$stage'
|
|
: 'dialogue-scene-${_scene.id}-$stage',
|
|
sceneId: widget.isLessonDialogue ? null : _scene.id,
|
|
rawAnswer: text,
|
|
assisted: usedHelp,
|
|
spoken: usedVoice && !transcriptEdited,
|
|
recordingPath: widget.state.keepRecordings ? recordingPath : null,
|
|
);
|
|
if (!widget.state.keepRecordings) {
|
|
VoiceService.instance.deleteRecording(recordingPath);
|
|
recordingPath = null;
|
|
}
|
|
final nextStage = stage + 1;
|
|
setState(() {
|
|
turns.add(DialogueTurn(text: text, isLearner: true));
|
|
controller.clear();
|
|
stage = nextStage;
|
|
waitingForReply = true;
|
|
hint = null;
|
|
validationError = null;
|
|
});
|
|
_saveDraft();
|
|
_scrollToBottom();
|
|
final aiResponse = await AiService.instance.dialogueReply(
|
|
provider: widget.state.aiProvider,
|
|
endpoint: widget.state.aiEndpoint,
|
|
model: widget.state.aiModel,
|
|
aiGoal: nextStage < script.prompts.length
|
|
? script.prompts[nextStage]
|
|
: 'Say goodbye warmly and end the conversation.',
|
|
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),
|
|
history: turns
|
|
.map(
|
|
(turn) => <String, String>{
|
|
'role': turn.isLearner ? 'user' : 'assistant',
|
|
'content': turn.text,
|
|
},
|
|
)
|
|
.toList(),
|
|
);
|
|
if (!mounted) return;
|
|
final replyText =
|
|
aiResponse?.reply ??
|
|
(nextStage < script.prompts.length
|
|
? script.prompts[nextStage]
|
|
: _closingLine);
|
|
var replyTranslation = aiResponse?.translation;
|
|
if (replyTranslation == null || replyTranslation.isEmpty) {
|
|
replyTranslation = _resolveTranslationFor(replyText);
|
|
}
|
|
setState(() {
|
|
turns.add(
|
|
DialogueTurn(
|
|
text: replyText,
|
|
isLearner: false,
|
|
translation: replyTranslation,
|
|
),
|
|
);
|
|
waitingForReply = false;
|
|
final feedback = aiResponse?.feedback;
|
|
if (feedback != null && feedback.trim().isNotEmpty) {
|
|
latestFeedback = feedback.trim();
|
|
}
|
|
aiNotice = aiResponse != null
|
|
? null
|
|
: (widget.state.aiProvider == AiProviderType.mock
|
|
? '当前未连接 AI,正在按示范脚本对话。'
|
|
: 'AI 暂时无法连接,这一句来自示范脚本。');
|
|
});
|
|
_saveDraft();
|
|
_scrollToBottom();
|
|
VoiceService.instance.speak(replyText);
|
|
}
|
|
|
|
void _saveDraft() {
|
|
final draft = DialogueDraft(
|
|
lessonId: widget.isLessonDialogue
|
|
? widget.state.activeLessonId
|
|
: _sceneDraftId,
|
|
stage: stage,
|
|
turns: List.unmodifiable(turns),
|
|
usedHelp: usedHelp,
|
|
);
|
|
if (widget.isLessonDialogue) {
|
|
widget.state.saveDialogueDraft(draft);
|
|
} else {
|
|
widget.state.saveSceneDialogueDraft(draft);
|
|
}
|
|
}
|
|
|
|
String _currentTaskLabel() => dialogueTaskLabel(script, stage);
|
|
|
|
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
|
|
// sentence, so an off-task answer passed every stage.
|
|
if (widget.isLessonDialogue &&
|
|
lessonById(widget.state.activeLessonId).segments.length > 1) {
|
|
return matchesSegmentDialogue(_lessonSegmentId, stage, response);
|
|
}
|
|
return matchesDialogueStage(script, stage, response);
|
|
}
|
|
|
|
String get _lessonSegmentId => lessonById(widget.state.activeLessonId)
|
|
.segments[widget.state.activeSegmentIndexFor(widget.state.activeLessonId)]
|
|
.id;
|
|
|
|
void _finish() {
|
|
if (widget.isLessonDialogue) {
|
|
widget.state.clearDialogueDraft();
|
|
widget.state.completeLessonDialogue();
|
|
widget.onFinished(null);
|
|
return;
|
|
}
|
|
widget.state.clearSceneDialogueDraft();
|
|
final learnerTurns = turns.where((turn) => turn.isLearner).toList();
|
|
final personalSentence = learnerTurns.isEmpty
|
|
? 'My name is …'
|
|
: learnerTurns.first.text;
|
|
widget.state.addDialogueRecap(personalSentence, sceneId: _scene.id);
|
|
widget.onFinished(
|
|
DialogueSummaryData(
|
|
// Only the turns the learner actually passed are reported.
|
|
completedTasks: _completedTaskLabels(),
|
|
personalSentence: personalSentence,
|
|
usedHelp: usedHelp,
|
|
improvement: latestFeedback,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// A learner turn is only added after it passes [_matchesCurrentTask], so the
|
|
/// number of learner turns is the number of tasks actually completed.
|
|
List<String> _completedTaskLabels() {
|
|
final done = turns.where((turn) => turn.isLearner).length;
|
|
return [
|
|
for (var i = 0; i < done && i < script.taskLabels.length; i++)
|
|
script.taskLabels[i],
|
|
];
|
|
}
|
|
|
|
Future<void> _toggleTurnTranslation(int index) async {
|
|
if (index < 0 || index >= turns.length) return;
|
|
final turn = turns[index];
|
|
if (turn.isLearner) return;
|
|
|
|
if (_shownTranslations.contains(index)) {
|
|
setState(() {
|
|
_shownTranslations.remove(index);
|
|
});
|
|
return;
|
|
}
|
|
|
|
String? trans = turn.translation;
|
|
if (trans == null || trans.isEmpty) {
|
|
trans = _resolveTranslationFor(turn.text);
|
|
}
|
|
|
|
if (trans != null && trans.isNotEmpty) {
|
|
setState(() {
|
|
turns[index] = turn.copyWith(translation: trans);
|
|
_shownTranslations.add(index);
|
|
});
|
|
_saveDraft();
|
|
_scrollToBottom();
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_shownTranslations.add(index);
|
|
turns[index] = turn.copyWith(translation: "正在翻译…");
|
|
});
|
|
final fetched = await AiService.instance.temporaryDefinition(
|
|
provider: widget.state.aiProvider,
|
|
endpoint: widget.state.aiEndpoint,
|
|
model: widget.state.aiModel,
|
|
text: turn.text,
|
|
);
|
|
if (!mounted) return;
|
|
final finalTrans = (fetched != null && fetched.isNotEmpty)
|
|
? fetched
|
|
: "暂无该句中文翻译";
|
|
setState(() {
|
|
turns[index] = turn.copyWith(translation: finalTrans);
|
|
});
|
|
_saveDraft();
|
|
_scrollToBottom();
|
|
}
|
|
|
|
Future<void> _showLatestAiTranslation() async {
|
|
final latestAiIndex = turns.lastIndexWhere((turn) => !turn.isLearner);
|
|
if (latestAiIndex == -1) return;
|
|
final latestAi = turns[latestAiIndex];
|
|
|
|
String? trans = latestAi.translation;
|
|
if (trans == null || trans.isEmpty) {
|
|
trans = _resolveTranslationFor(latestAi.text);
|
|
}
|
|
|
|
if (trans != null && trans.isNotEmpty) {
|
|
setState(() {
|
|
usedHelp = true;
|
|
hint = "对方说:$trans";
|
|
_shownTranslations.add(latestAiIndex);
|
|
turns[latestAiIndex] = latestAi.copyWith(translation: trans);
|
|
});
|
|
_saveDraft();
|
|
_scrollToBottom();
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
usedHelp = true;
|
|
hint = "正在获取对方英文翻译…";
|
|
_shownTranslations.add(latestAiIndex);
|
|
});
|
|
|
|
final fetched = await AiService.instance.temporaryDefinition(
|
|
provider: widget.state.aiProvider,
|
|
endpoint: widget.state.aiEndpoint,
|
|
model: widget.state.aiModel,
|
|
text: latestAi.text,
|
|
);
|
|
if (!mounted) return;
|
|
final finalTrans = (fetched != null && fetched.isNotEmpty)
|
|
? fetched
|
|
: "暂无该句中文翻译";
|
|
setState(() {
|
|
hint = "对方说:$finalTrans";
|
|
turns[latestAiIndex] = latestAi.copyWith(translation: finalTrans);
|
|
});
|
|
_saveDraft();
|
|
_scrollToBottom();
|
|
}
|
|
|
|
Future<void> _playLatestAi({required bool slow}) async {
|
|
final latest = turns.where((turn) => !turn.isLearner).lastOrNull;
|
|
if (latest == null) return;
|
|
await VoiceService.instance.speak(latest.text, slow: slow);
|
|
if (!slow || !mounted) return;
|
|
setState(() => usedHelp = true);
|
|
_saveDraft();
|
|
}
|
|
|
|
@override
|
|
AppState get voiceState => widget.state;
|
|
|
|
Future<void> _toggleListening() async {
|
|
if (aiVoiceRecording) {
|
|
await finishVoiceInput(
|
|
onTranscript: (text) {
|
|
controller.text = text;
|
|
usedVoice = true;
|
|
lastTranscript = text;
|
|
transcriptEdited = false;
|
|
},
|
|
afterTranscribe: _scrollToBottom,
|
|
);
|
|
return;
|
|
}
|
|
await startVoiceInput();
|
|
}
|
|
|
|
void _showHint() {
|
|
setState(() {
|
|
usedHelp = true;
|
|
final hintIdx = stage < script.hints.length
|
|
? stage
|
|
: (script.hints.isNotEmpty ? script.hints.length - 1 : 0);
|
|
hint = script.hints.isNotEmpty ? script.hints[hintIdx] : null;
|
|
});
|
|
_saveDraft();
|
|
_scrollToBottom();
|
|
}
|
|
|
|
void _showWord() => showLexiconLookup(
|
|
context,
|
|
state: widget.state,
|
|
initialText: turns.where((turn) => !turn.isLearner).lastOrNull?.text ?? '',
|
|
);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final finished = stage == script.prompts.length;
|
|
final totalStages = script.prompts.length;
|
|
return AppPage(
|
|
scrollController: _scrollController,
|
|
appBar: AppBar(
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
tooltip: "返回",
|
|
onPressed: () => widget.onFinished(null),
|
|
),
|
|
title: Text(
|
|
'${widget.isLessonDialogue ? '课程对话' : _scene.title} · ${finished ? totalStages : stage + 1} / $totalStages',
|
|
),
|
|
),
|
|
child: SpacedColumn(
|
|
children: [
|
|
Eyebrow('目标:${script.goal}'),
|
|
Container(
|
|
constraints: const BoxConstraints(minHeight: 250),
|
|
child: ListView.separated(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
itemCount: turns.length,
|
|
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
|
itemBuilder: (context, index) => _TurnBubble(
|
|
turn: turns[index],
|
|
state: widget.state,
|
|
showTranslation: _shownTranslations.contains(index),
|
|
onToggleTranslation: () => _toggleTurnTranslation(index),
|
|
),
|
|
),
|
|
),
|
|
if (!finished) ...[
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
_AssistChip(label: '提示', onTap: _showHint),
|
|
_AssistChip(label: '翻译', onTap: _showLatestAiTranslation),
|
|
_AssistChip(
|
|
label: '慢一点',
|
|
onTap: () => _playLatestAi(slow: true),
|
|
),
|
|
_AssistChip(
|
|
label: '重说',
|
|
onTap: () => _playLatestAi(slow: false),
|
|
),
|
|
_AssistChip(label: '查词', onTap: _showWord),
|
|
],
|
|
),
|
|
if (hint != null)
|
|
SectionCard(
|
|
tint: AppColors.warm,
|
|
child: Text(
|
|
hint!,
|
|
style: const TextStyle(color: AppColors.warmInk),
|
|
),
|
|
),
|
|
if (aiNotice != null)
|
|
SectionCard(
|
|
tint: AppColors.warm,
|
|
child: Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.cloud_off_outlined,
|
|
size: 18,
|
|
color: AppColors.warmInk,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
aiNotice!,
|
|
style: const TextStyle(
|
|
color: AppColors.warmInk,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (validationError != null)
|
|
Text(
|
|
validationError!,
|
|
style: const TextStyle(color: AppColors.warmInk),
|
|
),
|
|
TextField(
|
|
controller: controller,
|
|
onChanged: (value) => setState(() {
|
|
if (usedVoice && value != lastTranscript) {
|
|
transcriptEdited = true;
|
|
}
|
|
}),
|
|
onSubmitted: (_) => send(),
|
|
decoration: InputDecoration(
|
|
hintText: '输入你的英文回答',
|
|
filled: true,
|
|
fillColor: AppColors.surface,
|
|
prefixIcon: IconButton(
|
|
tooltip: transcribing
|
|
? '正在 AI 识别…'
|
|
: (listening ? '停止录音并识别' : '语音输入'),
|
|
icon: transcribing
|
|
? const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Icon(
|
|
listening ? Icons.stop_circle : Icons.mic_none,
|
|
color: listening ? Colors.redAccent : null,
|
|
),
|
|
onPressed: transcribing ? null : _toggleListening,
|
|
),
|
|
suffixIcon: IconButton(
|
|
icon: const Icon(Icons.send),
|
|
onPressed: waitingForReply ? null : send,
|
|
),
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
if (usedVoice)
|
|
Text(
|
|
transcriptEdited
|
|
? '你修改了设备转写:这轮按文字作答保存。'
|
|
: '这是设备转写;未修改提交后会作为语音尝试保存。',
|
|
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
|
),
|
|
RecordingControls(
|
|
recording: recording,
|
|
playing: playingRecording,
|
|
hasRecording: recordingPath != null,
|
|
onToggleRecording: toggleRecording,
|
|
onPlay: playRecording,
|
|
onDelete: deleteRecording,
|
|
),
|
|
Text(
|
|
widget.state.keepRecordings
|
|
? '录音只保存在本机,不会发送给 AI。'
|
|
: '录音仅供本次回听,离开后自动删除。',
|
|
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
|
),
|
|
const Text(
|
|
'文字输入可完成教学对话;即使使用语音输入,本受控教学对话也不会单独记为独立口语证据。',
|
|
style: TextStyle(color: AppColors.muted, fontSize: 12),
|
|
),
|
|
if (waitingForReply) const LinearProgressIndicator(),
|
|
] else ...[
|
|
SectionCard(
|
|
tint: usedHelp ? AppColors.warm : AppColors.softGreen,
|
|
child: Text(
|
|
usedHelp
|
|
? '本次使用过提示,课程会把关键表达安排到后续复习。'
|
|
: '你完成了 ${_completedTaskLabels().length} 个交际任务,接下来试着不看帮助独立表达。',
|
|
),
|
|
),
|
|
if (latestFeedback != null)
|
|
SectionCard(
|
|
tint: AppColors.warm,
|
|
child: Text(
|
|
'下次可以注意:${latestFeedback!}',
|
|
style: const TextStyle(color: AppColors.warmInk),
|
|
),
|
|
),
|
|
PrimaryButton(
|
|
label: widget.isLessonDialogue ? '进入独立尝试' : '查看总结',
|
|
onPressed: _finish,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TurnBubble extends StatelessWidget {
|
|
const _TurnBubble({
|
|
required this.turn,
|
|
required this.state,
|
|
required this.showTranslation,
|
|
required this.onToggleTranslation,
|
|
});
|
|
|
|
final DialogueTurn turn;
|
|
final AppState state;
|
|
final bool showTranslation;
|
|
final VoidCallback onToggleTranslation;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final translation = turn.translation;
|
|
return Align(
|
|
alignment: turn.isLearner ? Alignment.centerRight : Alignment.centerLeft,
|
|
child: Container(
|
|
constraints: const BoxConstraints(maxWidth: 290),
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: turn.isLearner ? AppColors.warm : AppColors.softGreen,
|
|
borderRadius: BorderRadius.circular(15),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
LexiconText(turn.text, state: state),
|
|
if (!turn.isLearner) ...[
|
|
if (showTranslation &&
|
|
translation != null &&
|
|
translation.isNotEmpty) ...[
|
|
const SizedBox(height: 6),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.6),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text(
|
|
translation,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
color: Color(0xFF2D3748),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 6),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_TurnAction(
|
|
icon: Icons.volume_up_outlined,
|
|
label: '播放',
|
|
onTap: () => VoiceService.instance.speak(turn.text),
|
|
),
|
|
const SizedBox(width: 14),
|
|
_TurnAction(
|
|
icon: showTranslation
|
|
? Icons.translate
|
|
: Icons.translate_outlined,
|
|
label: showTranslation ? '隐藏翻译' : '翻译',
|
|
onTap: onToggleTranslation,
|
|
),
|
|
const SizedBox(width: 14),
|
|
_TurnAction(
|
|
icon: Icons.psychology_alt_outlined,
|
|
label: '句型解析',
|
|
onTap: () => showLexiconLookup(
|
|
context,
|
|
state: state,
|
|
initialText: turn.text,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TurnAction extends StatelessWidget {
|
|
const _TurnAction({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onTap,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => GestureDetector(
|
|
onTap: onTap,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 16, color: AppColors.green),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
label,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppColors.green,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class _AssistChip extends StatelessWidget {
|
|
const _AssistChip({required this.label, required this.onTap});
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
@override
|
|
Widget build(BuildContext context) => ActionChip(
|
|
label: Text(label),
|
|
backgroundColor: AppColors.surface,
|
|
side: const BorderSide(color: AppColors.line),
|
|
onPressed: onTap,
|
|
);
|
|
}
|
|
|
|
class DialogueSummaryPage extends StatelessWidget {
|
|
const DialogueSummaryPage({
|
|
super.key,
|
|
required this.summary,
|
|
required this.onHome,
|
|
required this.onLesson,
|
|
required this.onRetry,
|
|
this.onBack,
|
|
});
|
|
final DialogueSummaryData summary;
|
|
final VoidCallback onHome;
|
|
final VoidCallback onLesson;
|
|
final VoidCallback onRetry;
|
|
final VoidCallback? onBack;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => AppPage(
|
|
appBar: AppBar(
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
tooltip: "返回",
|
|
onPressed: onBack ?? onHome,
|
|
),
|
|
title: const Text("对话完成"),
|
|
),
|
|
child: SpacedColumn(
|
|
children: [
|
|
const Eyebrow('对话完成'),
|
|
Text('你完成了自我介绍!', style: Theme.of(context).textTheme.headlineMedium),
|
|
Text(
|
|
summary.completedTasks.isEmpty
|
|
? '这次还没有完成完整的交际任务,可以再练一次。'
|
|
: '你完成了 ${summary.completedTasks.join('、')}。',
|
|
),
|
|
SectionCard(
|
|
child: _SummaryLine(
|
|
icon: Icons.check_circle_outline,
|
|
title: '你的个人复习卡(明天出现)',
|
|
sentence: summary.personalSentence,
|
|
tint: AppColors.green,
|
|
),
|
|
),
|
|
if (summary.improvement != null && summary.improvement!.isNotEmpty)
|
|
SectionCard(
|
|
tint: AppColors.warm,
|
|
child: _SummaryLine(
|
|
icon: Icons.tips_and_updates_outlined,
|
|
title: '下一次说得更好',
|
|
sentence: summary.improvement!,
|
|
tint: AppColors.warmInk,
|
|
),
|
|
),
|
|
Text(
|
|
summary.usedHelp
|
|
? '本次使用过提示。下次可以先不看提示,再试一次。'
|
|
: '下次试着换一个名字、地点或喜好,再完成同一任务。',
|
|
),
|
|
SecondaryButton(label: '再练一次初次见面', onPressed: onRetry),
|
|
PrimaryButton(label: '开始一节课程', onPressed: onLesson),
|
|
SecondaryButton(label: '回到首页', onPressed: onHome),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class _SummaryLine extends StatelessWidget {
|
|
const _SummaryLine({
|
|
required this.icon,
|
|
required this.title,
|
|
required this.sentence,
|
|
required this.tint,
|
|
});
|
|
final IconData icon;
|
|
final String title;
|
|
final String sentence;
|
|
final Color tint;
|
|
@override
|
|
Widget build(BuildContext context) => Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(icon, color: tint),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: TextStyle(color: tint, fontWeight: FontWeight.w600),
|
|
),
|
|
const SizedBox(height: 3),
|
|
Text(sentence),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|