feat(diary): B4+B5+B6 后端服务 + F5/F6/F7 前端模块

后端 (erp-diary):
- B4: CommentService 班级成员验证 + 删除评语 + SSE 通知推送
- B4: NotificationService 评语/主题/成就三类通知事件
- B5: StickerService 贴纸包列表 + 贴纸查询 + 模板管理
- B5: AchievementService 成就列表 + 解锁 + SSE 通知
- B6: MoodStatsService 心情统计 + 连续天数
- B6: ContentSafetyService 敏感词过滤框架
- SSE handler 增加 diary.notification.* 事件处理
- 新增 14 个 API 端点 + diary.comment.delete 权限

前端 (Flutter):
- F5: CalendarBloc + 月视图日历 + 日记列表
- F6: MoodBloc + fl_chart 心情饼图 + 统计卡片 + 连续天数
- F7: 贴纸库分类浏览 + 模板画廊
- 首页改为日记流 + 心情快速选择
- 成就页改为徽章收集展示

验证: cargo check ✓ cargo test 17/17 ✓ flutter analyze 0 error
This commit is contained in:
iven
2026-06-01 09:32:09 +08:00
parent 482eb244d5
commit 7e3597dc77
25 changed files with 3286 additions and 39 deletions

View File

@@ -1,13 +1,231 @@
import 'package:flutter/material.dart';
// 成就页面 — 徽章收集展示
import 'package:flutter/material.dart';
import 'package:nuanji_app/core/theme/app_colors.dart';
/// 成就数据模型
class Achievement {
final String id;
final String code;
final String name;
final String? description;
final String icon;
final String category;
final bool isUnlocked;
const Achievement({
required this.id,
required this.code,
required this.name,
this.description,
required this.icon,
required this.category,
this.isUnlocked = false,
});
}
/// 成就页面 — 徽章收集和展示
class AchievementPage extends StatelessWidget {
const AchievementPage({super.key});
static const _achievements = [
Achievement(id: '1', code: 'first_diary', name: '初次落笔', description: '写下第一篇日记', icon: '✏️', category: 'writing', isUnlocked: true),
Achievement(id: '2', code: 'streak_7', name: '坚持一周', description: '连续写日记 7 天', icon: '🔥', category: 'writing'),
Achievement(id: '3', code: 'streak_30', name: '月度达人', description: '连续写日记 30 天', icon: '💪', category: 'writing'),
Achievement(id: '4', code: 'sticker_collector', name: '贴纸收藏家', description: '收集 10 张贴纸', icon: '🎨', category: 'collection'),
Achievement(id: '5', code: 'social_butterfly', name: '分享之星', description: '分享 5 篇日记到班级', icon: '🌟', category: 'social'),
Achievement(id: '6', code: 'mood_tracker', name: '心情记录员', description: '连续记录心情 14 天', icon: '🌈', category: 'writing'),
Achievement(id: '7', code: 'early_bird', name: '早起日记', description: '在早上 7 点前写日记', icon: '🌅', category: 'special'),
Achievement(id: '8', code: 'artist', name: '小画家', description: '在日记中画 10 幅涂鸦', icon: '🖌️', category: 'collection'),
];
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Text('成就 - 占位页面'),
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final unlocked = _achievements.where((a) => a.isUnlocked).length;
return Scaffold(
appBar: AppBar(
title: const Text('成就'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 进度概览
_AchievementProgressCard(
unlocked: unlocked,
total: _achievements.length,
colorScheme: colorScheme,
),
const SizedBox(height: 24),
Text(
'全部成就',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.75,
),
itemCount: _achievements.length,
itemBuilder: (context, index) {
return _AchievementCard(
achievement: _achievements[index],
colorScheme: colorScheme,
);
},
),
],
),
),
);
}
}
/// 成就进度卡片
class _AchievementProgressCard extends StatelessWidget {
const _AchievementProgressCard({
required this.unlocked,
required this.total,
required this.colorScheme,
});
final int unlocked;
final int total;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final progress = total > 0 ? unlocked / total : 0.0;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
),
color: colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'收集进度',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
'$unlocked / $total',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator(
value: progress,
minHeight: 10,
backgroundColor: colorScheme.primary.withValues(alpha: 0.15),
color: colorScheme.primary,
),
),
],
),
),
);
}
}
/// 成就卡片
class _AchievementCard extends StatelessWidget {
const _AchievementCard({
required this.achievement,
required this.colorScheme,
});
final Achievement achievement;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: achievement.isUnlocked
? AppColors.accent.withValues(alpha: 0.4)
: colorScheme.outlineVariant,
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: achievement.isUnlocked
? AppColors.accent.withValues(alpha: 0.15)
: colorScheme.onSurface.withValues(alpha: 0.05),
),
alignment: Alignment.center,
child: achievement.isUnlocked
? Text(achievement.icon, style: const TextStyle(fontSize: 28))
: Icon(
Icons.lock_outline,
color: colorScheme.onSurface.withValues(alpha: 0.3),
),
),
const SizedBox(height: 8),
Text(
achievement.name,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: achievement.isUnlocked
? colorScheme.onSurface
: colorScheme.onSurface.withValues(alpha: 0.4),
),
),
if (achievement.description != null) ...[
const SizedBox(height: 4),
Text(
achievement.description!,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(
alpha: achievement.isUnlocked ? 0.6 : 0.3,
),
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
);
}

