923 lines
33 KiB
Dart
923 lines
33 KiB
Dart
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/seed_courses.dart';
|
||
import '../core/voice_service.dart';
|
||
import 'app_widgets.dart';
|
||
|
||
List<VocabularyItem> get courseLexiconEntries {
|
||
final entries = <VocabularyItem>[
|
||
...a0SeedLessons.expand((lesson) => lesson.vocabulary),
|
||
...a0SegmentVocabulary.values.expand((items) => items),
|
||
];
|
||
final seen = <String>{};
|
||
return entries.where((item) => seen.add(item.word.toLowerCase())).toList()
|
||
..sort((a, b) => b.word.length.compareTo(a.word.length));
|
||
}
|
||
|
||
/// 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;
|
||
return courseLexiconEntries.cast<VocabularyItem?>().firstWhere((entry) {
|
||
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);
|
||
}
|
||
|
||
/// 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;
|
||
}
|
||
|
||
/// 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 = '';
|
||
|
||
@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();
|
||
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,
|
||
);
|
||
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);
|
||
if (entry == null) {
|
||
spans.add(TextSpan(text: matched));
|
||
} else {
|
||
final recognizer = TapGestureRecognizer()
|
||
..onTap = () => showLexiconLookup(
|
||
context,
|
||
state: widget.state,
|
||
initialText: entry.word,
|
||
);
|
||
_recognizers.add(recognizer);
|
||
spans.add(
|
||
TextSpan(
|
||
text: matched,
|
||
recognizer: recognizer,
|
||
style: const TextStyle(
|
||
color: AppColors.green,
|
||
decoration: TextDecoration.underline,
|
||
decorationColor: AppColors.green,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
cursor = match.end;
|
||
}
|
||
if (cursor < widget.text.length) {
|
||
spans.add(TextSpan(text: widget.text.substring(cursor)));
|
||
}
|
||
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.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.temporaryDefinition(
|
||
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('已将短语 "${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),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
|
||
// Phrases Breakdown
|
||
if (analysis.phrases.isNotEmpty) ...[
|
||
const SizedBox(height: 4),
|
||
Row(
|
||
children: [
|
||
const 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: 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,
|
||
),
|
||
),
|
||
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('慢速朗读'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
|
||
// 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}'),
|
||
),
|
||
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: [
|
||
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('删除此临时释义'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|