- SearchPage: 热搜词从日记标签频率动态生成 + 模板搜索网格 - ProfilePage: 成就徽章从 AchievementBloc 动态加载 + 头像首字母 - TeacherPage: 班级码改为对话框展示 (班级名+码+人数) - StickerLibraryPage: 分类从 API 动态合并 + 精选包卡片动态化 - TemplateGalleryPage: 适配动态数据 - ClassPage: 微调 - HomePage: 路由适配 - CalendarBloc: 新增测试 - AppRouter: 路由更新
863 lines
26 KiB
Dart
863 lines
26 KiB
Dart
// 搜索页面 — 搜索历史 + 热门搜索 + 关键词高亮 + 分类 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),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|