View File

@@ -0,0 +1,175 @@
// 日历 BLoC — 管理日历视图状态和日记列表
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
// ===== Events =====
sealed class CalendarEvent {
const CalendarEvent();
}
/// 切换到指定月份
final class CalendarMonthChanged extends CalendarEvent {
final DateTime month;
const CalendarMonthChanged(this.month);
}
/// 选择某一天
final class CalendarDaySelected extends CalendarEvent {
final DateTime day;
const CalendarDaySelected(this.day);
}
/// 切换视图模式(月/周/时间轴)
final class CalendarViewModeChanged extends CalendarEvent {
final CalendarViewMode mode;
const CalendarViewModeChanged(this.mode);
}
/// 加载某月的日记列表
final class CalendarLoadJournals extends CalendarEvent {
final DateTime month;
const CalendarLoadJournals(this.month);
}
// ===== State =====
/// 日历视图模式
enum CalendarViewMode { month, week, timeline }
/// 日历状态
sealed class CalendarState {
const CalendarState();
}
/// 初始加载中
final class CalendarInitial extends CalendarState {
const CalendarInitial();
}
/// 日历已加载 — 包含当前月份、选中日期、日记列表
final class CalendarLoaded extends CalendarState {
/// 当前显示的月份
final DateTime focusedMonth;
/// 选中的日期
final DateTime selectedDay;
/// 当前月份所有日记(按日期索引)
final Map<DateTime, List<JournalEntry>> journalsByDate;
/// 当前选中日期的日记列表
final List<JournalEntry> selectedDayJournals;
/// 视图模式
final CalendarViewMode viewMode;
/// 是否正在加载
final bool isLoading;
const CalendarLoaded({
required this.focusedMonth,
required this.selectedDay,
required this.journalsByDate,
required this.selectedDayJournals,
this.viewMode = CalendarViewMode.month,
this.isLoading = false,
});
CalendarLoaded copyWith({
DateTime? focusedMonth,
DateTime? selectedDay,
Map<DateTime, List<JournalEntry>>? journalsByDate,
List<JournalEntry>? selectedDayJournals,
CalendarViewMode? viewMode,
bool? isLoading,
}) =>
CalendarLoaded(
focusedMonth: focusedMonth ?? this.focusedMonth,
selectedDay: selectedDay ?? this.selectedDay,
journalsByDate: journalsByDate ?? this.journalsByDate,
selectedDayJournals: selectedDayJournals ?? this.selectedDayJournals,
viewMode: viewMode ?? this.viewMode,
isLoading: isLoading ?? this.isLoading,
);
}
/// 加载失败
final class CalendarError extends CalendarState {
final String message;
const CalendarError(this.message);
}
// ===== BLoC =====
class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
CalendarBloc() : super(const CalendarInitial()) {
on<CalendarMonthChanged>(_onMonthChanged);
on<CalendarDaySelected>(_onDaySelected);
on<CalendarViewModeChanged>(_onViewModeChanged);
on<CalendarLoadJournals>(_onLoadJournals);
}
void _onMonthChanged(
CalendarMonthChanged event,
Emitter<CalendarState> emit,
) {
final currentState = state is CalendarLoaded ? state as CalendarLoaded : null;
emit(CalendarLoaded(
focusedMonth: event.month,
selectedDay: event.month,
journalsByDate: currentState?.journalsByDate ?? {},
selectedDayJournals: [],
viewMode: currentState?.viewMode ?? CalendarViewMode.month,
));
add(CalendarLoadJournals(event.month));
}
void _onDaySelected(
CalendarDaySelected event,
Emitter<CalendarState> emit,
) {
if (state is! CalendarLoaded) return;
final current = state as CalendarLoaded;
// 查找选中日期的日记
final dayKey = DateTime(event.day.year, event.day.month, event.day.day);
final dayJournals = current.journalsByDate[dayKey] ?? [];
emit(current.copyWith(
selectedDay: event.day,
selectedDayJournals: dayJournals,
));
}
void _onViewModeChanged(
CalendarViewModeChanged event,
Emitter<CalendarState> emit,
) {
if (state is! CalendarLoaded) return;
final current = state as CalendarLoaded;
emit(current.copyWith(viewMode: event.mode));
}
Future<void> _onLoadJournals(
CalendarLoadJournals event,
Emitter<CalendarState> emit,
) async {
if (state is! CalendarLoaded) return;
final current = state as CalendarLoaded;
emit(current.copyWith(isLoading: true));
// Phase 1: 使用空数据占位,待 Repository 集成后替换
// 实际将从 JournalRepository.loadByMonth(event.month) 获取
await Future.delayed(const Duration(milliseconds: 300));
emit(current.copyWith(
isLoading: false,
journalsByDate: current.journalsByDate,
));
}
}

