Files
nj/app/lib/features/search/views/search_page.dart
iven d67eedf7de feat(app): 多页面动态化 — 搜索/资料/教师/贴纸库/模板/日历
- SearchPage: 热搜词从日记标签频率动态生成 + 模板搜索网格
- ProfilePage: 成就徽章从 AchievementBloc 动态加载 + 头像首字母
- TeacherPage: 班级码改为对话框展示 (班级名+码+人数)
- StickerLibraryPage: 分类从 API 动态合并 + 精选包卡片动态化
- TemplateGalleryPage: 适配动态数据
- ClassPage: 微调
- HomePage: 路由适配
- CalendarBloc: 新增测试
- AppRouter: 路由更新
2026-06-07 10:44:04 +08:00

863 lines
26 KiB
Dart
Raw Permalink 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 '../../../data/remote/api_client.dart';
import '../../../data/repositories/journal_repository.dart';
import '../../templates/bloc/template_bloc.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();
// 热门搜索 — 从用户日记标签动态推导,无数据时使用默认推荐
List<String> _hotSearches = ['日常', '学校', '旅行', '心情', '读书', '手账'];
@override
void initState() {
super.initState();
_deriveHotSearches();
// 自动弹出键盘
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
});
}
/// 从日记标签频率推导热门搜索
Future<void> _deriveHotSearches() async {
try {
final repo = context.read<JournalRepository>();
final journals = await repo.getJournals();
final tagFreq = <String, int>{};
for (final j in journals) {
for (final tag in j.tags) {
tagFreq[tag] = (tagFreq[tag] ?? 0) + 1;
}
}
final sorted = tagFreq.keys.toList()
..sort((a, b) => tagFreq[b]!.compareTo(tagFreq[a]!));
if (sorted.isNotEmpty && mounted) {
setState(() {
_hotSearches = sorted.take(8).toList();
});
}
} catch (_) {
// 保持默认值
}
}
@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) {
return _TemplateSearchGrid(theme: theme, isDark: isDark);
}
// ===== 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,
);
}
/// 搜索页模板结果 — 从 TemplateBloc 动态加载
class _TemplateSearchGrid extends StatefulWidget {
const _TemplateSearchGrid({required this.theme, required this.isDark});
final ThemeData theme;
final bool isDark;
@override
State<_TemplateSearchGrid> createState() => _TemplateSearchGridState();
}
class _TemplateSearchGridState extends State<_TemplateSearchGrid> {
late final TemplateBloc _bloc;
@override
void initState() {
super.initState();
_bloc = TemplateBloc(api: context.read<ApiClient>());
_bloc.load();
_bloc.addListener(() => setState(() {}));
}
@override
void dispose() {
_bloc.dispose();
super.dispose();
}
static const _gradients = [
[AppColors.accent, AppColors.tertiary],
[AppColors.secondary, AppColors.tertiary],
[AppColors.rose, AppColors.accent],
[AppColors.tertiary, AppColors.secondary],
];
@override
Widget build(BuildContext context) {
final templates = _bloc.state.templates;
if (_bloc.state.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (templates.isEmpty) {
return Center(
child: Text('暂无模板', style: widget.theme.textTheme.bodyMedium?.copyWith(
color: widget.isDark ? AppColors.mutedDark : AppColors.mutedLight,
)),
);
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.75,
),
itemCount: templates.length,
itemBuilder: (context, index) {
final t = templates[index];
final colors = _gradients[index % _gradients.length];
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.md),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: colors,
),
),
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(
t.emoji,
style: const TextStyle(fontSize: 28),
),
const SizedBox(height: 8),
Text(
t.name,
style: widget.theme.textTheme.titleSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
t.isFree ? '免费模板' : '精品模板',
style: widget.theme.textTheme.bodySmall?.copyWith(
color: Colors.white.withValues(alpha: 0.7),
),
),
],
),
),
],
),
);
},
);
}
}