Files
nj/app/lib/features/search/views/search_page.dart
iven 7e928ae1e1
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
fix(app): 修复 P2~P4 共 10 项前端问题
P2 必须修复:
- 教师布置主题 classId 从硬编码改为班级下拉选择器
- 班级日记墙使用服务端 classId 过滤替代前端过滤
- Profile 统计栏接入 JournalRepository 真实数据
- WeeklyPage 从全硬编码改为 JournalRepository 数据驱动

P3 建议改进:
- 提取 mood_utils.dart 公共函数,消除 4 处重复定义
- 贴纸库搜索框连接 StickerBloc 按名称过滤

P4 细节打磨:
- 家长页多孩子时显示 DropdownButton 选择器
- 搜索结果日记卡片点击跳转 /editor?id=
- MonthlyPage 照片数量从 JournalElement 统计
- calendar_page/mood_page/search_page 统一使用 moodToEmoji/moodToLabel
2026-06-02 20:21:51 +08:00

784 lines
24 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.
// 搜索页面 — 搜索历史 + 热门搜索 + 关键词高亮 + 分类 Tab
//
// 通过 SearchBloc 驱动搜索状态:
// - 关键词输入 → SearchByKeyword event
// - 标签点击 → SearchByTag event
// - 心情选择 → SearchByMood event
// - Tab 切换 → SearchTabChanged event
// - 清除按钮 → SearchClear event
// 搜索结果由 BlocBuilder<SearchBloc, SearchState> 响应式渲染。
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<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
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<SearchBloc, SearchState>(
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<SearchBloc>()
.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<SearchBloc>()
.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<String> 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<SearchBloc>()
.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<SearchBloc>()
.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<SearchBloc>()
.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<JournalEntry> 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<JournalEntry> 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<JournalEntry> results) {
// 从搜索结果中提取所有标签及其频次
final tagFreq = <String, int>{};
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<SearchBloc>().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 = <TextSpan>[];
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,
);
}