View File

@@ -1,14 +1,489 @@
import 'package:flutter/material.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/data/models/journal_entry.dart';
import '../bloc/calendar_bloc.dart';
/// 日历页面 — 月视图 + 选中日期的日记列表
class CalendarPage extends StatelessWidget {
const CalendarPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Text('日历 - 占位页面'),
return BlocProvider(
create: (context) => CalendarBloc()
..add(CalendarMonthChanged(DateTime.now())),
child: const _CalendarView(),
);
}
}
class _CalendarView extends StatelessWidget {
const _CalendarView();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return BlocBuilder<CalendarBloc, CalendarState>(
builder: (context, state) {
if (state is CalendarInitial) {
return const Center(child: CircularProgressIndicator());
}
if (state is CalendarError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
Text(state.message, style: theme.textTheme.bodyLarge),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: () => context.read<CalendarBloc>()
.add(CalendarMonthChanged(DateTime.now())),
child: const Text('重试'),
),
],
),
);
}
if (state is! CalendarLoaded) return const SizedBox.shrink();
final loaded = state;
return Column(
children: [
// 月份导航
_MonthNavigator(
month: loaded.focusedMonth,
onPrevious: () {
final prev = DateTime(
loaded.focusedMonth.year,
loaded.focusedMonth.month - 1,
);
context.read<CalendarBloc>().add(CalendarMonthChanged(prev));
},
onNext: () {
final next = DateTime(
loaded.focusedMonth.year,
loaded.focusedMonth.month + 1,
);
context.read<CalendarBloc>().add(CalendarMonthChanged(next));
},
),
// 星期标题行
_WeekdayHeader(colorScheme: colorScheme),
// 日历网格
_CalendarGrid(
month: loaded.focusedMonth,
selectedDay: loaded.selectedDay,
journalsByDate: loaded.journalsByDate,
onDaySelected: (day) {
context.read<CalendarBloc>().add(CalendarDaySelected(day));
},
),
const Divider(height: 1),
// 选中日期的日记列表
Expanded(
child: loaded.selectedDayJournals.isEmpty
? _EmptyDayView(selectedDay: loaded.selectedDay)
: _DayJournalList(journals: loaded.selectedDayJournals),
),
],
);
},
);
}
}
/// 月份导航栏
class _MonthNavigator extends StatelessWidget {
const _MonthNavigator({
required this.month,
required this.onPrevious,
required this.onNext,
});
final DateTime month;
final VoidCallback onPrevious;
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final monthName = _formatMonth(month);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
onPressed: onPrevious,
icon: const Icon(Icons.chevron_left),
tooltip: '上个月',
),
Text(
monthName,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
IconButton(
onPressed: onNext,
icon: const Icon(Icons.chevron_right),
tooltip: '下个月',
),
],
),
);
}
String _formatMonth(DateTime date) {
const months = [
'1月', '2月', '3月', '4月', '5月', '6月',
'7月', '8月', '9月', '10月', '11月', '12月',
];
return '${date.year}${months[date.month - 1]}';
}
}
/// 星期标题行
class _WeekdayHeader extends StatelessWidget {
const _WeekdayHeader({required this.colorScheme});
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
const weekdays = ['', '', '', '', '', '', ''];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: weekdays.map((day) {
return Expanded(
child: Center(
child: Text(
day,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurface.withValues(alpha: 0.6),
fontWeight: FontWeight.w500,
),
),
),
);
}).toList(),
),
);
}
}
/// 日历网格 — 6行7列
class _CalendarGrid extends StatelessWidget {
const _CalendarGrid({
required this.month,
required this.selectedDay,
required this.journalsByDate,
required this.onDaySelected,
});
final DateTime month;
final DateTime selectedDay;
final Map<DateTime, List<JournalEntry>> journalsByDate;
final ValueChanged<DateTime> onDaySelected;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final today = DateTime.now();
final days = _generateDays(month);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: days.map((week) {
return Row(
children: week.map((dayInfo) {
return Expanded(
child: _DayCell(
dayInfo: dayInfo,
isToday: dayInfo.date.year == today.year &&
dayInfo.date.month == today.month &&
dayInfo.date.day == today.day,
isSelected: dayInfo.date.year == selectedDay.year &&
dayInfo.date.month == selectedDay.month &&
dayInfo.date.day == selectedDay.day,
hasJournals: journalsByDate.containsKey(
DateTime(dayInfo.date.year, dayInfo.date.month, dayInfo.date.day),
),
colorScheme: colorScheme,
onTap: () => onDaySelected(dayInfo.date),
),
);
}).toList(),
);
}).toList(),
),
);
}
/// 生成当月日历数据(包含前后补齐)
List<List<_DayInfo>> _generateDays(DateTime month) {
final firstDay = DateTime(month.year, month.month, 1);
// 周一为第一天weekday 1=Mon...7=Sun
final startOffset = firstDay.weekday - 1;
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
final allDays = <_DayInfo>[];
// 前面的空白
for (var i = 0; i < startOffset; i++) {
allDays.add(_DayInfo(date: firstDay.subtract(Duration(days: startOffset - i)), isCurrentMonth: false));
}
// 当月日期
for (var d = 1; d <= daysInMonth; d++) {
allDays.add(_DayInfo(
date: DateTime(month.year, month.month, d),
isCurrentMonth: true,
));
}
// 后面的补齐到完整行
while (allDays.length % 7 != 0) {
final last = allDays.last.date;
allDays.add(_DayInfo(date: last.add(const Duration(days: 1)), isCurrentMonth: false));
}
// 分周
return List.generate(allDays.length ~/ 7, (i) => allDays.sublist(i * 7, (i + 1) * 7));
}
}
class _DayInfo {
const _DayInfo({required this.date, required this.isCurrentMonth});
final DateTime date;
final bool isCurrentMonth;
}
/// 单日格子
class _DayCell extends StatelessWidget {
const _DayCell({
required this.dayInfo,
required this.isToday,
required this.isSelected,
required this.hasJournals,
required this.colorScheme,
required this.onTap,
});
final _DayInfo dayInfo;
final bool isToday;
final bool isSelected;
final bool hasJournals;
final ColorScheme colorScheme;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
height: 44,
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected
? colorScheme.primary
: isToday
? colorScheme.primaryContainer
: null,
),
alignment: Alignment.center,
child: Text(
'${dayInfo.date.day}',
style: TextStyle(
fontSize: 14,
fontWeight: isSelected || isToday ? FontWeight.bold : FontWeight.normal,
color: !dayInfo.isCurrentMonth
? colorScheme.onSurface.withValues(alpha: 0.3)
: isSelected
? colorScheme.onPrimary
: colorScheme.onSurface,
),
),
),
// 日记指示点
if (hasJournals)
Container(
width: 4,
height: 4,
margin: const EdgeInsets.only(top: 2),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected
? colorScheme.onPrimary
: AppColors.accent,
),
),
],
),
),
);
}
}
/// 无日记的空状态
class _EmptyDayView extends StatelessWidget {
const _EmptyDayView({required this.selectedDay});
final DateTime selectedDay;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.edit_note_rounded,
size: 64,
color: theme.colorScheme.onSurface.withValues(alpha: 0.2),
),
const SizedBox(height: 16),
Text(
'${selectedDay.month}${selectedDay.day}',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'这一天还没有日记',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
),
const SizedBox(height: 24),
FilledButton.tonal(
onPressed: () => context.go('/editor'),
child: const Text('写一篇'),
),
],
),
);
}
}
/// 日记列表
class _DayJournalList extends StatelessWidget {
const _DayJournalList({required this.journals});
final List<JournalEntry> journals;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: journals.length,
itemBuilder: (context, index) {
final journal = journals[index];
final moodColor = AppColors.moodColors[journal.mood.value] ?? colorScheme.primary;
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: InkWell(
onTap: () => context.go('/editor?id=${journal.id}'),
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// 心情图标
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: moodColor.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
_moodEmoji(journal.mood),
style: const TextStyle(fontSize: 20),
),
),
const SizedBox(width: 12),
// 标题和标签
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
journal.title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (journal.tags.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
journal.tags.take(3).join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
],
),
),
Icon(
Icons.chevron_right,
color: colorScheme.onSurface.withValues(alpha: 0.3),
),
],
),
),
),
);
},
);
}
String _moodEmoji(Mood mood) {
return switch (mood) {
Mood.happy => '😊',
Mood.calm => '😌',
Mood.sad => '😢',
Mood.angry => '😠',
Mood.thinking => '🤔',
};
}
}

