Files
English/kouyu_english/lib/widgets/lexicon_lookup.dart
T

343 lines
11 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/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('', "'").trim();
if (normalized.isEmpty) return null;
return courseLexiconEntries.cast<VocabularyItem?>().firstWhere((entry) {
final word = entry!.word.toLowerCase().replaceAll('', "'");
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);
}
/// 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,
});
final String text;
final AppState state;
final TextStyle? style;
final TextAlign? textAlign;
@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)
TextButton.icon(
onPressed: () => showLexiconLookup(
context,
state: widget.state,
initialText: selectedText,
),
icon: const Icon(Icons.translate_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;
bool requestingTemporaryDefinition = false;
String? temporaryDefinition;
String? temporaryError;
@override
void initState() {
super.initState();
controller = TextEditingController(text: widget.initialText);
entry = findCourseLexicon(widget.initialText);
temporaryDefinition = entry == null
? widget.state.temporaryDefinitionFor(widget.initialText)?.definition
: null;
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
void _lookup() => setState(() {
entry = findCourseLexicon(controller.text);
temporaryDefinition = widget.state
.temporaryDefinitionFor(controller.text)
?.definition;
temporaryError = null;
});
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,
);
}
}
@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,
),
),
),
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),
),
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('删除此临时释义'),
),
],
),
),
] 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('播放'),
),
),
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('慢放'),
),
),
],
),
PrimaryButton(
label: '加入复习',
onPressed: () {
widget.state.addSavedWord(entry!);
Navigator.pop(context);
},
),
],
],
),
),
);
}