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); } /// Extracts all distinct course lexicon phrases/words that appear within [text]. List extractCourseLexiconPhrases(String text) { final normalized = text.toLowerCase().replaceAll('’', "'").trim(); if (normalized.isEmpty) return const []; final matched = []; final seen = {}; for (final item in courseLexiconEntries) { final word = item.word.toLowerCase().replaceAll('’', "'"); if (word.isEmpty || word == normalized) continue; final pattern = RegExp( r'(?= 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 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 || 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 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; List localPhrases = []; SentenceAnalysisResult? sentenceAnalysis; bool requestingSentenceAnalysis = false; String? sentenceAnalysisError; bool requestingTemporaryDefinition = false; String? temporaryDefinition; String? temporaryError; final Set _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 _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 _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('删除此临时释义'), ), ], ), ), ], ], ), ), ), ); } }