View File

@@ -1,13 +1,184 @@
import 'package:flutter/material.dart';
// 首页 — 日记流 + 心情概览
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:nuanji_app/core/theme/app_colors.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
/// 首页 — 展示最近日记流和心情概览
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Text('首页 - 占位页面'),
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
appBar: AppBar(
title: Text(
'暖记',
style: theme.textTheme.headlineSmall?.copyWith(
fontFamily: 'Caveat',
color: colorScheme.primary,
),
),
actions: [
IconButton(
onPressed: () => context.go('/stickers'),
icon: const Icon(Icons.emoji_emotions_outlined),
tooltip: '贴纸库',
),
IconButton(
onPressed: () => context.go('/templates'),
icon: const Icon(Icons.dashboard_customize_outlined),
tooltip: '模板',
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 心情快速选择卡片
_QuickMoodCard(colorScheme: colorScheme),
const SizedBox(height: 20),
// 最近日记标题
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'最近日记',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
TextButton(
onPressed: () => context.go('/calendar'),
child: const Text('查看全部'),
),
],
),
const SizedBox(height: 12),
// 日记流占位 — 待数据层集成后替换
const _EmptyJournalState(),
],
),
),
);
}
}
/// 心情快速选择卡片
class _QuickMoodCard extends StatelessWidget {
const _QuickMoodCard({required this.colorScheme});
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final moods = [
('😊', '开心', Mood.happy),
('😌', '平静', Mood.calm),
('😢', '难过', Mood.sad),
('😠', '生气', Mood.angry),
('🤔', '思考', Mood.thinking),
];
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
),
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'今天心情如何?',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: moods.map((mood) {
return GestureDetector(
onTap: () => context.go('/editor'),
child: Column(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: (AppColors.moodColors[mood.$3.value] ??
colorScheme.primary)
.withValues(alpha: 0.15),
),
alignment: Alignment.center,
child: Text(mood.$1, style: const TextStyle(fontSize: 22)),
),
const SizedBox(height: 4),
Text(
mood.$2,
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
);
}).toList(),
),
],
),
),
);
}
}
/// 空日记状态
class _EmptyJournalState extends StatelessWidget {
const _EmptyJournalState();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Column(
children: [
Icon(
Icons.edit_note_rounded,
size: 64,
color: colorScheme.onSurface.withValues(alpha: 0.2),
),
const SizedBox(height: 16),
Text(
'开始你的第一篇手账日记吧!',
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () => context.go('/editor'),
icon: const Icon(Icons.add_rounded),
label: const Text('写日记'),
),
],
),
),
);
}

