Files
English/kouyu_english/lib/features/dialogue/dialogue_flow.dart
T
shenleiandClaude Opus 5 7e29c5449f feat: 重做阅读找答案题与 AI 情境对话,并归拢本地识别、查词等既有改动
阅读"在对话里找到答案":
- 16 道题全部重写,干扰项真实出现在对话里,靠说话人归属或否定句才能作答
- 选项按题目内容确定性打乱,答案不再固定排第一;听力环节同样处理
- 去掉超纲干扰项、重复题干,收紧自由作答匹配(过去单个字母也能判对)

AI 情境对话:
- 提示词区分"AI 这一句要做什么"与"学习者随后要完成什么",并下发已教词句清单
- JSON 只强制 reply,translation/feedback 可选;不再索要用不上的 slots/evidence
- AI 不可用时页面明确提示当前回复来自内置示范脚本
- 删掉按 stage 下标猜中文翻译的兜底,避免译文与英文对不上
- 整课对话改用逐轮必需表达校验,替换"关键词沾边就算过";修正自由场景正则误伤
- 总结的"完成任务"按实际通过的轮次生成;模型点评只在结束页呈现一次
- 自由场景支持草稿续练(独立存储槽);修正回答轮数文案与永不解锁的场景标注

同时提交此前工作区中累积的改动:SenseVoice 本地识别、查词/句型解析卡、
复习与测评页调整等,并补充对话校验、选项分布和句子解析的测试。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:38:23 +09:00

