feat: 重做阅读找答案题与 AI 情境对话,并归拢本地识别、查词等既有改动

阅读"在对话里找到答案":
- 16 道题全部重写,干扰项真实出现在对话里,靠说话人归属或否定句才能作答
- 选项按题目内容确定性打乱,答案不再固定排第一;听力环节同样处理
- 去掉超纲干扰项、重复题干,收紧自由作答匹配(过去单个字母也能判对)

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-16 16:38:23 +09:00
co-authored by Claude Opus 5
parent ef2fce207a
commit 7e29c5449f
24 changed files with 2896 additions and 665 deletions
@@ -158,6 +158,12 @@ class _AssessmentPageState extends State<AssessmentPage> {
@override
void dispose() {
VoiceService.instance.stopSpeaking();
VoiceService.instance.stopListening();
VoiceService.instance.stopRecordingPlayback();
if (listening || aiVoiceRecording) {
VoiceService.instance.stopRecording();
}
controller.dispose();
super.dispose();
}
@@ -209,58 +215,22 @@ class _AssessmentPageState extends State<AssessmentPage> {
return;
}
if (listening) {
await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false);
return;
}
final ready = await VoiceService.instance.startListening(
(text, _) {
if (mounted) {
setState(() {
controller.text = text;
usedMic = true;
lastTranscript = text;
transcriptEdited = false;
});
}
},
onStatus: (status) {
if (mounted && (status == 'notListening' || status == 'done')) {
setState(() => listening = false);
}
},
onError: (err) {
if (mounted) {
setState(() => listening = false);
}
},
);
if (!ready) {
final recordStarted = await VoiceService.instance.startRecording();
if (mounted) {
setState(() {
aiVoiceRecording = recordStarted;
listening = recordStarted;
speakingUnavailable = !recordStarted;
});
if (recordStarted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已启动麦克风录音,回答后再次点击,AI 将自动转写为英文。')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法访问麦克风,口语可稍后补测。')),
);
}
}
return;
}
if (mounted) {
setState(() {
listening = ready;
speakingUnavailable = !ready;
});
VoiceService.instance.stopSpeaking();
final recordStarted = await VoiceService.instance.startRecording();
if (!mounted) return;
setState(() {
aiVoiceRecording = recordStarted;
listening = recordStarted;
speakingUnavailable = !recordStarted;
});
if (recordStarted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已启动麦克风录音,回答后再次点击,将自动转写为英文。')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法访问麦克风,口语可稍后补测。')),
);
}
}
@@ -35,7 +35,7 @@ class DialogueScenePage extends StatelessWidget {
const Eyebrow('按当前水平推荐'),
Text('选一个场景,开口练习。', style: Theme.of(context).textTheme.headlineMedium),
Text(
'每次不超过 5 个回答,完成明确任务后结束。',
'轮 4 次回答,完成明确任务后结束。',
style: Theme.of(context).textTheme.bodyMedium,
),
SectionCard(
@@ -60,8 +60,8 @@ class DialogueScenePage extends StatelessWidget {
],
),
),
const _LockedScene(title: '认识新同学', note: '完成当前场景后解锁'),
const _LockedScene(title: '咖啡店', note: 'A1 · 尚未解锁'),
const _LockedScene(title: '认识新同学', note: 'A0 · 后续版本开放'),
const _LockedScene(title: '咖啡店', note: 'A1 · 后续版本开放'),
],
),
);
@@ -107,6 +107,7 @@ class DialoguePage extends StatefulWidget {
class _DialoguePageState extends State<DialoguePage> {
final controller = TextEditingController();
final ScrollController _scrollController = ScrollController();
final List<DialogueTurn> turns = [];
int stage = 0;
bool usedHelp = false;
@@ -123,6 +124,22 @@ class _DialoguePageState extends State<DialoguePage> {
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
@@ -134,14 +151,12 @@ class _DialoguePageState extends State<DialoguePage> {
.id,
widget.state.activeLessonId,
)
: const LessonDialogue(
goal: '姓名、地点、状态或喜好,并反问',
prompts: prompts,
hints: hints,
translations: translations,
);
: a0MeetDialogue;
String? _resolveTranslationFor(String text, int currentStage) {
/// 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++) {
@@ -151,11 +166,12 @@ class _DialoguePageState extends State<DialoguePage> {
}
}
}
// 2. Check standalone prompts
for (var i = 0; i < prompts.length; i++) {
if (prompts[i].trim().toLowerCase() == cleanText.toLowerCase()) {
if (i < translations.length) {
return 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];
}
}
}
@@ -180,6 +196,7 @@ class _DialoguePageState extends State<DialoguePage> {
}
}
// 5. Common fallback phrases
if (cleanText == _closingLine) return _closingTranslation;
if (cleanText.toLowerCase().contains("wonderful") &&
cleanText.toLowerCase().contains("nice meeting you")) {
return "太棒了 — 很高兴认识你!";
@@ -188,40 +205,22 @@ class _DialoguePageState extends State<DialoguePage> {
cleanText.toLowerCase().contains("bye")) {
return "再见!";
}
// 6. If stage index is within script.translations
if (currentStage >= 0 && currentStage < script.translations.length) {
return script.translations[currentStage];
}
return null;
}
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 draft = widget.isLessonDialogue
? widget.state.dialogueDraft
: widget.state.sceneDialogueDraft;
final expectedDraftId = widget.isLessonDialogue
? widget.state.activeLessonId
: _sceneDraftId;
final canRestore =
widget.isLessonDialogue &&
draft?.lessonId == widget.state.activeLessonId &&
draft!.stage >= 0 &&
draft != null &&
draft.lessonId == expectedDraftId &&
draft.stage >= 0 &&
draft.stage <= script.prompts.length &&
draft.turns.isNotEmpty;
if (canRestore) {
@@ -230,7 +229,7 @@ class _DialoguePageState extends State<DialoguePage> {
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, i ~/ 2);
final trans = _resolveTranslationFor(t.text);
turns.add(t.copyWith(translation: trans));
} else {
turns.add(t);
@@ -238,8 +237,9 @@ class _DialoguePageState extends State<DialoguePage> {
}
} else {
final initialPrompt = script.prompts.first;
final initialTranslation = script.translations.firstOrNull ??
_resolveTranslationFor(initialPrompt, 0);
final initialTranslation =
script.translations.firstOrNull ??
_resolveTranslationFor(initialPrompt);
turns.add(
DialogueTurn(
text: initialPrompt,
@@ -251,6 +251,7 @@ class _DialoguePageState extends State<DialoguePage> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_playLatestAi(slow: false);
_scrollToBottom();
}
});
}
@@ -260,12 +261,28 @@ class _DialoguePageState extends State<DialoguePage> {
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();
@@ -273,7 +290,10 @@ class _DialoguePageState extends State<DialoguePage> {
return;
}
if (!_matchesCurrentTask(text)) {
setState(() => validationError = '这句还没有完成当前任务。可以查看提示后补充一次。');
setState(
() => validationError = '这一轮要“${_currentTaskLabel()}”,这句还没做到。'
'可以点“提示”看示范,再补充一次。',
);
return;
}
widget.state.recordDialogueAttempt(
@@ -298,13 +318,20 @@ class _DialoguePageState extends State<DialoguePage> {
validationError = null;
});
_saveDraft();
_scrollToBottom();
final aiResponse = await AiService.instance.dialogueReply(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
requiredTask: nextStage < script.prompts.length
aiGoal: nextStage < script.prompts.length
? script.prompts[nextStage]
: 'Say goodbye warmly after the learner asked a question.',
: '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>{
@@ -318,10 +345,10 @@ class _DialoguePageState extends State<DialoguePage> {
final replyText = aiResponse?.reply ??
(nextStage < script.prompts.length
? script.prompts[nextStage]
: 'Wonderful — nice meeting you!');
: _closingLine);
var replyTranslation = aiResponse?.translation;
if (replyTranslation == null || replyTranslation.isEmpty) {
replyTranslation = _resolveTranslationFor(replyText, nextStage);
replyTranslation = _resolveTranslationFor(replyText);
}
setState(() {
turns.add(
@@ -332,86 +359,48 @@ class _DialoguePageState extends State<DialoguePage> {
),
);
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() {
if (!widget.isLessonDialogue) return;
widget.state.saveDialogueDraft(
DialogueDraft(
lessonId: widget.state.activeLessonId,
stage: stage,
turns: List.unmodifiable(turns),
usedHelp: usedHelp,
),
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) {
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),
};
// 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);
}
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);
return matchesDialogueStage(script, stage, response);
}
String get _lessonSegmentId => lessonById(widget.state.activeLessonId)
@@ -425,6 +414,7 @@ class _DialoguePageState extends State<DialoguePage> {
widget.onFinished(null);
return;
}
widget.state.clearSceneDialogueDraft();
final learnerTurns = turns.where((turn) => turn.isLearner).toList();
final personalSentence = learnerTurns.isEmpty
? 'My name is …'
@@ -432,13 +422,25 @@ class _DialoguePageState extends State<DialoguePage> {
widget.state.addDialogueRecap(personalSentence);
widget.onFinished(
DialogueSummaryData(
completedTasks: const ['介绍姓名', '说明来自哪里', '表达状态或喜好', '反问对方'],
// 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];
@@ -453,7 +455,7 @@ class _DialoguePageState extends State<DialoguePage> {
String? trans = turn.translation;
if (trans == null || trans.isEmpty) {
trans = _resolveTranslationFor(turn.text, index ~/ 2);
trans = _resolveTranslationFor(turn.text);
}
if (trans != null && trans.isNotEmpty) {
@@ -462,6 +464,7 @@ class _DialoguePageState extends State<DialoguePage> {
_shownTranslations.add(index);
});
_saveDraft();
_scrollToBottom();
return;
}
@@ -482,6 +485,7 @@ class _DialoguePageState extends State<DialoguePage> {
turns[index] = turn.copyWith(translation: finalTrans);
});
_saveDraft();
_scrollToBottom();
}
Future<void> _showLatestAiTranslation() async {
@@ -491,7 +495,7 @@ class _DialoguePageState extends State<DialoguePage> {
String? trans = latestAi.translation;
if (trans == null || trans.isEmpty) {
trans = _resolveTranslationFor(latestAi.text, stage);
trans = _resolveTranslationFor(latestAi.text);
}
if (trans != null && trans.isNotEmpty) {
@@ -502,6 +506,7 @@ class _DialoguePageState extends State<DialoguePage> {
turns[latestAiIndex] = latestAi.copyWith(translation: trans);
});
_saveDraft();
_scrollToBottom();
return;
}
@@ -525,6 +530,7 @@ class _DialoguePageState extends State<DialoguePage> {
turns[latestAiIndex] = latestAi.copyWith(translation: finalTrans);
});
_saveDraft();
_scrollToBottom();
}
Future<void> _playLatestAi({required bool slow}) async {
@@ -537,7 +543,7 @@ class _DialoguePageState extends State<DialoguePage> {
}
Future<void> _toggleListening() async {
if (aiVoiceRecording) {
if (listening || aiVoiceRecording) {
final path = await VoiceService.instance.stopRecording();
if (!mounted) return;
setState(() {
@@ -564,6 +570,7 @@ class _DialoguePageState extends State<DialoguePage> {
transcriptEdited = false;
}
});
_scrollToBottom();
if (transcribed == null || transcribed.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('未识别到清晰语音,请再试一次或使用键盘输入。')),
@@ -576,56 +583,19 @@ class _DialoguePageState extends State<DialoguePage> {
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;
}
VoiceService.instance.stopSpeaking();
final recordStarted = await VoiceService.instance.startRecording();
if (!mounted) return;
setState(() => listening = available);
if (recordStarted) {
setState(() {
aiVoiceRecording = true;
listening = true;
});
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法访问麦克风,请检查手机录音权限。')),
);
}
}
Future<void> _toggleRecording() async {
@@ -679,7 +649,9 @@ class _DialoguePageState extends State<DialoguePage> {
@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),
@@ -687,7 +659,7 @@ class _DialoguePageState extends State<DialoguePage> {
onPressed: () => widget.onFinished(null),
),
title: Text(
'${widget.isLessonDialogue ? '课程对话' : '初次见面'} · ${finished ? 4 : stage + 1} / 4',
'${widget.isLessonDialogue ? '课程对话' : '初次见面'} · ${finished ? totalStages : stage + 1} / $totalStages',
),
),
child: SpacedColumn(
@@ -796,6 +768,33 @@ class _DialoguePageState extends State<DialoguePage> {
],
),
),
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,
),
),
],
),
),
],
),
],
@@ -822,6 +821,7 @@ class _DialoguePageState extends State<DialoguePage> {
hint = script.hints.isNotEmpty ? script.hints[hintIdx] : null;
});
_saveDraft();
_scrollToBottom();
},
),
_AssistChip(
@@ -847,6 +847,29 @@ class _DialoguePageState extends State<DialoguePage> {
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!,
@@ -940,9 +963,17 @@ class _DialoguePageState extends State<DialoguePage> {
child: Text(
usedHelp
? '本次使用过提示,课程会把关键表达安排到后续复习。'
: '你完成了 4 个交际任务,接下来试着不看帮助独立表达。',
: '你完成了 ${_completedTaskLabels().length} 个交际任务,接下来试着不看帮助独立表达。',
),
),
if (latestFeedback != null)
SectionCard(
tint: AppColors.warm,
child: Text(
'下次可以注意:${latestFeedback!}',
style: const TextStyle(color: AppColors.warmInk),
),
),
PrimaryButton(
label: widget.isLessonDialogue ? '进入独立尝试' : '查看总结',
onPressed: _finish,
@@ -996,7 +1027,11 @@ class DialogueSummaryPage extends StatelessWidget {
children: [
const Eyebrow('对话完成'),
Text('你完成了自我介绍!', style: Theme.of(context).textTheme.headlineMedium),
Text('你完成了 ${summary.completedTasks.join('')}'),
Text(
summary.completedTasks.isEmpty
? '这次还没有完成完整的交际任务,可以再练一次。'
: '你完成了 ${summary.completedTasks.join('')}',
),
SectionCard(
child: _SummaryLine(
icon: Icons.check_circle_outline,
@@ -1005,6 +1040,16 @@ class DialogueSummaryPage extends StatelessWidget {
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
? '本次使用过提示。下次可以先不看提示,再试一次。'
@@ -89,9 +89,15 @@ class _LessonFlowState extends State<LessonFlow> {
onSkip: widget.state.completePreview,
);
case LessonStep.listening:
final listeningOptions = shuffledOptions(
activity.answers,
'${activity.listening}-listening',
);
content = _ListeningStep(
state: widget.state,
activity: activity,
options: listeningOptions,
correctAnswer: activity.answers.first,
selectedAnswer: selectedAnswer,
audioPlayed: listeningAudioPlayed,
onSelected: (value) => setState(() => selectedAnswer = value),
@@ -101,7 +107,9 @@ class _LessonFlowState extends State<LessonFlow> {
state: widget.state,
initialText: activity.listening,
),
onContinue: selectedAnswer == 0
onContinue:
selectedAnswer >= 0 &&
listeningOptions[selectedAnswer] == activity.answers.first
? widget.state.completeListening
: null,
);
@@ -333,6 +341,8 @@ class _ListeningStep extends StatelessWidget {
const _ListeningStep({
required this.state,
required this.activity,
required this.options,
required this.correctAnswer,
required this.selectedAnswer,
required this.audioPlayed,
required this.onSelected,
@@ -343,6 +353,8 @@ class _ListeningStep extends StatelessWidget {
final LessonActivity activity;
final AppState state;
final List<String> options;
final String correctAnswer;
final int selectedAnswer;
final bool audioPlayed;
@@ -353,7 +365,7 @@ class _ListeningStep extends StatelessWidget {
@override
Widget build(BuildContext context) {
final answers = activity.answers;
final answers = options;
return _LessonScaffold(
step: 2,
child: SpacedColumn(
@@ -409,7 +421,7 @@ class _ListeningStep extends StatelessWidget {
: (audioPlayed ? '请选择答案' : '先播放音频或选择答案'),
onPressed: onContinue,
),
if (selectedAnswer >= 0 && selectedAnswer != 0)
if (selectedAnswer >= 0 && answers[selectedAnswer] != correctAnswer)
const Text(
'再听一次,选择正确答案。',
style: TextStyle(color: AppColors.warmInk),
@@ -645,77 +657,299 @@ class _ReadingStep extends StatefulWidget {
class _ReadingStepState extends State<_ReadingStep> {
final controller = TextEditingController();
int? selectedOptionIndex;
bool showAnswer = false;
/// 打乱后的选项:答案不再固定排在第一位,但同一道题顺序保持稳定。
late final List<String> options = shuffledOptions(
widget.activity.readingOptions,
'${widget.activity.readingQuestion}-reading',
);
@override
void dispose() {
controller.dispose();
super.dispose();
}
bool _isOptionCorrect(int index) {
if (options.isEmpty || index < 0 || index >= options.length) {
return false;
}
final option = options[index].trim();
final answer = widget.activity.readingAnswer.trim();
if (option.toLowerCase() == answer.toLowerCase()) return true;
final normOption = option
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9\u4e00-\u9fa5]'), '');
final normAnswer = answer
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9\u4e00-\u9fa5]'), '');
return normOption.isNotEmpty &&
normAnswer.isNotEmpty &&
(normOption.contains(normAnswer) || normAnswer.contains(normOption));
}
bool get isOptionMode => options.isNotEmpty;
bool get isCorrect {
if (isOptionMode) {
return selectedOptionIndex != null && _isOptionCorrect(selectedOptionIndex!);
}
final answer = widget.activity.readingAnswer.toLowerCase().replaceAll(
RegExp(r'[^a-z0-9]'),
RegExp(r'[^a-z0-9\u4e00-\u9fa5]'),
'',
);
final response = controller.text.toLowerCase().replaceAll(
RegExp(r'[^a-z0-9]'),
RegExp(r'[^a-z0-9\u4e00-\u9fa5]'),
'',
);
return response.isNotEmpty && response.contains(answer);
// 只接受写全了答案的输入:过去反向的 answer.contains(response) 让单个字母
// 也能判对('a' 通过 'A book')。
return response.isNotEmpty && answer.isNotEmpty && response.contains(answer);
}
@override
Widget build(BuildContext context) => _LessonScaffold(
step: 4,
child: SpacedColumn(
children: [
const Eyebrow('读一读'),
Text('在对话里找到答案。', style: Theme.of(context).textTheme.headlineMedium),
SectionCard(
tint: AppColors.softGreen,
child: LexiconText(
widget.activity.reading,
state: widget.state,
style: TextStyle(fontSize: 16, height: 1.6),
),
),
Text(widget.activity.readingQuestion),
TextButton.icon(
onPressed: widget.onLookup,
icon: const Icon(Icons.menu_book_outlined),
label: const Text('查词或短语'),
),
TextField(
controller: controller,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
hintText: '用英文输入答案',
filled: true,
fillColor: AppColors.surface,
border: OutlineInputBorder(),
),
),
if (showAnswer)
Widget build(BuildContext context) {
final hasSelected = selectedOptionIndex != null;
final answeredCorrectly = isCorrect;
return _LessonScaffold(
step: 4,
child: SpacedColumn(
children: [
const Eyebrow('读一读'),
Text('在对话里找到答案。', style: Theme.of(context).textTheme.headlineMedium),
SectionCard(
tint: AppColors.warm,
child: Text(
'答案:${widget.activity.readingAnswer}',
style: const TextStyle(color: AppColors.warmInk),
tint: AppColors.softGreen,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Row(
children: [
Icon(Icons.chat_bubble_outline, size: 16, color: AppColors.green),
SizedBox(width: 6),
Text(
'对话内容',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.green,
),
),
],
),
InkWell(
onTap: () => VoiceService.instance.speak(widget.activity.reading),
borderRadius: BorderRadius.circular(16),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
Icon(Icons.volume_up_outlined, size: 16, color: AppColors.green),
SizedBox(width: 4),
Text('朗读对话', style: TextStyle(fontSize: 13, color: AppColors.green)),
],
),
),
),
],
),
const SizedBox(height: 8),
LexiconText(
widget.activity.reading,
state: widget.state,
style: const TextStyle(fontSize: 16, height: 1.6),
),
],
),
),
if (!showAnswer && controller.text.isNotEmpty && !isCorrect)
TextButton(
onPressed: () => setState(() => showAnswer = true),
child: const Text('查看答案后继续学习'),
Row(
children: [
TextButton.icon(
onPressed: widget.onLookup,
icon: const Icon(Icons.menu_book_outlined, size: 18),
label: const Text('查词或短语'),
),
const SizedBox(width: 8),
TextButton.icon(
onPressed: () {
final lines = widget.activity.reading.split('\n');
final target = lines.firstWhere(
(l) => l.trim().isNotEmpty,
orElse: () => widget.activity.reading,
).replaceFirst(RegExp(r'^[A-Za-z]+:\s*'), '');
showLexiconLookup(
context,
state: widget.state,
initialText: target,
);
},
icon: const Icon(Icons.auto_stories_outlined, size: 18),
label: const Text('句型深度解析'),
),
],
),
PrimaryButton(
label: showAnswer ? '继续写一写' : '检查并继续',
onPressed: showAnswer || isCorrect ? widget.onContinue : null,
),
],
),
);
SectionCard(
tint: AppColors.surfaceMuted,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.green.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'问题',
style: TextStyle(
color: AppColors.green,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
widget.activity.readingQuestion,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.ink,
),
),
),
],
),
),
if (isOptionMode) ...[
for (var index = 0; index < options.length; index++) ...[
SectionCard(
tint: selectedOptionIndex == index
? (_isOptionCorrect(index) ? AppColors.softGreen : AppColors.warm)
: null,
onTap: () {
setState(() {
selectedOptionIndex = index;
showAnswer = false;
});
},
child: Row(
children: [
Icon(
selectedOptionIndex == index
? (_isOptionCorrect(index) ? Icons.check_circle : Icons.cancel_outlined)
: Icons.radio_button_off,
color: selectedOptionIndex == index
? (_isOptionCorrect(index) ? AppColors.green : AppColors.warmInk)
: AppColors.muted,
),
const SizedBox(width: 12),
Expanded(
child: Text(
options[index],
style: TextStyle(
fontSize: 15,
fontWeight: selectedOptionIndex == index ? FontWeight.w600 : FontWeight.normal,
color: selectedOptionIndex == index
? (_isOptionCorrect(index) ? AppColors.green : AppColors.warmInk)
: AppColors.ink,
),
),
),
],
),
),
],
if (hasSelected && answeredCorrectly)
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.softGreen,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.green.withValues(alpha: 0.3)),
),
child: const Row(
children: [
Icon(Icons.check_circle, color: AppColors.green, size: 20),
SizedBox(width: 8),
Expanded(
child: Text(
'回答正确!点击下方按钮继续',
style: TextStyle(
color: AppColors.green,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
],
),
)
else if (hasSelected && !answeredCorrectly)
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.warm,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.warmInk.withValues(alpha: 0.2)),
),
child: const Row(
children: [
Icon(Icons.help_outline, color: AppColors.warmInk, size: 20),
SizedBox(width: 8),
Expanded(
child: Text(
'不对哦,再仔细观察对话中的关键句子~',
style: TextStyle(color: AppColors.warmInk, fontSize: 13),
),
),
],
),
),
] else ...[
TextField(
controller: controller,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
hintText: '用英文输入答案',
filled: true,
fillColor: AppColors.surface,
border: OutlineInputBorder(),
),
),
],
if (showAnswer)
SectionCard(
tint: AppColors.warm,
child: Text(
'答案:${widget.activity.readingAnswer}',
style: const TextStyle(color: AppColors.warmInk),
),
),
if (!showAnswer &&
((isOptionMode && hasSelected && !answeredCorrectly) ||
(!isOptionMode && controller.text.isNotEmpty && !answeredCorrectly)))
TextButton(
onPressed: () => setState(() => showAnswer = true),
child: const Text('查看答案后继续学习'),
),
PrimaryButton(
label: showAnswer || answeredCorrectly
? '继续写一写'
: (isOptionMode ? '请选择答案' : '检查并继续'),
onPressed: showAnswer || answeredCorrectly ? widget.onContinue : null,
),
],
),
);
}
}
class _WritingStep extends StatefulWidget {
@@ -6,6 +6,7 @@ import '../../core/ai_service.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../core/voice_service.dart';
import '../../core/sherpa_stt_service.dart';
import '../../widgets/app_widgets.dart';
import '../../core/sync/sync_coordinator.dart';
import 'sync_settings_sheet.dart';
@@ -321,6 +322,22 @@ class _SettingsPageState extends State<SettingsPage> {
onTap: () => _confirmDeleteRecordings(context),
),
const Divider(height: 28),
const Text(
'离线语音识别引擎',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Text(
'已内置 SenseVoice-Small 高精度离线语音识别模型 (INT8)。随 App 安装包直接打包,离线即用,无需额外下载,零网络流量消耗。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
_SettingTile(
title: '离线语音识别:SenseVoice-Small',
subtitle: SherpaSttService.instance.isReady
? '已就绪 · 本地离线识别 (16kHz WAV · INT8)'
: '预加载就绪 · 已内置打包',
trailing: const Icon(Icons.check_circle, color: AppColors.green, size: 20),
),
const Divider(height: 28),
const Text(
'云同步与多端备份',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
@@ -351,7 +368,7 @@ class _SettingsPageState extends State<SettingsPage> {
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
DropdownButtonFormField<AiProviderType>(
value: widget.state.aiProvider,
initialValue: widget.state.aiProvider,
decoration: const InputDecoration(
labelText: '服务类型',
border: OutlineInputBorder(),
@@ -436,6 +453,7 @@ class _SettingsPageState extends State<SettingsPage> {
onPressed: _testingConnection
? null
: () async {
final messenger = ScaffoldMessenger.of(context);
setState(() => _testingConnection = true);
final result = await AiService.instance.testConnection(
provider: widget.state.aiProvider,
@@ -445,7 +463,7 @@ class _SettingsPageState extends State<SettingsPage> {
);
if (!mounted) return;
setState(() => _testingConnection = false);
ScaffoldMessenger.of(context).showSnackBar(
messenger.showSnackBar(
SnackBar(
content: Text(result.message),
backgroundColor:
@@ -458,6 +476,7 @@ class _SettingsPageState extends State<SettingsPage> {
SecondaryButton(
label: '从配置文件重载 (ai_config.json)',
onPressed: () async {
final messenger = ScaffoldMessenger.of(context);
final ok = await widget.state.reloadAiConfigFromAsset();
if (!mounted) return;
if (ok) {
@@ -465,13 +484,13 @@ class _SettingsPageState extends State<SettingsPage> {
endpoint.text = widget.state.aiEndpoint;
model.text = widget.state.aiModel;
});
ScaffoldMessenger.of(context).showSnackBar(
messenger.showSnackBar(
const SnackBar(
content: Text('已从 assets/config/ai_config.json 载入配置。'),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
messenger.showSnackBar(
const SnackBar(content: Text('未找到配置文件或解析失败。')),
);
}
@@ -608,17 +627,20 @@ class _SettingTile extends StatelessWidget {
const _SettingTile({
required this.title,
required this.subtitle,
required this.onTap,
this.onTap,
this.trailing,
});
final String title;
final String subtitle;
final VoidCallback? onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: Text(title),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
trailing: trailing ?? (onTap != null ? const Icon(Icons.chevron_right) : null),
onTap: onTap,
);
}
@@ -1,3 +1,4 @@
import '../../widgets/lexicon_lookup.dart';
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
@@ -257,6 +258,14 @@ class _ReviewPageState extends State<ReviewPage> {
? null
: () => _generateAdaptiveLesson(item),
),
ActionChip(
avatar: const Icon(Icons.search, size: 16),
label: const Text('查词查句'),
onPressed: () => showLexiconLookup(
context,
state: widget.state,
),
),
],
),
if (showHint)
@@ -342,8 +351,12 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
@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);
}
@@ -416,7 +429,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
}
Future<void> _toggleListening() async {
if (aiVoiceRecording) {
if (listening || aiVoiceRecording) {
final path = await VoiceService.instance.stopRecording();
if (!mounted) return;
setState(() {
@@ -457,59 +470,19 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
return;
}
if (listening) {
await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false);
return;
}
final ready = await VoiceService.instance.startListening(
(text, _) {
if (!mounted) return;
setState(() {
controller.text = text;
usedVoice = true;
transcriptEdited = false;
transcriptConfirmed = false;
lastTranscript = text;
});
final lesson = widget.state.cachedAdaptiveLesson;
if (lesson != null) _saveDraft(lesson);
},
onStatus: (status) {
if (mounted && (status == 'notListening' || status == 'done')) {
setState(() => listening = false);
}
},
onError: (err) {
if (mounted) {
setState(() => listening = false);
}
},
);
if (!ready) {
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;
}
VoiceService.instance.stopSpeaking();
final recordStarted = await VoiceService.instance.startRecording();
if (!mounted) return;
setState(() => listening = ready);
if (recordStarted) {
setState(() {
aiVoiceRecording = true;
listening = true;
});
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法访问麦克风,请检查录音权限。你仍可输入英文完成补练。')),
);
}
}
Future<void> _toggleRecording() async {
@@ -649,6 +622,16 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
label: const Text('慢放'),
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => showLexiconLookup(
context,
state: widget.state,
initialText: task.stimulus,
),
icon: const Icon(Icons.psychology_alt_outlined),
label: const Text('解析'),
),
],
),
TextField(