View File

@@ -0,0 +1,128 @@
// 心情 BLoC — 管理心情统计和趋势数据
import 'package:flutter/material.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
// ===== 心情统计模型 =====
/// 心情统计数据
class MoodStats {
final List<MoodCount> moodCounts;
final int streakDays;
final int totalJournals;
final Mood? dominantMood;
const MoodStats({
this.moodCounts = const [],
this.streakDays = 0,
this.totalJournals = 0,
this.dominantMood,
});
}
/// 单种心情的统计
class MoodCount {
final Mood mood;
final int count;
final double percentage;
const MoodCount({
required this.mood,
required this.count,
required this.percentage,
});
}
/// 心情趋势数据点
class MoodTrendPoint {
final DateTime date;
final Mood mood;
final int journalCount;
const MoodTrendPoint({
required this.date,
required this.mood,
required this.journalCount,
});
}
/// 统计周期
enum StatsPeriod { week, month, quarter }
// ===== 心情页面状态 =====
/// 心情页面状态
class MoodState {
final MoodStats stats;
final List<MoodTrendPoint> trendData;
final StatsPeriod selectedPeriod;
final bool isLoading;
final String? errorMessage;
const MoodState({
this.stats = const MoodStats(),
this.trendData = const [],
this.selectedPeriod = StatsPeriod.week,
this.isLoading = false,
this.errorMessage,
});
MoodState copyWith({
MoodStats? stats,
List<MoodTrendPoint>? trendData,
StatsPeriod? selectedPeriod,
bool? isLoading,
String? errorMessage,
}) =>
MoodState(
stats: stats ?? this.stats,
trendData: trendData ?? this.trendData,
selectedPeriod: selectedPeriod ?? this.selectedPeriod,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage,
);
}
// ===== 心情 BLoC =====
class MoodBloc extends ChangeNotifier {
MoodState _state = const MoodState();
MoodState get state => _state;
/// 切换统计周期
void changePeriod(StatsPeriod period) {
_state = _state.copyWith(selectedPeriod: period, isLoading: true);
notifyListeners();
_loadStats();
}
/// 加载统计数据
Future<void> _loadStats() async {
// Phase 1: 占位数据,待 API 集成后替换
await Future.delayed(const Duration(milliseconds: 300));
_state = _state.copyWith(
isLoading: false,
stats: MoodStats(
moodCounts: [
const MoodCount(mood: Mood.happy, count: 12, percentage: 40.0),
const MoodCount(mood: Mood.calm, count: 8, percentage: 26.7),
const MoodCount(mood: Mood.thinking, count: 5, percentage: 16.7),
const MoodCount(mood: Mood.sad, count: 3, percentage: 10.0),
const MoodCount(mood: Mood.angry, count: 2, percentage: 6.6),
],
streakDays: 7,
totalJournals: 30,
dominantMood: Mood.happy,
),
);
notifyListeners();
}
/// 初始加载
void load() {
_state = _state.copyWith(isLoading: true);
notifyListeners();
_loadStats();
}
}

