Files
English/kouyu_english/lib/widgets/lexicon_lookup.dart
T
shenleiandClaude Opus 5 6b42b7abc3 feat: 独立词库、三向认词与当日快闪复习
## 独立词库

理解词原先只能跟着课程单元走,学完 A0 十课词汇量只增加约 22 个实词,
不足以解决"记不住单词"。新增一份独立词库 assets/words/wordbank.json
(2748 词,A1–B1),挂进 receptiveWordRegistry 的合成单元 bank-A1/A2/B1,
完全复用理解词已有的状态机,不依赖课程进度,第一天就能用。

数据来源、许可与合成规则记在 tool/words/DATA-NOTE.md:CEFR-J 定等级、
公开词书提供音标、AI 重写全部释义并生成例句、OpenSubtitles 提供口语词频。
词书部分为 CC BY-NC-SA 4.0 且上游权利不明,仅供个人非商用;
若要分发或上架,须替换音标那一列。

## 背单词机制

- 间隔阶梯 1/3/7/15/30/60/120 天,连续答对上一级,答错回第一级。
  原先首次答对后要等 7 天才复习,正是"第二天就忘"的成因。
- 每日新词上限(10 分钟 8 个 / 20 分钟 15 个 / 30 分钟 20 个)。
  阶梯第一级是次日,今天引入的新词就是明天的工作量。
- 新词按口语频率发放,不再按字母序 —— A1 从 a.m./ability 变成 no/not/know/just。
- 三个方向按层级轮转:看词(英→中)→ 听词(音→中)→ 想词(中→英)。
  想词题仍是选择题,不要求产出,理解词定位不变,不进升级分母。
- 单词页独立成 tab,首页今日任务卡下方给一张认词入口卡。

## 用法对照

课程 JSON 增加 usage 字段(when/reply/swap/confuse):一个句型用在什么场合、
对方通常怎么答、还能怎么说、跟哪个学过的句型容易混。
知道 How are you? 的意思,不等于知道它不是用来问名字的。

## 复习流

- 当日快闪(recap)独立成队列,不占复习预算,也不计入积压。
- 只发放当日预算内的量,其余保持到期状态等下次,不悄悄丢弃或改期。
- 答错的项隔几题后回来,而不是立刻重问。

测试 296 通过,flutter analyze 干净。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 23:54:17 +09:00

1058 lines
36 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../core/app_state.dart';
import '../core/app_theme.dart';
import '../core/ai_service.dart';
import '../core/models.dart';
import '../core/courses/courses.dart';
import '../core/voice_service.dart';
import 'app_widgets.dart';
import 'usage_card.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>{};
_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.
VocabularyItem? findCourseLexicon(String text) {
final normalized = text
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (normalized.isEmpty) return null;
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 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].
List<VocabularyItem> extractCourseLexiconPhrases(String text) {
final normalized = text
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (normalized.isEmpty) return const [];
final matched = <VocabularyItem>[];
final seen = <String>{};
for (final item in courseLexiconEntries) {
final word = item.word
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
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;
}
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 {
const LexiconText(
this.text, {
super.key,
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();
}
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() {
for (final recognizer in _recognizers) {
recognizer.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
for (final recognizer in _recognizers) {
recognizer.dispose();
}
_recognizers.clear();
if (_chunks == null || _chunks!.isEmpty) {
return Text(widget.text, style: widget.style);
}
final spans = <InlineSpan>[];
for (final chunk in _chunks!) {
final entry = chunk.entry;
if (entry == null) {
spans.add(TextSpan(text: chunk.text));
} else {
final recognizer = TapGestureRecognizer()
..onTap = () => showLexiconLookup(
context,
state: widget.state,
initialText: entry.word,
);
_recognizers.add(recognizer);
spans.add(
TextSpan(
text: chunk.text,
recognizer: recognizer,
style: TextStyle(
color: AppColors.green,
decoration: TextDecoration.underline,
decorationColor: AppColors.green,
),
),
);
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SelectableText.rich(
TextSpan(style: widget.style, children: spans),
textAlign: widget.textAlign,
onSelectionChanged: (selection, _) {
final text = selection.isValid && !selection.isCollapsed
? widget.text.substring(selection.start, selection.end).trim()
: '';
if (text != selectedText && mounted) {
setState(() => selectedText = text);
}
},
),
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('解析整句与短语'),
),
],
),
),
],
);
}
}
Future<void> showLexiconLookup(
BuildContext context, {
required AppState state,
String initialText = '',
}) => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) =>
_LexiconLookupSheet(state: state, initialText: initialText),
);
class _LexiconLookupSheet extends StatefulWidget {
const _LexiconLookupSheet({required this.state, required this.initialText});
final AppState state;
final String initialText;
@override
State<_LexiconLookupSheet> createState() => _LexiconLookupSheetState();
}
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();
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(initial)?.definition
: null;
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
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.capabilities.lexicon
.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();
if (text.isEmpty || widget.state.aiProvider.name == 'mock') {
setState(() => temporaryError = '未配置 AI 服务时,只能查询本地已审核课程词典。');
return;
}
setState(() {
requestingTemporaryDefinition = true;
temporaryError = null;
});
final definition = await AiService.instance.capabilities.lexicon.define(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
text: text,
);
if (!mounted) return;
setState(() {
requestingTemporaryDefinition = false;
temporaryDefinition = definition;
temporaryError = definition == null ? '暂时无法查询;结果不会被编造。' : null;
});
if (definition != null) {
widget.state.cacheTemporaryDefinition(
query: text,
definition: definition,
);
}
}
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('已加入单词,之后在「单词」里认它'),
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('已加入单词,之后在「单词」里认它'),
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: [
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),
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),
),
],
],
),
),
// Phrases Breakdown
if (analysis.phrases.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.auto_stories_outlined,
size: 18,
color: AppColors.green,
),
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: 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())
? 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,
),
),
if (phrase.usageNote != null && phrase.usageNote!.isNotEmpty)
Text(
'用法:${phrase.usageNote}',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
],
),
),
],
// Cache source / re-analyze footer
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'解析引擎:${analysis.provider} · 本机已缓存',
style: 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: 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('慢速朗读'),
),
),
],
),
// Mode 1: Exact course lexicon single word card
if (hasExactCourseEntry) ...[
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}'),
),
UsageCard(itemId: entry!.id, state: widget.state),
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 句型与深度解析',
),
),
],
// 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: [
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(),
)
? 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: 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: 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: 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('删除此临时释义'),
),
],
),
),
],
],
),
),
),
);
}
}