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:
@@ -8,11 +8,13 @@ class AppPage extends StatelessWidget {
|
||||
required this.child,
|
||||
this.appBar,
|
||||
this.bottomNavigationBar,
|
||||
this.scrollController,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final PreferredSizeWidget? appBar;
|
||||
final Widget? bottomNavigationBar;
|
||||
final ScrollController? scrollController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -22,6 +24,7 @@ class AppPage extends StatelessWidget {
|
||||
body: SafeArea(
|
||||
top: appBar == null,
|
||||
child: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 24),
|
||||
child: child,
|
||||
),
|
||||
@@ -145,14 +148,20 @@ class Eyebrow extends StatelessWidget {
|
||||
}
|
||||
|
||||
class SpacedColumn extends StatelessWidget {
|
||||
const SpacedColumn({super.key, required this.children, this.spacing = 12});
|
||||
const SpacedColumn({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.spacing = 12,
|
||||
this.crossAxisAlignment = CrossAxisAlignment.start,
|
||||
});
|
||||
|
||||
final List<Widget> children;
|
||||
final double spacing;
|
||||
final CrossAxisAlignment crossAxisAlignment;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: crossAxisAlignment,
|
||||
children: [
|
||||
for (var index = 0; index < children.length; index++) ...[
|
||||
children[index],
|
||||
|
||||
@@ -34,6 +34,43 @@ VocabularyItem? findCourseLexicon(String text) {
|
||||
}, orElse: () => null);
|
||||
}
|
||||
|
||||
/// Extracts all distinct course lexicon phrases/words that appear within [text].
|
||||
List<VocabularyItem> extractCourseLexiconPhrases(String text) {
|
||||
final normalized = text.toLowerCase().replaceAll('’', "'").trim();
|
||||
if (normalized.isEmpty) return const [];
|
||||
final matched = <VocabularyItem>[];
|
||||
final seen = <String>{};
|
||||
for (final item in courseLexiconEntries) {
|
||||
final word = item.word.toLowerCase().replaceAll('’', "'");
|
||||
if (word.isEmpty || word == normalized) continue;
|
||||
final pattern = RegExp(
|
||||
r'(?<![a-zA-Z0-9])' + RegExp.escape(word) + r'(?![a-zA-Z0-9])',
|
||||
caseSensitive: false,
|
||||
);
|
||||
if (pattern.hasMatch(normalized)) {
|
||||
if (seen.add(word)) {
|
||||
matched.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
/// Determines whether the input text looks like a multi-word phrase or complete sentence.
|
||||
bool isSentenceQuery(String text) {
|
||||
final trimmed = text.trim();
|
||||
if (trimmed.isEmpty) return false;
|
||||
final words = trimmed.split(RegExp(r'\s+'));
|
||||
return words.length >= 3 ||
|
||||
trimmed.contains('.') ||
|
||||
trimmed.contains('?') ||
|
||||
trimmed.contains('!') ||
|
||||
trimmed.contains(';') ||
|
||||
trimmed.contains(',') ||
|
||||
trimmed.contains('。') ||
|
||||
trimmed.length > 25;
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -43,12 +80,14 @@ class LexiconText extends StatefulWidget {
|
||||
required this.state,
|
||||
this.style,
|
||||
this.textAlign,
|
||||
this.showSentenceAction = false,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final AppState state;
|
||||
final TextStyle? style;
|
||||
final TextAlign? textAlign;
|
||||
final bool showSentenceAction;
|
||||
|
||||
@override
|
||||
State<LexiconText> createState() => _LexiconTextState();
|
||||
@@ -135,15 +174,34 @@ class _LexiconTextState extends State<LexiconText> {
|
||||
}
|
||||
},
|
||||
),
|
||||
if (selectedText.isNotEmpty)
|
||||
TextButton.icon(
|
||||
onPressed: () => showLexiconLookup(
|
||||
context,
|
||||
state: widget.state,
|
||||
initialText: selectedText,
|
||||
if (selectedText.isNotEmpty || widget.showSentenceAction)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (selectedText.isNotEmpty)
|
||||
TextButton.icon(
|
||||
onPressed: () => showLexiconLookup(
|
||||
context,
|
||||
state: widget.state,
|
||||
initialText: selectedText,
|
||||
),
|
||||
icon: const Icon(Icons.translate_outlined, size: 16),
|
||||
label: const Text('查询已选文本'),
|
||||
),
|
||||
if (widget.showSentenceAction && widget.text.trim().isNotEmpty)
|
||||
TextButton.icon(
|
||||
onPressed: () => showLexiconLookup(
|
||||
context,
|
||||
state: widget.state,
|
||||
initialText: widget.text,
|
||||
),
|
||||
icon: const Icon(Icons.psychology_alt_outlined, size: 16),
|
||||
label: const Text('解析整句与短语'),
|
||||
),
|
||||
],
|
||||
),
|
||||
icon: const Icon(Icons.translate_outlined, size: 16),
|
||||
label: const Text('查询已选文本'),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -174,17 +232,25 @@ class _LexiconLookupSheet extends StatefulWidget {
|
||||
class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
late final TextEditingController controller;
|
||||
VocabularyItem? entry;
|
||||
List<VocabularyItem> localPhrases = [];
|
||||
SentenceAnalysisResult? sentenceAnalysis;
|
||||
bool requestingSentenceAnalysis = false;
|
||||
String? sentenceAnalysisError;
|
||||
bool requestingTemporaryDefinition = false;
|
||||
String? temporaryDefinition;
|
||||
String? temporaryError;
|
||||
final Set<String> _addedToReview = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = TextEditingController(text: widget.initialText);
|
||||
entry = findCourseLexicon(widget.initialText);
|
||||
final initial = widget.initialText.trim();
|
||||
controller = TextEditingController(text: initial);
|
||||
entry = findCourseLexicon(initial);
|
||||
localPhrases = extractCourseLexiconPhrases(initial);
|
||||
sentenceAnalysis = widget.state.sentenceAnalysisFor(initial);
|
||||
temporaryDefinition = entry == null
|
||||
? widget.state.temporaryDefinitionFor(widget.initialText)?.definition
|
||||
? widget.state.temporaryDefinitionFor(initial)?.definition
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -194,13 +260,50 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _lookup() => setState(() {
|
||||
entry = findCourseLexicon(controller.text);
|
||||
temporaryDefinition = widget.state
|
||||
.temporaryDefinitionFor(controller.text)
|
||||
?.definition;
|
||||
temporaryError = null;
|
||||
});
|
||||
void _lookup() {
|
||||
final query = controller.text.trim();
|
||||
setState(() {
|
||||
entry = findCourseLexicon(query);
|
||||
localPhrases = extractCourseLexiconPhrases(query);
|
||||
sentenceAnalysis = widget.state.sentenceAnalysisFor(query);
|
||||
temporaryDefinition = widget.state
|
||||
.temporaryDefinitionFor(query)
|
||||
?.definition;
|
||||
sentenceAnalysisError = null;
|
||||
temporaryError = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _requestSentenceAnalysis() async {
|
||||
final text = controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
setState(() {
|
||||
requestingSentenceAnalysis = true;
|
||||
sentenceAnalysisError = null;
|
||||
});
|
||||
|
||||
final result = await AiService.instance.analyzeSentence(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
text: text,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
requestingSentenceAnalysis = false;
|
||||
sentenceAnalysis = result;
|
||||
sentenceAnalysisError = result == null
|
||||
? (widget.state.aiProvider.name == 'mock'
|
||||
? '未能完成解析,请稍后重试。'
|
||||
: 'AI 解析暂不可用,请检查网络或 AI 服务配置。')
|
||||
: null;
|
||||
});
|
||||
|
||||
if (result != null) {
|
||||
widget.state.cacheSentenceAnalysis(result);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestTemporaryDefinition() async {
|
||||
final text = controller.text.trim();
|
||||
@@ -232,111 +335,568 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
0,
|
||||
20,
|
||||
24 + MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('课程词典 · 短语优先'),
|
||||
TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (_) => _lookup(),
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入或粘贴英文词、短语、句子',
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: _lookup,
|
||||
void _addPhraseToReview(PhraseBreakdownItem phrase) {
|
||||
widget.state.addPhraseToReview(
|
||||
phrase: phrase.phrase,
|
||||
meaning: phrase.meaning,
|
||||
ipa: phrase.ipa,
|
||||
usageNote: phrase.usageNote,
|
||||
contextSentence: controller.text.trim(),
|
||||
);
|
||||
setState(() {
|
||||
_addedToReview.add(phrase.phrase.toLowerCase());
|
||||
});
|
||||
try {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('已将短语 "${phrase.phrase}" 加入复习计划'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _addVocabItemToReview(VocabularyItem item) {
|
||||
widget.state.addSavedWord(item);
|
||||
setState(() {
|
||||
_addedToReview.add(item.word.toLowerCase());
|
||||
});
|
||||
try {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('已将 "${item.word}" 加入复习计划'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Widget _buildSentenceAnalysisSection(SentenceAnalysisResult analysis) {
|
||||
return SpacedColumn(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Translation
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: SpacedColumn(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.translate, size: 16, color: AppColors.green),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'中文整句翻译',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.green,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
analysis.translation,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Pattern, Pronunciation Tips, Grammar Note
|
||||
if (analysis.sentencePattern != null ||
|
||||
analysis.pronunciationTips != null ||
|
||||
analysis.grammarNote != null)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: SpacedColumn(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (analysis.sentencePattern != null &&
|
||||
analysis.sentencePattern!.isNotEmpty) ...[
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lightbulb_outline,
|
||||
size: 16,
|
||||
color: Color(0xFFD97706),
|
||||
),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'核心句型',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFD97706),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
analysis.sentencePattern!,
|
||||
style: const TextStyle(fontSize: 14, height: 1.3),
|
||||
),
|
||||
],
|
||||
if (analysis.pronunciationTips != null &&
|
||||
analysis.pronunciationTips!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.record_voice_over_outlined,
|
||||
size: 16,
|
||||
color: AppColors.green,
|
||||
),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'口语连读与发音',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.green,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
analysis.pronunciationTips!,
|
||||
style: const TextStyle(fontSize: 14, height: 1.3),
|
||||
),
|
||||
],
|
||||
if (analysis.grammarNote != null &&
|
||||
analysis.grammarNote!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.menu_book_outlined,
|
||||
size: 16,
|
||||
color: Color(0xFF4B5563),
|
||||
),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'语法要点',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF4B5563),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
analysis.grammarNote!,
|
||||
style: const TextStyle(fontSize: 14, height: 1.3),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (entry == null) ...[
|
||||
const SectionCard(
|
||||
child: Text('本地词典未收录。可以请求临时释义;它会标记为待审核,不能加入复习或影响掌握度。'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: requestingTemporaryDefinition
|
||||
? null
|
||||
: _requestTemporaryDefinition,
|
||||
icon: const Icon(Icons.auto_awesome_outlined),
|
||||
label: Text(requestingTemporaryDefinition ? '查询中…' : '生成临时释义'),
|
||||
),
|
||||
if (temporaryError != null)
|
||||
Text(
|
||||
temporaryError!,
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
|
||||
// Phrases Breakdown
|
||||
if (analysis.phrases.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.auto_stories_outlined,
|
||||
size: 18,
|
||||
color: AppColors.green,
|
||||
),
|
||||
if (temporaryDefinition != null)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Text('待审核临时释义\n$temporaryDefinition'),
|
||||
Text(
|
||||
'仅保存在本机,不会加入复习或影响掌握度。',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'重点短语与搭配 (${analysis.phrases.length})',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final phrase in analysis.phrases)
|
||||
SectionCard(
|
||||
child: SpacedColumn(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
phrase.phrase,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (phrase.ipa != null && phrase.ipa!.isNotEmpty)
|
||||
Text(
|
||||
phrase.ipa!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.volume_up_outlined, size: 20),
|
||||
tooltip: '播放读音',
|
||||
onPressed: () => VoiceService.instance.speak(phrase.phrase),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_addedToReview.contains(phrase.phrase.toLowerCase())
|
||||
? const Chip(
|
||||
label: Text('已在复习', style: TextStyle(fontSize: 12)),
|
||||
avatar: Icon(Icons.check, size: 14, color: AppColors.green),
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
)
|
||||
: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
),
|
||||
onPressed: () => _addPhraseToReview(phrase),
|
||||
icon: const Icon(Icons.bookmark_add_outlined, size: 14),
|
||||
label: const Text('加复习', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
phrase.meaning,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
widget.state.removeTemporaryDefinition(controller.text);
|
||||
setState(() => temporaryDefinition = null);
|
||||
},
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
label: const Text('删除此临时释义'),
|
||||
),
|
||||
if (phrase.usageNote != null && phrase.usageNote!.isNotEmpty)
|
||||
Text(
|
||||
'用法:${phrase.usageNote}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Cache source / re-analyze footer
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'解析引擎:${analysis.provider} · 本机已缓存',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: requestingSentenceAnalysis ? null : _requestSentenceAnalysis,
|
||||
icon: const Icon(Icons.refresh, size: 14),
|
||||
label: const Text('重新解析', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final queryText = controller.text.trim();
|
||||
final isSentence = isSentenceQuery(queryText);
|
||||
final hasExactCourseEntry = entry != null && !isSentence;
|
||||
|
||||
return SafeArea(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.85,
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
0,
|
||||
20,
|
||||
24 + MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: SpacedColumn(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Eyebrow('查词与整句深度解析'),
|
||||
if (sentenceAnalysis != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.softGreen,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Text(
|
||||
'已解析',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.green,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Search bar
|
||||
TextField(
|
||||
controller: controller,
|
||||
autofocus: queryText.isEmpty,
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (_) => _lookup(),
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入英文单词、短语或整句',
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: queryText.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear, size: 18),
|
||||
onPressed: () {
|
||||
controller.clear();
|
||||
_lookup();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
|
||||
// Audio row for current query
|
||||
if (queryText.isNotEmpty)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => VoiceService.instance.speak(queryText),
|
||||
icon: const Icon(Icons.volume_up_outlined, size: 18),
|
||||
label: const Text('朗读原文'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
VoiceService.instance.speak(queryText, slow: true),
|
||||
icon: const Icon(Icons.slow_motion_video_outlined, size: 18),
|
||||
label: const Text('慢速朗读'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Text(
|
||||
entry!.word,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
if (entry!.ipa != null)
|
||||
Text(entry!.ipa!, style: Theme.of(context).textTheme.bodyMedium),
|
||||
Text(entry!.meaning, style: const TextStyle(fontSize: 18)),
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: Text('${entry!.example}\n${entry!.exampleMeaning}'),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => VoiceService.instance.speak(entry!.word),
|
||||
icon: const Icon(Icons.volume_up_outlined),
|
||||
label: const Text('播放'),
|
||||
),
|
||||
|
||||
// Mode 1: Exact course lexicon single word card
|
||||
if (hasExactCourseEntry) ...[
|
||||
Text(
|
||||
entry!.word,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
VoiceService.instance.speak(entry!.word, slow: true),
|
||||
icon: const Icon(Icons.slow_motion_video_outlined),
|
||||
label: const Text('慢放'),
|
||||
),
|
||||
if (entry!.ipa != null)
|
||||
Text(entry!.ipa!, style: Theme.of(context).textTheme.bodyMedium),
|
||||
Text(entry!.meaning, style: const TextStyle(fontSize: 18)),
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: Text('${entry!.example}\n${entry!.exampleMeaning}'),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: PrimaryButton(
|
||||
label: _addedToReview.contains(entry!.word.toLowerCase())
|
||||
? '已在复习中'
|
||||
: '加入复习',
|
||||
onPressed: () {
|
||||
_addVocabItemToReview(entry!);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Provide option to analyze further with AI if user wants deeper context
|
||||
if (sentenceAnalysis == null)
|
||||
OutlinedButton.icon(
|
||||
onPressed: requestingSentenceAnalysis ? null : _requestSentenceAnalysis,
|
||||
icon: const Icon(Icons.auto_awesome_outlined, size: 16),
|
||||
label: Text(requestingSentenceAnalysis ? 'AI 解析中…' : '请求 AI 句型与深度解析'),
|
||||
),
|
||||
],
|
||||
),
|
||||
PrimaryButton(
|
||||
label: '加入复习',
|
||||
onPressed: () {
|
||||
widget.state.addSavedWord(entry!);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
// Mode 2: Sentence / Phrase Analysis Section
|
||||
if (sentenceAnalysis != null) ...[
|
||||
_buildSentenceAnalysisSection(sentenceAnalysis!),
|
||||
],
|
||||
|
||||
// Local phrases extracted from sentence (offline fallback/supplement)
|
||||
if (localPhrases.isNotEmpty && sentenceAnalysis == null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.layers_outlined, size: 18, color: AppColors.green),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'本地词典匹配到的短语 (${localPhrases.length})',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final phraseItem in localPhrases)
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: SpacedColumn(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
phraseItem.word,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.volume_up_outlined, size: 18),
|
||||
onPressed: () =>
|
||||
VoiceService.instance.speak(phraseItem.word),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_addedToReview.contains(phraseItem.word.toLowerCase())
|
||||
? const Icon(Icons.check, size: 18, color: AppColors.green)
|
||||
: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
),
|
||||
onPressed: () =>
|
||||
_addVocabItemToReview(phraseItem),
|
||||
child: const Text('+ 复习', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(phraseItem.meaning, style: const TextStyle(fontSize: 14)),
|
||||
if (phraseItem.example.isNotEmpty)
|
||||
Text(
|
||||
'例:${phraseItem.example} (${phraseItem.exampleMeaning})',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// If sentenceAnalysis is not yet available, show AI Trigger Section
|
||||
if (sentenceAnalysis == null && !hasExactCourseEntry) ...[
|
||||
SectionCard(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Text(
|
||||
isSentence
|
||||
? '需要整个句子的翻译、句型解析、口语连读技巧与重点短语拆解?'
|
||||
: '本地词典未精确收录。可使用 AI 智能剖析其含义、发音、短语搭配与例句。',
|
||||
),
|
||||
if (requestingSentenceAnalysis)
|
||||
const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
Text('AI 正在深度解析句子结构与短语…'),
|
||||
],
|
||||
)
|
||||
else
|
||||
PrimaryButton(
|
||||
label: 'AI 深度解析句子与短语',
|
||||
icon: Icons.auto_awesome,
|
||||
onPressed: _requestSentenceAnalysis,
|
||||
),
|
||||
if (sentenceAnalysisError != null)
|
||||
Text(
|
||||
sentenceAnalysisError!,
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Legacy fallback for quick temporary definition if learner only wants a gloss
|
||||
if (temporaryDefinition == null)
|
||||
TextButton.icon(
|
||||
onPressed: requestingTemporaryDefinition
|
||||
? null
|
||||
: _requestTemporaryDefinition,
|
||||
icon: const Icon(Icons.text_fields_outlined, size: 16),
|
||||
label: Text(
|
||||
requestingTemporaryDefinition
|
||||
? '查询简短释义中…'
|
||||
: '仅生成简短释义 (待审核)',
|
||||
),
|
||||
),
|
||||
|
||||
if (temporaryError != null)
|
||||
Text(
|
||||
temporaryError!,
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
|
||||
if (temporaryDefinition != null)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Text('待审核临时释义\n$temporaryDefinition'),
|
||||
Text(
|
||||
'仅保存在本机,不会加入复习或影响掌握度。',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
widget.state.removeTemporaryDefinition(controller.text);
|
||||
setState(() => temporaryDefinition = null);
|
||||
},
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
label: const Text('删除此临时释义'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user