View File

@@ -1,14 +1,356 @@
import 'package:flutter/material.dart';
// 心情页面 — 心情统计 + 趋势图 + 连续天数
class MoodPage extends StatelessWidget {
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:nuanji_app/core/theme/app_colors.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import '../bloc/mood_bloc.dart';
/// 心情页面 — 统计卡片 + 心情分布饼图 + 趋势折线图
class MoodPage extends StatefulWidget {
const MoodPage({super.key});
@override
State<MoodPage> createState() => _MoodPageState();
}
class _MoodPageState extends State<MoodPage> {
final _bloc = MoodBloc();
@override
void initState() {
super.initState();
_bloc.load();
}
@override
void dispose() {
_bloc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Text('心情 - 占位页面'),
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return ListenableBuilder(
listenable: _bloc,
builder: (context, _) {
final state = _bloc.state;
if (state.isLoading) {
return const Center(child: CircularProgressIndicator());
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 统计概览卡片
_StatsOverviewCard(stats: state.stats, colorScheme: colorScheme),
const SizedBox(height: 16),
// 周期选择器
_PeriodSelector(
selectedPeriod: state.selectedPeriod,
onPeriodChanged: _bloc.changePeriod,
),
const SizedBox(height: 16),
// 心情分布饼图
_MoodDistributionChart(
moodCounts: state.stats.moodCounts,
colorScheme: colorScheme,
),
const SizedBox(height: 24),
// 心情详情列表
Text(
'心情详情',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
...state.stats.moodCounts.map((mc) => _MoodCountTile(mc: mc)),
const SizedBox(height: 24),
// 连续天数鼓励卡片
_StreakCard(streakDays: state.stats.streakDays),
],
),
);
},
);
}
}
/// 统计概览卡片
class _StatsOverviewCard extends StatelessWidget {
const _StatsOverviewCard({
required this.stats,
required this.colorScheme,
});
final MoodStats stats;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dominantEmoji = stats.dominantMood != null
? _moodEmoji(stats.dominantMood!)
: '📝';
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
),
color: colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
// 主导心情图标
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primary.withValues(alpha: 0.15),
),
alignment: Alignment.center,
child: Text(dominantEmoji, style: const TextStyle(fontSize: 28)),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'心情概览',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${stats.totalJournals} 篇日记 · 连续 ${stats.streakDays}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.7),
),
),
],
),
),
],
),
),
);
}
}
/// 周期选择器
class _PeriodSelector extends StatelessWidget {
const _PeriodSelector({
required this.selectedPeriod,
required this.onPeriodChanged,
});
final StatsPeriod selectedPeriod;
final ValueChanged<StatsPeriod> onPeriodChanged;
@override
Widget build(BuildContext context) {
return SegmentedButton<StatsPeriod>(
segments: const [
ButtonSegment(value: StatsPeriod.week, label: Text('')),
ButtonSegment(value: StatsPeriod.month, label: Text('')),
ButtonSegment(value: StatsPeriod.quarter, label: Text('')),
],
selected: {selectedPeriod},
onSelectionChanged: (set) => onPeriodChanged(set.first),
);
}
}
/// 心情分布饼图
class _MoodDistributionChart extends StatelessWidget {
const _MoodDistributionChart({
required this.moodCounts,
required this.colorScheme,
});
final List<MoodCount> moodCounts;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
if (moodCounts.isEmpty) {
return const SizedBox.shrink();
}
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: SizedBox(
height: 200,
child: PieChart(
PieChartData(
sections: moodCounts.map((mc) {
final color = AppColors.moodColors[mc.mood.value] ?? colorScheme.primary;
return PieChartSectionData(
value: mc.count.toDouble(),
color: color,
radius: 50,
title: '${mc.percentage.toStringAsFixed(0)}%',
titleStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
);
}).toList(),
sectionsSpace: 2,
centerSpaceRadius: 40,
),
),
),
),
);
}
}
/// 心情计数列表项
class _MoodCountTile extends StatelessWidget {
const _MoodCountTile({required this.mc});
final MoodCount mc;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = AppColors.moodColors[mc.mood.value] ?? theme.colorScheme.primary;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Text(_moodEmoji(mc.mood), style: const TextStyle(fontSize: 20)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_moodLabel(mc.mood),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 4),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: mc.percentage / 100,
backgroundColor: color.withValues(alpha: 0.15),
color: color,
minHeight: 6,
),
),
],
),
),
const SizedBox(width: 12),
SizedBox(
width: 48,
child: Text(
'${mc.count}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
textAlign: TextAlign.end,
),
),
],
),
);
}
}
/// 连续天数鼓励卡片
class _StreakCard extends StatelessWidget {
const _StreakCard({required this.streakDays});
final int streakDays;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
),
color: AppColors.tertiary.withValues(alpha: 0.15),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
const Text('🔥', style: TextStyle(fontSize: 32)),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'连续 $streakDays',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
streakDays >= 7
? '太棒了!你已经坚持了一周 ✨'
: '继续加油,坚持就是胜利!',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.7),
),
),
],
),
),
],
),
),
);
}
}
// ===== 辅助函数 =====
String _moodEmoji(Mood mood) => switch (mood) {
Mood.happy => '😊',
Mood.calm => '😌',
Mood.sad => '😢',
Mood.angry => '😠',
Mood.thinking => '🤔',
};
String _moodLabel(Mood mood) => switch (mood) {
Mood.happy => '开心',
Mood.calm => '平静',
Mood.sad => '难过',
Mood.angry => '生气',
Mood.thinking => '思考',
};

