830 lines
26 KiB
Dart
830 lines
26 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(
|
||
'每次不超过 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. What’s your name?',
|
||
'Nice to meet you. Where are you from?',
|
||
'Great! How are you today? Or what do you like?',
|
||
'I’m good, thanks. Now ask me one question!',
|
||
];
|
||
static const hints = [
|
||
'My name is Alex.',
|
||
'I’m from Hong Kong.',
|
||
'I’m good, thanks. / I like coffee.',
|
||
'What’s 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),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|