343 lines
11 KiB
Dart
343 lines
11 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('’', "'").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);
|
||
},
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|