View File

@@ -1,13 +1,215 @@
import 'package:flutter/material.dart';
// 贴纸库页面 — 贴纸包浏览 + 贴纸网格
class StickerLibraryPage extends StatelessWidget {
import 'package:flutter/material.dart';
import 'package:nuanji_app/core/theme/app_colors.dart';
/// 贴纸包数据模型
class StickerPack {
final String id;
final String name;
final String? coverEmoji;
final int stickerCount;
final bool isFree;
final String? category;
const StickerPack({
required this.id,
required this.name,
this.coverEmoji,
this.stickerCount = 0,
this.isFree = true,
this.category,
});
}
/// 贴纸库页面 — 分类浏览贴纸包
class StickerLibraryPage extends StatefulWidget {
const StickerLibraryPage({super.key});
@override
State<StickerLibraryPage> createState() => _StickerLibraryPageState();
}
class _StickerLibraryPageState extends State<StickerLibraryPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
// Phase 1 占位数据
final _categories = ['全部', '动物', '食物', '自然', '节日', '表情'];
final _packs = const [
StickerPack(id: '1', name: '可爱猫咪', coverEmoji: '🐱', stickerCount: 24, isFree: true, category: '动物'),
StickerPack(id: '2', name: '小兔子系列', coverEmoji: '🐰', stickerCount: 20, isFree: true, category: '动物'),
StickerPack(id: '3', name: '甜品派对', coverEmoji: '🍰', stickerCount: 18, isFree: true, category: '食物'),
StickerPack(id: '4', name: '花朵合集', coverEmoji: '🌸', stickerCount: 22, isFree: true, category: '自然'),
StickerPack(id: '5', name: '夏日清凉', coverEmoji: '🍉', stickerCount: 16, isFree: true, category: '食物'),
StickerPack(id: '6', name: '星空物语', coverEmoji: '', stickerCount: 20, isFree: false, category: '自然'),
StickerPack(id: '7', name: '开心表情', coverEmoji: '😄', stickerCount: 30, isFree: true, category: '表情'),
StickerPack(id: '8', name: '新年快乐', coverEmoji: '🎉', stickerCount: 15, isFree: false, category: '节日'),
];
@override
void initState() {
super.initState();
_tabController = TabController(length: _categories.length, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Text('贴纸库 - 占位页面'),
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('贴纸库'),
bottom: TabBar(
controller: _tabController,
isScrollable: true,
tabAlignment: TabAlignment.start,
tabs: _categories.map((c) => Tab(text: c)).toList(),
),
),
body: TabBarView(
controller: _tabController,
children: _categories.map((category) {
final filtered = category == '全部'
? _packs
: _packs.where((p) => p.category == category).toList();
return _StickerPackGrid(packs: filtered, colorScheme: colorScheme);
}).toList(),
),
);
}
}
/// 贴纸包网格
class _StickerPackGrid extends StatelessWidget {
const _StickerPackGrid({
required this.packs,
required this.colorScheme,
});
final List<StickerPack> packs;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
if (packs.isEmpty) {
return const Center(child: Text('暂无贴纸包'));
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.85,
),
itemCount: packs.length,
itemBuilder: (context, index) {
final pack = packs[index];
return _StickerPackCard(pack: pack, colorScheme: colorScheme);
},
);
}
}
/// 贴纸包卡片
class _StickerPackCard extends StatelessWidget {
const _StickerPackCard({
required this.pack,
required this.colorScheme,
});
final StickerPack pack;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: InkWell(
onTap: () {
// Phase 1: 展示贴纸包详情页(待实现)
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('打开贴纸包: ${pack.name}')),
);
},
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 贴纸包封面图标
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(16),
),
alignment: Alignment.center,
child: Text(
pack.coverEmoji ?? '🎨',
style: const TextStyle(fontSize: 32),
),
),
const SizedBox(height: 12),
// 名称
Text(
pack.name,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// 数量和价格标签
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${pack.stickerCount}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
if (!pack.isFree) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.accent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'积分',
style: theme.textTheme.labelSmall?.copyWith(
color: AppColors.accent,
),
),
),
],
],
),
],
),
),
),
);
}

