// 搜索页面 — 搜索历史 + 热门搜索 + 关键词高亮 + 分类 Tab // // 通过 SearchBloc 驱动搜索状态: // - 关键词输入 → SearchByKeyword event // - 标签点击 → SearchByTag event // - 心情选择 → SearchByMood event // - Tab 切换 → SearchTabChanged event // - 清除按钮 → SearchClear event // 搜索结果由 BlocBuilder 响应式渲染。 import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_radius.dart'; import '../../../core/utils/mood_utils.dart'; import '../../../data/models/journal_entry.dart'; import '../bloc/search_bloc.dart'; /// 搜索页面 — 搜索历史 + 热门搜索 + 结果分类 class SearchPage extends StatefulWidget { const SearchPage({super.key}); @override State createState() => _SearchPageState(); } class _SearchPageState extends State { final _searchController = TextEditingController(); final _searchFocusNode = FocusNode(); // 热门搜索占位数据 final _hotSearches = ['日常', '学校', '旅行', '美食', '读书', '心情', '手账', '贴纸']; @override void initState() { super.initState(); // 自动弹出键盘 WidgetsBinding.instance.addPostFrameCallback((_) { _searchFocusNode.requestFocus(); }); } @override void dispose() { _searchController.dispose(); _searchFocusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final isDark = theme.brightness == Brightness.dark; return BlocBuilder( builder: (context, state) { return Scaffold( body: SafeArea( child: Column( children: [ // 6A: 搜索头部 — 返回 + 输入框 + 取消 _buildSearchHeader( context, theme, colorScheme, isDark), // 6C: 结果分类 Tab(有结果时显示) if (state case SearchLoaded(:final hasActiveFilter) when hasActiveFilter) _buildResultTabs(context, state), // 主体内容 Expanded( child: _buildBody( context, theme, colorScheme, isDark, state), ), ], ), ), ); }, ); } // ===== 6A: 搜索头部 ===== Widget _buildSearchHeader( BuildContext context, ThemeData theme, ColorScheme colorScheme, bool isDark, ) { final surfaceWarmColor = isDark ? AppColors.surfaceWarmDark : AppColors.surfaceWarmLight; return Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), child: Row( children: [ // 返回按钮 SizedBox( width: 44, height: 44, child: IconButton( onPressed: () => Navigator.of(context).pop(), icon: Icon( Icons.arrow_back_ios_new, size: 18, color: isDark ? AppColors.fgDark : AppColors.fgLight, ), style: IconButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.pill), ), ), ), ), const SizedBox(width: 4), // 搜索输入框 Expanded( child: SizedBox( height: 44, child: TextField( controller: _searchController, focusNode: _searchFocusNode, decoration: InputDecoration( hintText: '搜索日记...', hintStyle: theme.textTheme.bodyLarge?.copyWith( color: isDark ? AppColors.mutedDark : AppColors.mutedLight, ), prefixIcon: Icon( Icons.search, size: 20, color: isDark ? AppColors.mutedDark : AppColors.mutedLight, ), suffixIcon: _searchController.text.isNotEmpty ? IconButton( icon: Icon( Icons.clear, size: 18, color: isDark ? AppColors.mutedDark : AppColors.mutedLight, ), onPressed: () { _searchController.clear(); _clearSearch(); }, ) : null, filled: true, fillColor: surfaceWarmColor, border: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.pill), borderSide: BorderSide.none, ), contentPadding: const EdgeInsets.symmetric(horizontal: 16), isDense: true, ), textInputAction: TextInputAction.search, onSubmitted: (value) { if (value.trim().isNotEmpty) { context .read() .add(SearchByKeyword(value.trim())); } }, ), ), ), const SizedBox(width: 4), // 取消按钮 TextButton( onPressed: () => Navigator.of(context).pop(), child: Text( '取消', style: theme.textTheme.bodyMedium?.copyWith( color: AppColors.accent, fontWeight: FontWeight.w500, ), ), ), ], ), ); } // ===== 6C: 结果分类 Tab ===== Widget _buildResultTabs( BuildContext context, SearchLoaded state) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: Row( children: SearchResultTab.values.map((tab) { final isActive = state.activeTab == tab; return GestureDetector( onTap: () { context .read() .add(SearchTabChanged(tab)); }, child: Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), decoration: BoxDecoration( border: Border( bottom: BorderSide( color: isActive ? AppColors.accent : Colors.transparent, width: 3, ), ), ), child: Text( tab.label, style: theme.textTheme.bodySmall?.copyWith( color: isActive ? AppColors.accent : (isDark ? AppColors.mutedDark : AppColors.mutedLight), fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, ), ), ), ); }).toList(), ), ); } // ===== 主体内容 ===== Widget _buildBody( BuildContext context, ThemeData theme, ColorScheme colorScheme, bool isDark, SearchState state, ) { return switch (state) { SearchInitial() => _buildSuggestions( context, theme, colorScheme, isDark, []), SearchLoading() => const Center(child: CircularProgressIndicator()), SearchLoaded( :final results, :final activeMood, :final activeTag, :final activeKeyword, :final activeTab, :final searchHistory, ) => hasActiveFilter(activeMood, activeTag, activeKeyword) ? _buildFilteredResults( context, theme, colorScheme, isDark, results, activeKeyword, activeTab, ) : _buildSuggestions( context, theme, colorScheme, isDark, searchHistory), SearchError(:final message) => _buildError(colorScheme, message), }; } bool hasActiveFilter(String? mood, String? tag, String? keyword) => mood != null || tag != null || keyword != null; // ===== 6B: 搜索历史 + 热门搜索 ===== Widget _buildSuggestions( BuildContext context, ThemeData theme, ColorScheme colorScheme, bool isDark, List searchHistory, ) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 搜索历史 if (searchHistory.isNotEmpty) ...[ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '最近搜索', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), GestureDetector( onTap: _clearSearch, child: Text( '清除', style: theme.textTheme.bodySmall?.copyWith( color: AppColors.accent, ), ), ), ], ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: searchHistory.map((keyword) { return ActionChip( label: Text(keyword), onPressed: () { _searchController.text = keyword; context .read() .add(SearchByKeyword(keyword)); }, ); }).toList(), ), const SizedBox(height: 24), ], // 热门搜索 Text( '热门搜索', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: _hotSearches.map((keyword) { return ActionChip( label: Text(keyword), onPressed: () { _searchController.text = keyword; context .read() .add(SearchByKeyword(keyword)); }, ); }).toList(), ), const SizedBox(height: 24), // 心情筛选 Text( '按心情筛选', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 12), Wrap( spacing: 12, runSpacing: 12, children: Mood.values.map((mood) { final color = AppColors.moodColors[mood.value] ?? colorScheme.primary; return GestureDetector( onTap: () { _searchController.text = moodToLabel(mood); context .read() .add(SearchByMood(mood)); }, child: Column( children: [ Container( width: 48, height: 48, decoration: BoxDecoration( shape: BoxShape.circle, color: color.withValues(alpha: 0.15), ), alignment: Alignment.center, child: Text(moodToEmoji(mood), style: const TextStyle(fontSize: 24)), ), const SizedBox(height: 4), Text(moodToLabel(mood), style: theme.textTheme.labelSmall), ], ), ); }).toList(), ), ], ), ); } // ===== 按分类 Tab 过滤结果 ===== Widget _buildFilteredResults( BuildContext context, ThemeData theme, ColorScheme colorScheme, bool isDark, List results, String? keyword, SearchResultTab activeTab, ) { if (results.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.search_off_rounded, size: 48, color: colorScheme.onSurface.withValues(alpha: 0.2)), const SizedBox(height: 12), Text( '没有找到匹配的日记', style: theme.textTheme.bodyMedium?.copyWith( color: colorScheme.onSurface.withValues(alpha: 0.5), ), ), const SizedBox(height: 16), FilledButton.tonal( onPressed: _clearSearch, child: const Text('清除筛选'), ), ], ), ); } // 根据活跃 Tab 选择展示内容 switch (activeTab) { case SearchResultTab.all: case SearchResultTab.journal: return _buildJournalResults( context, theme, colorScheme, isDark, results, keyword); case SearchResultTab.template: return _buildTemplateResults(theme, isDark); case SearchResultTab.tag: return _buildTagResults(theme, isDark, results); } } // ===== 日记结果列表 ===== Widget _buildJournalResults( BuildContext context, ThemeData theme, ColorScheme colorScheme, bool isDark, List results, String? keyword, ) { return ListView.separated( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), itemCount: results.length, separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (context, index) { final entry = results[index]; return _JournalCard( entry: entry, keyword: keyword, ); }, ); } // ===== 6E: 模板结果(占位) ===== Widget _buildTemplateResults(ThemeData theme, bool isDark) { // Phase 1 占位 — 模板功能未实现 return GridView.builder( padding: const EdgeInsets.all(16), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, mainAxisSpacing: 12, crossAxisSpacing: 12, childAspectRatio: 0.75, ), itemCount: 4, itemBuilder: (context, index) { final gradients = [ const [AppColors.accent, AppColors.tertiary], const [AppColors.secondary, AppColors.tertiary], const [AppColors.rose, AppColors.accent], const [AppColors.tertiary, AppColors.secondary], ]; final labels = ['每日心情', '旅行手账', '读书笔记', '日常记录']; return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppRadius.md), gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: gradients[index], ), ), child: Stack( children: [ // 装饰圆 Positioned( right: -10, bottom: -10, child: Container( width: 60, height: 60, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white.withValues(alpha: 0.12), ), ), ), Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.end, children: [ Text( labels[index], style: theme.textTheme.titleSmall?.copyWith( color: Colors.white, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 4), Text( '即将上线', style: theme.textTheme.bodySmall?.copyWith( color: Colors.white.withValues(alpha: 0.7), ), ), ], ), ), ], ), ); }, ); } // ===== 6E: 标签结果 ===== Widget _buildTagResults( ThemeData theme, bool isDark, List results) { // 从搜索结果中提取所有标签及其频次 final tagFreq = {}; for (final entry in results) { for (final tag in entry.tags) { tagFreq[tag] = (tagFreq[tag] ?? 0) + 1; } } final sortedTags = tagFreq.keys.toList() ..sort((a, b) => tagFreq[b]!.compareTo(tagFreq[a]!)); if (sortedTags.isEmpty) { return Center( child: Text( '没有找到相关标签', style: theme.textTheme.bodyMedium?.copyWith( color: isDark ? AppColors.mutedDark : AppColors.mutedLight, ), ), ); } return Wrap( spacing: 10, runSpacing: 10, children: sortedTags.map((tag) { return Container( padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 10, ), decoration: BoxDecoration( color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight, borderRadius: BorderRadius.circular(AppRadius.pill), border: Border.all( color: isDark ? AppColors.borderDark : AppColors.borderLight, ), ), child: Text( '$tag (${tagFreq[tag]})', style: theme.textTheme.bodyMedium, ), ); }).toList(), ).padAll(16); } /// 错误提示 Widget _buildError(ColorScheme colorScheme, String message) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline_rounded, size: 48, color: colorScheme.error.withValues(alpha: 0.6)), const SizedBox(height: 12), Text( message, style: TextStyle( color: colorScheme.error, fontSize: 14, ), ), const SizedBox(height: 16), FilledButton.tonal( onPressed: _clearSearch, child: const Text('重试'), ), ], ), ); } void _clearSearch() { _searchController.clear(); context.read().add(const SearchClear()); } } // ===== 6D: 关键词高亮辅助函数 ===== /// 将文本中的关键词部分用高亮样式包裹 Widget _highlightText(String text, String? keyword) { if (keyword == null || keyword.isEmpty || !text.toLowerCase().contains(keyword.toLowerCase())) { return Text(text); } final lowerText = text.toLowerCase(); final lowerKeyword = keyword.toLowerCase(); final spans = []; int start = 0; while (start < text.length) { final index = lowerText.indexOf(lowerKeyword, start); if (index == -1) { spans.add(TextSpan(text: text.substring(start))); break; } if (index > start) { spans.add(TextSpan(text: text.substring(start, index))); } spans.add(TextSpan( text: text.substring(index, index + keyword.length), style: const TextStyle( color: AppColors.accent, backgroundColor: AppColors.tertiarySoftLight, fontWeight: FontWeight.w600, ), )); start = index + keyword.length; } return RichText(text: TextSpan(style: const TextStyle(), children: spans)); } /// 日记卡片 — 在搜索结果中展示单条日记摘要(支持关键词高亮) class _JournalCard extends StatelessWidget { final JournalEntry entry; final String? keyword; const _JournalCard({required this.entry, this.keyword}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final moodColor = AppColors.moodColors[entry.mood.value] ?? colorScheme.primary; return Card( elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: BorderSide(color: colorScheme.outlineVariant), ), child: InkWell( borderRadius: BorderRadius.circular(16), onTap: () { context.push('/editor?id=${entry.id}'); }, child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 第一行:心情 emoji + 标题(高亮)+ 日期 Row( children: [ Text( moodToEmoji(entry.mood), style: const TextStyle(fontSize: 20), ), const SizedBox(width: 8), Expanded( child: _highlightText(entry.title, keyword), ), Text( DateFormat('MM/dd').format(entry.date), style: theme.textTheme.labelSmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ], ), // 第二行:标签 if (entry.tags.isNotEmpty) ...[ const SizedBox(height: 8), Wrap( spacing: 6, runSpacing: 4, children: entry.tags.take(4).map((tag) { return Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2, ), decoration: BoxDecoration( color: moodColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Text( tag, style: theme.textTheme.labelSmall?.copyWith( color: moodColor, fontSize: 11, ), ), ); }).toList(), ), ], ], ), ), ), ); } } /// Padding 扩展 — 简化 Wrap 的 padding extension _PadAll on Widget { Widget padAll(double value) => Padding( padding: EdgeInsets.all(value), child: this, ); }