feat: 优化口语防重复问答、课程推进流转、键盘输入流畅度与竖屏锁定

- AI口语约束:在对话系统提示词与单轮指导中增加防重复问答约束,避免重复寒暄及索取已提供信息,根据历史自然向前推进
- 课程流转:修复多段课程学完后今日任务卡片流转下一课,及退出重进定位逻辑
- 竖屏锁定:在 Flutter、Android 及 iOS 平台配置仅支持竖屏显示
- 对话交互:优化对话界面文本输入重绘与词汇查询,解决键盘输入卡顿
- 测试用例:补充并更新单轮约束、状态流转与返回键退出测试,212项测试全绿
This commit is contained in:
shen
2026-09-18 07:38:54 -07:00
parent 0a4f97d105
commit 5b9cba793e
16 changed files with 568 additions and 242 deletions
@@ -18,6 +18,67 @@ final Map<String, LessonDialogue> segmentDialogues = <String, LessonDialogue>{};
final Map<String, String> segmentGrammarNotes = <String, String>{};
final List<DialogueScene> practiceScenes = <DialogueScene>[];
/// All distinct English words introduced in each CEFR level, populated from
/// the course JSON content packs.
final Map<String, Set<String>> levelVocabularyWords = <String, Set<String>>{};
const List<String> cefrLevels = ['A0', 'A1', 'A2', 'B1'];
/// Registers words for [level] into the vocabulary index.
void registerLevelWords(String level, Iterable<String> words) {
final set = levelVocabularyWords.putIfAbsent(
level.toUpperCase(),
() => <String>{},
);
for (final w in words) {
final clean = w.toLowerCase().replaceAll("'", '').trim();
if (clean.isNotEmpty) set.add(clean);
}
}
/// Fixed demo names and places accepted across all levels as standard practice slots.
const Set<String> standardDemoEntities = {
'alex',
'mia',
'shen',
'bo',
'ava',
'tom',
'sam',
'beijing',
'shanghai',
'hong',
'kong',
'china',
'london',
};
/// Returns all English words taught in the course up to [level] (inclusive).
Set<String> taughtCourseWordsUpToLevel(String level) {
final targetIdx = cefrLevels.indexOf(level.toUpperCase());
final maxIdx = targetIdx < 0 ? 0 : targetIdx;
final result = Set<String>.from(standardDemoEntities);
for (var i = 0; i <= maxIdx && i < cefrLevels.length; i++) {
final words = levelVocabularyWords[cefrLevels[i]];
if (words != null) result.addAll(words);
}
return result;
}
/// Returns all English words taught in the course up to [targetItemId]'s level.
Set<String> taughtCourseWordsUpToItem(String targetItemId) {
return taughtCourseWordsUpToLevel(itemLevel(targetItemId));
}
/// All distinct English words across all loaded course packs.
Set<String> allLoadedCourseWords() {
final result = <String>{};
for (final words in levelVocabularyWords.values) {
result.addAll(words);
}
return result;
}
/// Empties every registry, for `CourseRepository.resetForTest`.
void clearCourseCatalog() {
courseLessons.clear();
@@ -28,6 +89,7 @@ void clearCourseCatalog() {
segmentGrammarNotes.clear();
practiceScenes.clear();
assessmentPacks.clear();
levelVocabularyWords.clear();
}
/// Every lesson the app can teach, in learning order: A0 first, then the
@@ -120,10 +120,57 @@ class CourseRepository {
_register(pack);
}
void _indexPackVocabulary(CoursePack pack) {
final words = <String>{};
void addText(String? text) {
if (text == null || text.isEmpty) return;
final matches = RegExp(r"[A-Za-z]+(?:'[A-Za-z]+)?").allMatches(text);
for (final m in matches) {
final word = m.group(0)!.replaceAll("'", '').toLowerCase();
if (word.isNotEmpty) words.add(word);
}
}
for (final item in pack.coreItems) {
addText(item.en);
addText(item.exampleEn);
for (final group in item.match) {
for (final term in group) {
addText(term);
}
}
}
for (final rw in pack.receptiveWords) {
addText(rw.en);
}
for (final seg in pack.segments) {
addText(seg.listeningText);
addText(seg.speakingText);
addText(seg.readingText);
addText(seg.writingExample);
if (seg.vocabulary != null) {
for (final v in seg.vocabulary!) {
addText(v.word);
}
}
}
for (final scene in pack.scenes) {
for (final turn in scene.turns) {
addText(turn.ai);
addText(turn.model);
}
}
for (final mat in pack.materials) {
addText(mat.text);
}
registerLevelWords(pack.level, words);
}
/// Adapts one pack into the shared runtime registries. Explicit rules and
/// texts in the pack win; anything left out is derived from the core items
/// the part teaches.
void _register(CoursePack pack) {
_indexPackVocabulary(pack);
final byId = {for (final item in pack.coreItems) item.id: item};
final scenesById = {for (final scene in pack.scenes) scene.id: scene};