View File

@@ -1,13 +1,189 @@
import 'package:flutter/material.dart';
// 模板画廊页面 — 日记模板浏览和选择
class TemplateGalleryPage extends StatelessWidget {
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:nuanji_app/core/theme/app_colors.dart';
/// 模板数据模型
class Template {
final String id;
final String name;
final String emoji;
final String? category;
final bool isFree;
final String? description;
const Template({
required this.id,
required this.name,
required this.emoji,
this.category,
this.isFree = true,
this.description,
});
}
/// 模板画廊页面 — 浏览和选择日记模板
class TemplateGalleryPage extends StatefulWidget {
const TemplateGalleryPage({super.key});
@override
State<TemplateGalleryPage> createState() => _TemplateGalleryPageState();
}
class _TemplateGalleryPageState extends State<TemplateGalleryPage> {
String _selectedCategory = '全部';
final _categories = ['全部', '日常', '旅行', '校园', '节日', '创意'];
// Phase 1 占位数据
final _templates = const [
Template(id: '1', name: '今日心情', emoji: '💭', category: '日常', description: '记录今天的心情和感受'),
Template(id: '2', name: '校园日记', emoji: '📚', category: '校园', description: '在学校的一天'),
Template(id: '3', name: '旅行手账', emoji: '🗺️', category: '旅行', description: '记录旅行中的美好瞬间'),
Template(id: '4', name: '美食记录', emoji: '🍜', category: '日常', description: '记录今天吃到的美食'),
Template(id: '5', name: '读书笔记', emoji: '📖', category: '校园', description: '记录读完一本书的感想'),
Template(id: '6', name: '节日特辑', emoji: '🎄', category: '节日', description: '特别的节日记录'),
Template(id: '7', name: '自然观察', emoji: '🌿', category: '创意', description: '记录大自然的发现'),
Template(id: '8', name: '梦想清单', emoji: '', category: '创意', description: '写下心中的梦想'),
Template(id: '9', name: '周末时光', emoji: '☀️', category: '日常', description: '悠闲的周末记录'),
Template(id: '10', name: '运动打卡', emoji: '🏃', category: '日常', description: '记录运动和锻炼'),
];
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Text('模板画廊 - 占位页面'),
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final filtered = _selectedCategory == '全部'
? _templates
: _templates.where((t) => t.category == _selectedCategory).toList();
return Scaffold(
appBar: AppBar(
title: const Text('模板画廊'),
),
body: Column(
children: [
// 分类选择器
SizedBox(
height: 48,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: _categories.map((cat) {
final isSelected = cat == _selectedCategory;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
selected: isSelected,
label: Text(cat),
onSelected: (_) {
setState(() => _selectedCategory = cat);
},
selectedColor: colorScheme.primaryContainer,
checkmarkColor: colorScheme.primary,
),
);
}).toList(),
),
),
const SizedBox(height: 8),
// 模板网格
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.78,
),
itemCount: filtered.length,
itemBuilder: (context, index) {
return _TemplateCard(template: filtered[index]);
},
),
),
],
),
);
}
}
/// 模板卡片
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;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: InkWell(
onTap: () {
// 使用模板创建日记
context.go('/editor?template=${template.id}');
},
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 模板预览区
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colorScheme.primaryContainer.withValues(alpha: 0.5),
AppColors.tertiary.withValues(alpha: 0.3),
],
),
borderRadius: BorderRadius.circular(16),
),
alignment: Alignment.center,
child: Text(
template.emoji,
style: const TextStyle(fontSize: 36),
),
),
const SizedBox(height: 12),
// 模板名称
Text(
template.name,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
// 描述
if (template.description != null)
Text(
template.description!,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
),
),
),
);
}