Files
English/kouyu_english/lib/features/dialogue/dialogue_flow.dart
T

830 lines
26 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(
'每次不超过 5 个回答轮,完成明确任务后结束。',
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: '完成当前场景后解锁'),
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 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;
LessonDialogue get script => widget.isLessonDialogue
? dialogueBySegmentId(
lessonById(widget.state.activeLessonId)
.segments[widget.state.activeSegmentIndexFor(
widget.state.activeLessonId,
)]
.id,
widget.state.activeLessonId,
)
: const LessonDialogue(
goal: '姓名、地点、状态或喜好,并反问',
prompts: prompts,
hints: hints,
);
static const prompts = [
'Hi! My name is Mia. Whats your name?',
'Nice to meet you. Where are you from?',
'Great! How are you today? Or what do you like?',
'Im good, thanks. Now ask me one question!',
];
static const hints = [
'My name is Alex.',
'Im from Hong Kong.',
'Im good, thanks. / I like coffee.',
'Whats your name? / Where are you from?',
];
static const translations = [
'嗨!我叫 Mia。你叫什么名字?',
'很高兴认识你。你来自哪里?',
'很好!你今天怎么样?或者你喜欢什么?',
'我很好,谢谢。现在请问我一个问题!',
];
@override
void initState() {
super.initState();
final draft = widget.state.dialogueDraft;
final canRestore =
widget.isLessonDialogue &&
draft?.lessonId == widget.state.activeLessonId &&
draft!.stage >= 0 &&
draft.stage <= script.prompts.length &&
draft.turns.isNotEmpty;
if (canRestore) {
stage = draft.stage;
usedHelp = draft.usedHelp;
turns.addAll(draft.turns);
} else {
turns.add(DialogueTurn(text: script.prompts.first, isLearner: false));
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_playLatestAi(slow: false);
}
});
}
@override
void dispose() {
VoiceService.instance.stopSpeaking();
VoiceService.instance.stopListening();
VoiceService.instance.stopRecordingPlayback();
if (!widget.state.keepRecordings) {
VoiceService.instance.deleteRecording(recordingPath);
}
controller.dispose();
super.dispose();
}
Future<void> send() async {
final text = controller.text.trim();
if (text.isEmpty || stage >= script.prompts.length || waitingForReply) {
return;
}
if (!_matchesCurrentTask(text)) {
setState(() => validationError = '这句还没有完成当前任务。可以查看提示后补充一次。');
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();
final aiResponse = await AiService.instance.dialogueReply(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
requiredTask: nextStage < script.prompts.length
? script.prompts[nextStage]
: 'Say goodbye warmly after the learner asked a question.',
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]
: 'Wonderful — nice meeting you!');
setState(() {
turns.add(
DialogueTurn(
text: replyText,
isLearner: false,
),
);
waitingForReply = false;
});
_saveDraft();
VoiceService.instance.speak(replyText);
}
void _saveDraft() {
if (!widget.isLessonDialogue) return;
widget.state.saveDialogueDraft(
DialogueDraft(
lessonId: widget.state.activeLessonId,
stage: stage,
turns: List.unmodifiable(turns),
usedHelp: usedHelp,
),
);
}
bool _matchesCurrentTask(String response) {
if (!widget.isLessonDialogue) {
final text = response.toLowerCase();
return switch (stage) {
0 => RegExp(r"\b(i'?m|my name is)\s+[a-z]").hasMatch(text),
1 => RegExp(r"\b(i'?m|i am)\s+from\s+[a-z]").hasMatch(text),
2 => RegExp(
r"\b(i'?m|i am)\s+(good|okay|tired)\b|\bi like\s+[a-z]",
).hasMatch(text),
_ => RegExp(r"\b(what('?s| is)|how are|do you like)\b").hasMatch(text),
};
}
final text = response.toLowerCase();
final lessonId = widget.state.activeLessonId;
if (lessonById(lessonId).segments.length > 1) {
return matchesSegmentDialogue(_lessonSegmentId, stage, text);
}
if (stage == 0) {
return response.replaceAll(RegExp(r'[^a-zA-Z]'), '').length >= 2;
}
if (lessonId == 'a0-02' && stage == 1) {
return RegExp(
r'[a-z](?:[ -]?[a-z]){2,}',
caseSensitive: false,
).hasMatch(text);
}
final expected = switch (lessonId) {
'a0-01' => ['nice', 'hello', 'what'],
'a0-03' => ['how', 'good', 'okay', 'tired', 'bye'],
'a0-04' => [
'yes',
'one',
'two',
'three',
'four',
'five',
'six',
'seven',
'eight',
'nine',
'zero',
'what',
],
'a0-05' => ['it', 'what', 'book', 'pen', 'bag', 'key'],
'a0-06' => ['from', 'where', 'bye'],
'a0-07' => ['this', 'who', 'mother', 'father', 'sister', 'brother'],
'a0-08' => [
'it',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
'clock',
'what',
],
'a0-09' => ['like', 'yes', 'no', 'do'],
'a0-10' => ['please', 'what', 'like'],
_ => ['nice', 'how', 'what', 'from', 'like'],
};
return expected.any(text.contains);
}
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;
}
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(
completedTasks: const ['介绍姓名', '说明来自哪里', '表达状态或喜好', '反问对方'],
personalSentence: personalSentence,
usedHelp: usedHelp,
),
);
}
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 (aiVoiceRecording) {
final path = await VoiceService.instance.stopRecording();
if (!mounted) return;
setState(() {
aiVoiceRecording = false;
listening = false;
transcribing = true;
});
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;
}
});
if (transcribed == null || transcribed.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('未识别到清晰语音,请再试一次或使用键盘输入。')),
);
}
}
} else {
if (mounted) setState(() => transcribing = false);
}
return;
}
if (listening) {
await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false);
return;
}
final available = await VoiceService.instance.startListening(
(text, _) {
if (!mounted) return;
setState(() {
controller.text = text;
usedVoice = true;
lastTranscript = text;
transcriptEdited = false;
});
},
onStatus: (status) {
if (mounted && (status == 'notListening' || status == 'done')) {
setState(() => listening = false);
}
},
onError: (err) {
if (mounted) {
setState(() => listening = false);
}
},
);
if (!available) {
final recordStarted = await VoiceService.instance.startRecording();
if (mounted) {
setState(() {
aiVoiceRecording = recordStarted;
listening = recordStarted;
});
if (recordStarted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已启动麦克风录音,说完后再次点击麦克风,AI 将自动转写英文。')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法访问麦克风,请检查手机录音权限。')),
);
}
}
return;
}
if (!mounted) return;
setState(() => listening = available);
}
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);
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;
return AppPage(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: () => widget.onFinished(null),
),
title: Text(
'${widget.isLessonDialogue ? '课程对话' : '初次见面'} · ${finished ? 4 : stage + 1} / 4',
),
),
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) ...[
const SizedBox(height: 6),
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,
),
),
],
),
),
],
],
),
),
);
},
),
),
if (!finished) ...[
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_AssistChip(
label: '提示',
onTap: () {
setState(() {
usedHelp = true;
hint = script.hints[stage];
});
_saveDraft();
},
),
_AssistChip(
label: '翻译',
onTap: () {
setState(() {
usedHelp = true;
hint = translations[stage];
});
_saveDraft();
},
),
_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 (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
? '本次使用过提示,课程会把关键表达安排到后续复习。'
: '你完成了 4 个交际任务,接下来试着不看帮助独立表达。',
),
),
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.join('、')}。'),
SectionCard(
child: _SummaryLine(
icon: Icons.check_circle_outline,
title: '你的个人复习卡(明天出现)',
sentence: summary.personalSentence,
tint: AppColors.green,
),
),
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),
],
),
),
],
);
}