feat: 优化口语防重复问答、课程推进流转、键盘输入流畅度与竖屏锁定
- AI口语约束:在对话系统提示词与单轮指导中增加防重复问答约束,避免重复寒暄及索取已提供信息,根据历史自然向前推进 - 课程流转:修复多段课程学完后今日任务卡片流转下一课,及退出重进定位逻辑 - 竖屏锁定:在 Flutter、Android 及 iOS 平台配置仅支持竖屏显示 - 对话交互:优化对话界面文本输入重绘与词汇查询,解决键盘输入卡顿 - 测试用例:补充并更新单轮约束、状态流转与返回键退出测试,212项测试全绿
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user