feat: 优化口语防重复问答、课程推进流转、键盘输入流畅度与竖屏锁定
- AI口语约束:在对话系统提示词与单轮指导中增加防重复问答约束,避免重复寒暄及索取已提供信息,根据历史自然向前推进 - 课程流转:修复多段课程学完后今日任务卡片流转下一课,及退出重进定位逻辑 - 竖屏锁定:在 Flutter、Android 及 iOS 平台配置仅支持竖屏显示 - 对话交互:优化对话界面文本输入重绘与词汇查询,解决键盘输入卡顿 - 测试用例:补充并更新单轮约束、状态流转与返回键退出测试,212项测试全绿
This commit is contained in:
@@ -682,18 +682,20 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Eyebrow('目标:${script.goal}'),
|
||||
Container(
|
||||
constraints: const BoxConstraints(minHeight: 250),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: turns.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) => _TurnBubble(
|
||||
turn: turns[index],
|
||||
state: widget.state,
|
||||
showTranslation: _shownTranslations.contains(index),
|
||||
onToggleTranslation: () => _toggleTurnTranslation(index),
|
||||
RepaintBoundary(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 250),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: turns.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) => _TurnBubble(
|
||||
turn: turns[index],
|
||||
state: widget.state,
|
||||
showTranslation: _shownTranslations.contains(index),
|
||||
onToggleTranslation: () => _toggleTurnTranslation(index),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -750,13 +752,22 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
),
|
||||
TextField(
|
||||
controller: controller,
|
||||
onChanged: (value) => setState(() {
|
||||
if (usedVoice && value != lastTranscript) {
|
||||
transcriptEdited = true;
|
||||
onChanged: (value) {
|
||||
final needResetVoice =
|
||||
usedVoice &&
|
||||
value != lastTranscript &&
|
||||
!transcriptEdited;
|
||||
final needClearAi = aiCheck != null || aiCheckError != null;
|
||||
if (needResetVoice || needClearAi) {
|
||||
setState(() {
|
||||
if (needResetVoice) transcriptEdited = true;
|
||||
if (needClearAi) {
|
||||
aiCheck = null;
|
||||
aiCheckError = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
aiCheck = null;
|
||||
aiCheckError = null;
|
||||
}),
|
||||
},
|
||||
onSubmitted: (_) => send(),
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入你的英文回答',
|
||||
@@ -785,21 +796,27 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed:
|
||||
controller.text.trim().isEmpty ||
|
||||
checkingWithAi ||
|
||||
waitingForReply
|
||||
? null
|
||||
: _checkWithAi,
|
||||
icon: checkingWithAi
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.spellcheck),
|
||||
label: Text(checkingWithAi ? '正在检查…' : '发送前 AI 检查语法和拼写(可选)'),
|
||||
ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: controller,
|
||||
builder: (context, value, _) {
|
||||
final canCheck =
|
||||
value.text.trim().isNotEmpty &&
|
||||
!checkingWithAi &&
|
||||
!waitingForReply;
|
||||
return OutlinedButton.icon(
|
||||
onPressed: canCheck ? _checkWithAi : null,
|
||||
icon: checkingWithAi
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.spellcheck),
|
||||
label: Text(
|
||||
checkingWithAi ? '正在检查…' : '发送前 AI 检查语法和拼写(可选)',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (aiCheck != null)
|
||||
SectionCard(
|
||||
|
||||
@@ -49,7 +49,15 @@ class TodayTaskCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
_TodayTask _todayTask() {
|
||||
final lesson = lessonById(state.activeLessonId);
|
||||
var lessonId = state.activeLessonId;
|
||||
if (state.completedLessonIds.contains(lessonId)) {
|
||||
final next = state.nextIncompleteLessonId;
|
||||
if (next != null) {
|
||||
lessonId = next;
|
||||
}
|
||||
}
|
||||
final lesson = lessonById(lessonId);
|
||||
|
||||
if (state.hasResumableLessonDialogue) {
|
||||
return _TodayTask(
|
||||
label: '未完成',
|
||||
@@ -75,17 +83,39 @@ class TodayTaskCard extends StatelessWidget {
|
||||
}
|
||||
// A0 课程学完、尚未通过阶段评估时,先巩固再评估;通过后回到下一课。
|
||||
if (state.a0LessonsComplete && !state.a0Passed) return _stageTask();
|
||||
|
||||
if (state.allLessonsComplete) {
|
||||
return _TodayTask(
|
||||
label: '已学完',
|
||||
title: '所有课程已完成',
|
||||
note: '你已学完全部课程内容!建议保持日常到期复习或自由情境对话。',
|
||||
action: '去复习',
|
||||
minutes: '10 分钟',
|
||||
onPressed: onStartReview,
|
||||
);
|
||||
}
|
||||
|
||||
final segments = lesson.segments.length;
|
||||
final segment = state.activeSegmentIndexFor(lesson.id) + 1;
|
||||
final segmentIndex = state.activeSegmentIndexFor(lesson.id);
|
||||
final segment = segmentIndex + 1;
|
||||
final isSegmentResuming = segments > 1 && segmentIndex > 0;
|
||||
|
||||
return _TodayTask(
|
||||
label: '今日新课',
|
||||
label: isSegmentResuming ? '继续课程' : '今日新课',
|
||||
title:
|
||||
'第 ${lesson.number} 课 · ${lesson.title}'
|
||||
'${segments > 1 ? ' · 第 $segment/$segments 段' : ''}',
|
||||
note: '预热词汇 → 听说读写 → 对话 → 独立尝试',
|
||||
action: '开始今天的学习',
|
||||
note: isSegmentResuming
|
||||
? '第 $segmentIndex 段已学完;继续本课下一段情境训练。'
|
||||
: '预热词汇 → 听说读写 → 对话 → 独立尝试',
|
||||
action: isSegmentResuming ? '继续学习第 $segment 段' : '开始今天的学习',
|
||||
minutes: '12 分钟',
|
||||
onPressed: onStartLesson,
|
||||
onPressed: () {
|
||||
if (state.activeLessonId != lesson.id) {
|
||||
state.openLesson(lesson.id);
|
||||
}
|
||||
onStartLesson();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.state.addListener(_onStateChange);
|
||||
writingController.text = widget.state.lessonWritingDraft;
|
||||
independentController.text = widget.state.independentAttemptDraft;
|
||||
previewIndex = widget.state.previewIndex
|
||||
@@ -73,11 +74,16 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.state.removeListener(_onStateChange);
|
||||
writingController.dispose();
|
||||
independentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onStateChange() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lesson = lessonById(widget.state.activeLessonId);
|
||||
@@ -230,19 +236,23 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
widget.state.activeSegmentIndexFor(lesson.id) ==
|
||||
lesson.segments.length - 1,
|
||||
nextSegmentNumber: widget.state.activeSegmentIndexFor(lesson.id) + 2,
|
||||
onFinish: () {
|
||||
widget.state.finishCurrentLessonSegment();
|
||||
widget.onFinish();
|
||||
},
|
||||
onFinish: _finishOrExit,
|
||||
);
|
||||
}
|
||||
return _LessonScope(
|
||||
title:
|
||||
'第 ${lesson.number} 课 · ${lesson.title} · 第 ${widget.state.activeSegmentIndexFor(lesson.id) + 1}/${lesson.segments.length} 段',
|
||||
onExit: widget.onFinish,
|
||||
onExit: _finishOrExit,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
void _finishOrExit() {
|
||||
if (widget.state.lessonStep == LessonStep.complete) {
|
||||
widget.state.finishCurrentLessonSegment();
|
||||
}
|
||||
widget.onFinish();
|
||||
}
|
||||
}
|
||||
|
||||
class _LessonScaffold extends StatelessWidget {
|
||||
|
||||
@@ -71,6 +71,9 @@ class _LearningShellState extends State<LearningShell> {
|
||||
void handleBack() {
|
||||
switch (route) {
|
||||
case _ShellRoute.lesson:
|
||||
if (widget.state.lessonStep == LessonStep.complete) {
|
||||
widget.state.finishCurrentLessonSegment();
|
||||
}
|
||||
showTab(tab);
|
||||
case _ShellRoute.dialogue:
|
||||
if (dialogueInLesson) {
|
||||
@@ -99,66 +102,6 @@ class _LearningShellState extends State<LearningShell> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget body;
|
||||
switch (route) {
|
||||
case _ShellRoute.lesson:
|
||||
body = LessonFlow(
|
||||
// 换课或换段时重建,避免上一段的听力选择和播放状态沿用过来。
|
||||
key: ValueKey(
|
||||
'${widget.state.activeLessonId}-'
|
||||
'${widget.state.activeSegmentIndexFor(widget.state.activeLessonId)}',
|
||||
),
|
||||
state: widget.state,
|
||||
onOpenDialogue: () => showDialogue(inLesson: true),
|
||||
onFinish: () => showTab(tab),
|
||||
);
|
||||
case _ShellRoute.dialogue:
|
||||
body = DialoguePage(
|
||||
key: ValueKey(dialogueInLesson ? 'lesson' : 'scene-$sceneId'),
|
||||
state: widget.state,
|
||||
isLessonDialogue: dialogueInLesson,
|
||||
sceneId: sceneId,
|
||||
onFinished: (summary) {
|
||||
if (dialogueInLesson) {
|
||||
showLesson();
|
||||
} else if (summary != null) {
|
||||
showSummary(summary);
|
||||
} else {
|
||||
handleBack();
|
||||
}
|
||||
},
|
||||
);
|
||||
case _ShellRoute.summary:
|
||||
body = DialogueSummaryPage(
|
||||
summary: dialogueSummary!,
|
||||
onHome: () => showTab(AppTab.learn),
|
||||
onLesson: showLesson,
|
||||
onRetry: () => showDialogue(scene: sceneId),
|
||||
onBack: () => showTab(tab),
|
||||
);
|
||||
case _ShellRoute.adaptiveLesson:
|
||||
body = AdaptiveLessonPage(
|
||||
state: widget.state,
|
||||
onFinished: () => showTab(AppTab.review),
|
||||
);
|
||||
case _ShellRoute.assessment:
|
||||
body = AssessmentPage(
|
||||
state: widget.state,
|
||||
pack: assessmentPack!,
|
||||
onFinished: () => showTab(AppTab.profile),
|
||||
onStartReplacement: showAssessment,
|
||||
);
|
||||
case _ShellRoute.assessmentPreparation:
|
||||
body = AssessmentPreparationPage(
|
||||
state: widget.state,
|
||||
pack: assessmentPack!,
|
||||
onStart: startAssessment,
|
||||
onBack: () => showTab(AppTab.profile),
|
||||
);
|
||||
case _ShellRoute.tab:
|
||||
body = _tabContent();
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: route == _ShellRoute.tab && tab == AppTab.learn,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
@@ -168,8 +111,69 @@ class _LearningShellState extends State<LearningShell> {
|
||||
},
|
||||
child: AnimatedBuilder(
|
||||
animation: widget.state,
|
||||
builder: (context, _) => Scaffold(
|
||||
body: body,
|
||||
builder: (context, _) {
|
||||
final Widget body;
|
||||
switch (route) {
|
||||
case _ShellRoute.lesson:
|
||||
body = LessonFlow(
|
||||
// 换课或换段时重建,避免上一段的听力选择和播放状态沿用过来。
|
||||
key: ValueKey(
|
||||
'${widget.state.activeLessonId}-'
|
||||
'${widget.state.activeSegmentIndexFor(widget.state.activeLessonId)}',
|
||||
),
|
||||
state: widget.state,
|
||||
onOpenDialogue: () => showDialogue(inLesson: true),
|
||||
onFinish: () => showTab(tab),
|
||||
);
|
||||
case _ShellRoute.dialogue:
|
||||
body = DialoguePage(
|
||||
key: ValueKey(dialogueInLesson ? 'lesson' : 'scene-$sceneId'),
|
||||
state: widget.state,
|
||||
isLessonDialogue: dialogueInLesson,
|
||||
sceneId: sceneId,
|
||||
onFinished: (summary) {
|
||||
if (dialogueInLesson) {
|
||||
showLesson();
|
||||
} else if (summary != null) {
|
||||
showSummary(summary);
|
||||
} else {
|
||||
handleBack();
|
||||
}
|
||||
},
|
||||
);
|
||||
case _ShellRoute.summary:
|
||||
body = DialogueSummaryPage(
|
||||
summary: dialogueSummary!,
|
||||
onHome: () => showTab(AppTab.learn),
|
||||
onLesson: showLesson,
|
||||
onRetry: () => showDialogue(scene: sceneId),
|
||||
onBack: () => showTab(tab),
|
||||
);
|
||||
case _ShellRoute.adaptiveLesson:
|
||||
body = AdaptiveLessonPage(
|
||||
state: widget.state,
|
||||
onFinished: () => showTab(AppTab.review),
|
||||
);
|
||||
case _ShellRoute.assessment:
|
||||
body = AssessmentPage(
|
||||
state: widget.state,
|
||||
pack: assessmentPack!,
|
||||
onFinished: () => showTab(AppTab.profile),
|
||||
onStartReplacement: showAssessment,
|
||||
);
|
||||
case _ShellRoute.assessmentPreparation:
|
||||
body = AssessmentPreparationPage(
|
||||
state: widget.state,
|
||||
pack: assessmentPack!,
|
||||
onStart: startAssessment,
|
||||
onBack: () => showTab(AppTab.profile),
|
||||
);
|
||||
case _ShellRoute.tab:
|
||||
body = _tabContent();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: body,
|
||||
bottomNavigationBar: route == _ShellRoute.tab
|
||||
? NavigationBar(
|
||||
selectedIndex: tab.index,
|
||||
@@ -201,7 +205,8 @@ class _LearningShellState extends State<LearningShell> {
|
||||
],
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -211,7 +216,14 @@ class _LearningShellState extends State<LearningShell> {
|
||||
case AppTab.learn:
|
||||
return HomePage(
|
||||
state: widget.state,
|
||||
onStartLesson: showLesson,
|
||||
onStartLesson: () {
|
||||
if (widget.state.completedLessonIds
|
||||
.contains(widget.state.activeLessonId)) {
|
||||
final next = widget.state.nextIncompleteLessonId;
|
||||
if (next != null) widget.state.openLesson(next);
|
||||
}
|
||||
showLesson();
|
||||
},
|
||||
onOpenLesson: (id) {
|
||||
widget.state.openLesson(id);
|
||||
showLesson();
|
||||
|
||||
Reference in New Issue
Block a user