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
+117 -33
View File
@@ -9,15 +9,59 @@ import '../core/courses/courses.dart';
import '../core/voice_service.dart';
import 'app_widgets.dart';
List<VocabularyItem>? _cachedEntries;
Map<String, VocabularyItem>? _cachedExactMap;
RegExp? _cachedExpression;
/// Clears the lexicon cache when courses are reloaded or reset.
void clearLexiconCache() {
_cachedEntries = null;
_cachedExactMap = null;
_cachedExpression = null;
}
List<VocabularyItem> get courseLexiconEntries {
if (_cachedEntries != null) return _cachedEntries!;
// Segment words first: they carry the example and IPA a lesson shows.
final entries = <VocabularyItem>[
...segmentVocabulary.values.expand((items) => items),
...allLessons.expand((lesson) => lesson.vocabulary),
];
final seen = <String>{};
return entries.where((item) => seen.add(item.word.toLowerCase())).toList()
_cachedEntries = entries.where((item) => seen.add(item.word.toLowerCase())).toList()
..sort((a, b) => b.word.length.compareTo(a.word.length));
return _cachedEntries!;
}
Map<String, VocabularyItem> get _exactLexiconMap {
if (_cachedExactMap != null) return _cachedExactMap!;
final map = <String, VocabularyItem>{};
for (final item in courseLexiconEntries) {
final normalized = item.word
.toLowerCase()
.replaceAll('’', "'")
.replaceAll('‘', "'")
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
map.putIfAbsent(normalized, () => item);
}
_cachedExactMap = map;
return map;
}
RegExp? get courseLexiconExpression {
if (_cachedExpression != null) return _cachedExpression;
final entries = courseLexiconEntries;
if (entries.isEmpty) return null;
_cachedExpression = RegExp(
r'(?<![a-zA-Z0-9])(?:' +
entries
.map((item) => RegExp.escape(item.word).replaceAll('’', "['’]"))
.join('|') +
r')(?![a-zA-Z0-9])',
caseSensitive: false,
);
return _cachedExpression;
}
/// Finds a course item by exact query, then by the longest known phrase in it.
@@ -29,20 +73,28 @@ VocabularyItem? findCourseLexicon(String text) {
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (normalized.isEmpty) return null;
return courseLexiconEntries.cast<VocabularyItem?>().firstWhere((entry) {
final word = entry!.word
final exact = _exactLexiconMap[normalized];
if (exact != null) return exact;
final entries = courseLexiconEntries;
for (var i = 0; i < entries.length; i++) {
final entry = entries[i];
final word = entry.word
.toLowerCase()
.replaceAll('’', "'")
.replaceAll('‘', "'")
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (normalized == word) return true;
final pattern = RegExp(
r'(?<![a-zA-Z0-9])' + RegExp.escape(word) + r'(?![a-zA-Z0-9])',
caseSensitive: false,
);
return pattern.hasMatch(normalized);
}, orElse: () => null);
if (normalized == word) return entry;
if (normalized.contains(word)) {
final pattern = RegExp(
r'(?<![a-zA-Z0-9])' + RegExp.escape(word) + r'(?![a-zA-Z0-9])',
caseSensitive: false,
);
if (pattern.hasMatch(normalized)) return entry;
}
}
return null;
}
/// Extracts all distinct course lexicon phrases/words that appear within [text].
@@ -92,6 +144,12 @@ bool isSentenceQuery(String text) {
trimmed.length > 25;
}
class _LexiconChunk {
const _LexiconChunk({required this.text, this.entry});
final String text;
final VocabularyItem? entry;
}
/// Inline course text that lets a learner tap a known word or phrase without
/// leaving the current task. Longest phrases are matched before their words.
class LexiconText extends StatefulWidget {
@@ -117,6 +175,46 @@ class LexiconText extends StatefulWidget {
class _LexiconTextState extends State<LexiconText> {
final List<TapGestureRecognizer> _recognizers = [];
String selectedText = '';
List<_LexiconChunk>? _chunks;
@override
void initState() {
super.initState();
_parseChunks();
}
@override
void didUpdateWidget(LexiconText oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.text != widget.text) {
_parseChunks();
}
}
void _parseChunks() {
final expression = courseLexiconExpression;
if (expression == null) {
_chunks = [_LexiconChunk(text: widget.text)];
return;
}
final chunks = <_LexiconChunk>[];
var cursor = 0;
for (final match in expression.allMatches(widget.text)) {
if (match.start > cursor) {
chunks.add(
_LexiconChunk(text: widget.text.substring(cursor, match.start)),
);
}
final matched = widget.text.substring(match.start, match.end);
final entry = findCourseLexicon(matched);
chunks.add(_LexiconChunk(text: matched, entry: entry));
cursor = match.end;
}
if (cursor < widget.text.length) {
chunks.add(_LexiconChunk(text: widget.text.substring(cursor)));
}
_chunks = chunks;
}
@override
void dispose() {
@@ -132,26 +230,16 @@ class _LexiconTextState extends State<LexiconText> {
recognizer.dispose();
}
_recognizers.clear();
final entries = courseLexiconEntries;
if (entries.isEmpty) return Text(widget.text, style: widget.style);
final expression = RegExp(
r'(?<![a-zA-Z0-9])(?:' +
entries
.map((item) => RegExp.escape(item.word).replaceAll('’', "['’]"))
.join('|') +
r')(?![a-zA-Z0-9])',
caseSensitive: false,
);
if (_chunks == null || _chunks!.isEmpty) {
return Text(widget.text, style: widget.style);
}
final spans = <InlineSpan>[];
var cursor = 0;
for (final match in expression.allMatches(widget.text)) {
if (match.start > cursor) {
spans.add(TextSpan(text: widget.text.substring(cursor, match.start)));
}
final matched = widget.text.substring(match.start, match.end);
final entry = findCourseLexicon(matched);
for (final chunk in _chunks!) {
final entry = chunk.entry;
if (entry == null) {
spans.add(TextSpan(text: matched));
spans.add(TextSpan(text: chunk.text));
} else {
final recognizer = TapGestureRecognizer()
..onTap = () => showLexiconLookup(
@@ -162,7 +250,7 @@ class _LexiconTextState extends State<LexiconText> {
_recognizers.add(recognizer);
spans.add(
TextSpan(
text: matched,
text: chunk.text,
recognizer: recognizer,
style: TextStyle(
color: AppColors.green,
@@ -172,10 +260,6 @@ class _LexiconTextState extends State<LexiconText> {
),
);
}
cursor = match.end;
}
if (cursor < widget.text.length) {
spans.add(TextSpan(text: widget.text.substring(cursor)));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,