Files
nj/app/lib/features/templates/views/template_gallery_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

361 lines
12 KiB
Dart

// 模板画廊页面 — 日记模板浏览和选择
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:nuanji_app/core/theme/app_colors.dart';
import 'package:nuanji_app/core/theme/app_radius.dart';
import 'package:nuanji_app/data/remote/api_client.dart';
import '../bloc/template_bloc.dart';
/// 视图模式
enum _ViewMode { daily, weekly, monthly }
/// 模板画廊页面 — 浏览和选择日记模板
class TemplateGalleryPage extends StatefulWidget {
const TemplateGalleryPage({super.key});
@override
State<TemplateGalleryPage> createState() => _TemplateGalleryPageState();
}
class _TemplateGalleryPageState extends State<TemplateGalleryPage> {
late final TemplateBloc _bloc;
_ViewMode _viewMode = _ViewMode.daily;
@override
void initState() {
super.initState();
_bloc = TemplateBloc(api: context.read<ApiClient>());
_bloc.load();
}
@override
void dispose() {
_bloc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
final surfaceWarm = isDark ? AppColors.surfaceWarmDark : AppColors.surfaceWarmLight;
return Scaffold(
body: SafeArea(
child: ListenableBuilder(
listenable: _bloc,
builder: (context, _) {
final state = _bloc.state;
if (state.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state.errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: _bloc.load,
child: const Text('重试'),
),
],
),
);
}
return Column(
children: [
// ---- 自定义顶栏 ----
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 16, 0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
Text('模板画廊', style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
)),
],
),
),
const SizedBox(height: 12),
// ---- 视图选择器 (日/周/月) ----
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
_ViewModeButton(
emoji: '📅', label: '日视图',
selected: _viewMode == _ViewMode.daily,
surfaceWarm: surfaceWarm,
onTap: () => setState(() => _viewMode = _ViewMode.daily),
),
const SizedBox(width: 8),
_ViewModeButton(
emoji: '📊', label: '周视图',
selected: _viewMode == _ViewMode.weekly,
surfaceWarm: surfaceWarm,
onTap: () => setState(() => _viewMode = _ViewMode.weekly),
),
const SizedBox(width: 8),
_ViewModeButton(
emoji: '📈', label: '月视图',
selected: _viewMode == _ViewMode.monthly,
surfaceWarm: surfaceWarm,
onTap: () => setState(() => _viewMode = _ViewMode.monthly),
),
],
),
),
const SizedBox(height: 12),
// ---- 分类选择器 ----
SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: state.categories.map((cat) {
final isSelected = cat == state.selectedCategory;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
selected: isSelected,
label: Text(cat),
onSelected: (_) => _bloc.selectCategory(cat),
selectedColor: AppColors.accent.withValues(alpha: 0.15),
checkmarkColor: AppColors.accent,
labelStyle: TextStyle(
color: isSelected ? AppColors.accent : colorScheme.onSurface,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
);
}).toList(),
),
),
const SizedBox(height: 8),
// ---- 模板网格 (200px 高预览) ----
Expanded(
child: state.filteredTemplates.isEmpty
? const Center(child: Text('暂无模板'))
: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.52,
),
itemCount: state.filteredTemplates.length,
itemBuilder: (context, index) {
return _TemplateCard(
template: state.filteredTemplates[index],
);
},
),
),
],
);
},
),
),
);
}
}
/// 视图模式按钮
class _ViewModeButton extends StatelessWidget {
const _ViewModeButton({
required this.emoji,
required this.label,
required this.selected,
required this.surfaceWarm,
required this.onTap,
});
final String emoji;
final String label;
final bool selected;
final Color surfaceWarm;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: selected ? surfaceWarm : Colors.transparent,
border: Border.all(
color: selected ? AppColors.accent : Theme.of(context).colorScheme.outlineVariant,
width: selected ? 1.5 : 1,
),
borderRadius: AppRadius.pillBorder,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(emoji, style: const TextStyle(fontSize: 14)),
const SizedBox(width: 4),
Text(label, style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
color: selected ? AppColors.accent : Theme.of(context).colorScheme.onSurface,
)),
],
),
),
);
}
}
/// 模板卡片 — 200px 预览 + 使用按钮 + 标签
class _TemplateCard extends StatelessWidget {
const _TemplateCard({required this.template});
final Template template;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
final secondarySoft = isDark ? AppColors.secondarySoftDark : AppColors.secondarySoftLight;
final tertiarySoft = isDark ? AppColors.tertiarySoftDark : AppColors.tertiarySoftLight;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.mdBorder,
side: BorderSide(color: colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 模板预览区 — 200px 高
Expanded(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colorScheme.primaryContainer.withValues(alpha: 0.5),
AppColors.tertiary.withValues(alpha: 0.3),
],
),
borderRadius: AppRadius.mdBorder,
),
alignment: Alignment.center,
child: Text(
template.emoji,
style: const TextStyle(fontSize: 48),
),
),
),
const SizedBox(height: 10),
// 模板名称
Text(
template.name,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
// 描述
if (template.description != null)
Text(
template.description!,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
// 标签(从模板 category 动态生成)
Wrap(
spacing: 6,
runSpacing: 4,
children: [
if (template.category != null && template.category!.isNotEmpty)
_TagPill(
label: template.category!,
bgColor: secondarySoft,
textColor: AppColors.secondary,
),
_TagPill(
label: template.isFree ? '免费' : '精品',
bgColor: template.isFree ? tertiarySoft : AppColors.roseSoftLight,
textColor: template.isFree ? AppColors.tertiary : AppColors.rose,
),
],
),
const SizedBox(height: 8),
// 使用按钮
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () {
context.push('/editor?template=${template.id}');
},
style: FilledButton.styleFrom(
backgroundColor: AppColors.accent,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
shape: RoundedRectangleBorder(borderRadius: AppRadius.pillBorder),
),
child: const Text('使用', style: TextStyle(
fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white,
)),
),
),
],
),
),
);
}
}
/// 标签胶囊
class _TagPill extends StatelessWidget {
const _TagPill({required this.label, required this.bgColor, required this.textColor});
final String label;
final Color bgColor;
final Color textColor;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: bgColor,
borderRadius: AppRadius.pillBorder,
),
child: Text(label, style: TextStyle(
fontSize: 10, fontWeight: FontWeight.w500, color: textColor,
)),
);
}
}