1099 lines
38 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';
class DialogueScenePage extends StatelessWidget {
const DialogueScenePage({
super.key,
required this.onStart,
this.onBack,
});
final VoidCallback onStart;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) => 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,
),
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
children: [
const Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'初次见面',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
Text('A0', style: TextStyle(color: AppColors.green)),
],
),
Text(
'介绍姓名、地点、状态或喜好,并反问对方',
style: Theme.of(context).textTheme.bodyMedium,
),
PrimaryButton(label: '开始对话', onPressed: onStart),
],
),
),
const _LockedScene(title: '认识新同学', note: 'A0 · 后续版本开放'),
const _LockedScene(title: '咖啡店', note: 'A1 · 后续版本开放'),
],
),
);
}
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,
});
final AppState state;
final ValueChanged<DialogueSummaryData?> onFinished;
final bool isLessonDialogue;
@override
State<DialoguePage> createState() => _DialoguePageState();
}
class _DialoguePageState extends State<DialoguePage> {
final controller = TextEditingController();
final ScrollController _scrollController = ScrollController();
final List<DialogueTurn> turns = [];
int stage = 0;
bool usedHelp = false;
String? hint;
bool listening = false;
bool recording = false;
bool transcribing = false;
bool aiVoiceRecording = false;
bool playingRecording = false;
bool usedVoice = false;
bool transcriptEdited = false;
String lastTranscript = '';
String? recordingPath;
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;
/// The free scene stores its draft under its own id.
static const _sceneDraftId = 'scene-a0-meet';
/// 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,
)
: a0MeetDialogue;
/// 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();
VoiceService.instance.stopRecordingPlayback();
if (listening || aiVoiceRecording) {
VoiceService.instance.stopRecording();
}
if (!widget.state.keepRecordings) {
VoiceService.instance.deleteRecording(recordingPath);
}
_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:
'dialogue-${widget.isLessonDialogue ? _lessonSegmentId : 'a0-meet'}-$stage',
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)
: allTaughtLanguage,
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);
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();
}
Future<void> _toggleListening() async {
if (listening || aiVoiceRecording) {
final path = await VoiceService.instance.stopRecording();
if (!mounted) return;
setState(() {
aiVoiceRecording = false;
listening = false;
transcribing = true;
recordingPath = path;
});
if (path != null) {
final config = widget.state.aiConfig;
final transcribed = await AiService.instance.transcribeAudio(
filePath: path,
provider: config.provider,
endpoint: config.endpoint,
model: config.model,
);
if (mounted) {
setState(() {
transcribing = false;
if (transcribed != null && transcribed.trim().isNotEmpty) {
controller.text = transcribed.trim();
usedVoice = true;
lastTranscript = transcribed.trim();
transcriptEdited = false;
}
});
_scrollToBottom();
if (transcribed == null || transcribed.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('未识别到清晰语音,请再试一次或使用键盘输入。')),
);
}
}
} else {
if (mounted) setState(() => transcribing = false);
}
return;
}
VoiceService.instance.stopSpeaking();
final recordStarted = await VoiceService.instance.startRecording();
if (!mounted) return;
if (recordStarted) {
setState(() {
aiVoiceRecording = true;
listening = true;
});
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法访问麦克风,请检查手机录音权限。')),
);
}
}
Future<void> _toggleRecording() async {
if (recording) {
final path = await VoiceService.instance.stopRecording();
if (mounted) {
setState(() {
recording = false;
recordingPath = path;
});
}
return;
}
await VoiceService.instance.deleteRecording(recordingPath);
final ready = await VoiceService.instance.startRecording();
if (!mounted) return;
setState(() {
recording = ready;
if (ready) recordingPath = null;
});
if (!ready) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('无法使用麦克风录音;请检查系统权限。')));
}
}
Future<void> _playRecording() async {
final path = recordingPath;
if (path == null) return;
setState(() => playingRecording = true);
await VoiceService.instance.playRecording(
path,
onComplete: () {
if (mounted) setState(() => playingRecording = false);
},
);
}
Future<void> _deleteRecording() async {
await VoiceService.instance.deleteRecording(recordingPath);
if (mounted) setState(() => recordingPath = null);
}
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 ? '课程对话' : '初次见面'} · ${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) {
final turn = turns[index];
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: widget.state),
if (!turn.isLearner) ...[
if (_shownTranslations.contains(index) &&
turn.translation != null &&
turn.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(
turn.translation!,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF2D3748),
),
),
),
],
const SizedBox(height: 6),
Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () =>
VoiceService.instance.speak(turn.text),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.volume_up_outlined,
size: 16,
color: AppColors.green,
),
SizedBox(width: 4),
Text(
"播放",
style: TextStyle(
fontSize: 12,
color: AppColors.green,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(width: 14),
GestureDetector(
onTap: () => _toggleTurnTranslation(index),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_shownTranslations.contains(index)
? Icons.translate
: Icons.translate_outlined,
size: 16,
color: AppColors.green,
),
const SizedBox(width: 4),
Text(
_shownTranslations.contains(index)
? "隐藏翻译"
: "翻译",
style: const TextStyle(
fontSize: 12,
color: AppColors.green,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(width: 14),
GestureDetector(
onTap: () => showLexiconLookup(
context,
state: widget.state,
initialText: turn.text,
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.psychology_alt_outlined,
size: 16,
color: AppColors.green,
),
SizedBox(width: 4),
Text(
"句型解析",
style: TextStyle(
fontSize: 12,
color: AppColors.green,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
],
],
),
),
);
},
),
),
if (!finished) ...[
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_AssistChip(
label: '提示',
onTap: () {
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();
},
),
_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),
),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _toggleRecording,
icon: Icon(
recording
? Icons.stop_circle_outlined
: Icons.fiber_manual_record,
),
label: Text(recording ? '停止录音' : '录音回听'),
),
),
if (recordingPath != null) ...[
IconButton(
tooltip: playingRecording ? '正在播放' : '回听录音',
onPressed: playingRecording ? null : _playRecording,
icon: const Icon(Icons.play_arrow),
),
IconButton(
tooltip: '删除录音',
onPressed: _deleteRecording,
icon: const Icon(Icons.delete_outline),
),
],
],
),
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 _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),
],
),
),
],
);
}