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 get courseLexiconEntries { final entries = [ ...a0SeedLessons.expand((lesson) => lesson.vocabulary), ...a0SegmentVocabulary.values.expand((items) => items), ]; final seen = {}; 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().firstWhere((entry) { final word = entry!.word.toLowerCase().replaceAll('’', "'"); if (normalized == word) return true; final pattern = RegExp( r'(? 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 createState() => _LexiconTextState(); } class _LexiconTextState extends State { final List _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'(? RegExp.escape(item.word).replaceAll('’', "['’]"), ) .join('|') + r')(?![a-zA-Z0-9])', caseSensitive: false, ); final spans = []; 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 showLexiconLookup( BuildContext context, { required AppState state, String initialText = '', }) => showModalBottomSheet( 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 _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); }, ), ], ], ), ), ); }