Files
nj/app/lib/features/mood/views/mood_page.dart
iven 8331db63ba feat(app): 设置页 UI + Mood/成就/贴纸 BLoC 接入 API + B7 测试扩展
前端改动:
- 新建设置页面 (主题切换/关于/隐私政策/用户协议/儿童隐私保护)
- SettingsBloc 注册到 MultiRepositoryProvider 全局可访问
- MoodBloc 修复编译错误 + 接入 /diary/stats/mood API
- MoodPage 添加错误状态展示和重试按钮
- AchievementBloc + 页面改造接入 /diary/achievements API
- StickerBloc + 页面改造接入 /diary/sticker-packs API
- TemplateBloc + 页面改造接入 /diary/templates API
- ProfilePage 设置入口改为跳转 /settings
- 添加 /settings 路由

后端改动:
- 扩展 mood_stats_service 测试 (连续天数算法/心情计数/边界场景)
- 新增 class_service 测试 (班级码生成/唯一性/错误映射)
- 新增 achievement_service 测试 (DTO 结构/序列化/map 构建)
- 新增 sticker_service 测试 (DTO 序列化/错误处理)
- 扩展 dto.rs 测试 (achievement/mood_stats/sticker/template/notification)
- 清理 2 个 unused import warning

验证:
- cargo check 0 error 0 warning
- flutter analyze 0 error
2026-06-01 11:19:43 +08:00

380 lines
11 KiB
Dart

// 心情页面 — 心情统计 + 趋势图 + 连续天数
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nuanji_app/core/theme/app_colors.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/remote/api_client.dart';
import '../bloc/mood_bloc.dart';
/// 心情页面 — 统计卡片 + 心情分布饼图 + 详情列表
class MoodPage extends StatefulWidget {
const MoodPage({super.key});
@override
State<MoodPage> createState() => _MoodPageState();
}
class _MoodPageState extends State<MoodPage> {
late final MoodBloc _bloc;
@override
void initState() {
super.initState();
_bloc = MoodBloc(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;
return 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: 12),
Text(state.errorMessage!, style: theme.textTheme.bodyMedium),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: _bloc.load,
child: const Text('重试'),
),
],
),
);
}
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: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.onPrimary,
),
);
}).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 => '思考',
};