fix(app): 修复 P2~P4 共 10 项前端问题
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

P2 必须修复:
- 教师布置主题 classId 从硬编码改为班级下拉选择器
- 班级日记墙使用服务端 classId 过滤替代前端过滤
- Profile 统计栏接入 JournalRepository 真实数据
- WeeklyPage 从全硬编码改为 JournalRepository 数据驱动

P3 建议改进:
- 提取 mood_utils.dart 公共函数,消除 4 处重复定义
- 贴纸库搜索框连接 StickerBloc 按名称过滤

P4 细节打磨:
- 家长页多孩子时显示 DropdownButton 选择器
- 搜索结果日记卡片点击跳转 /editor?id=
- MonthlyPage 照片数量从 JournalElement 统计
- calendar_page/mood_page/search_page 统一使用 moodToEmoji/moodToLabel
This commit is contained in:
iven
2026-06-02 20:21:51 +08:00
parent 75db6a7eb7
commit 7e928ae1e1
17 changed files with 2537 additions and 799 deletions

View File

@@ -1,15 +1,16 @@
// 心情页面 — 心情统计 + 趋势图 + 连续天数
// 心情页面 — 今日心情 + 天气 + 柱状图 + 统计网格 + 心情洞察
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/core/theme/app_radius.dart';
import 'package:nuanji_app/core/utils/mood_utils.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});
@@ -20,6 +21,9 @@ class MoodPage extends StatefulWidget {
class _MoodPageState extends State<MoodPage> {
late final MoodBloc _bloc;
// 天气选择状态
_WeatherType? _selectedWeather;
@override
void initState() {
super.initState();
@@ -70,38 +74,42 @@ class _MoodPageState extends State<MoodPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 统计概览卡片
_StatsOverviewCard(stats: state.stats, colorScheme: colorScheme),
// 5A: 今日心情卡片
_TodayMoodCard(stats: state.stats),
const SizedBox(height: 16),
// 5B: 天气卡片
_WeatherCard(
selectedWeather: _selectedWeather,
onWeatherSelected: (w) {
setState(() {
_selectedWeather =
_selectedWeather == w ? null : w;
});
},
),
const SizedBox(height: 16),
// 周期选择器
_PeriodSelector(
_PeriodPills(
selectedPeriod: state.selectedPeriod,
onPeriodChanged: _bloc.changePeriod,
),
const SizedBox(height: 16),
// 心情分布饼
_MoodDistributionChart(
// 5C: 柱状
_MoodBarChart(
moodCounts: state.stats.moodCounts,
colorScheme: colorScheme,
selectedPeriod: state.selectedPeriod,
),
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)),
// 5D: 统计网格
_StatsGrid(stats: state.stats),
const SizedBox(height: 24),
// 连续天数鼓励卡片
_StreakCard(streakDays: state.stats.streakDays),
// 5E: 心情洞察卡片
_InsightCard(stats: state.stats),
],
),
);
@@ -110,75 +118,204 @@ class _MoodPageState extends State<MoodPage> {
}
}
/// 统计概览卡片
class _StatsOverviewCard extends StatelessWidget {
const _StatsOverviewCard({
required this.stats,
required this.colorScheme,
});
// ===== 5A: 今日心情卡片 =====
class _TodayMoodCard extends StatelessWidget {
const _TodayMoodCard({required this.stats});
final MoodStats stats;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final now = DateTime.now();
final dateStr =
'${now.year}${now.month}${now.day}';
final dominantEmoji = stats.dominantMood != null
? _moodEmoji(stats.dominantMood!)
? moodToEmoji(stats.dominantMood!)
: '📝';
final dominantLabel = stats.dominantMood != null
? moodToLabel(stats.dominantMood!)
: '暂无记录';
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.lgBorder,
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.lg),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.accent, AppColors.tertiary],
),
),
color: colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
// 主导心情图标
Container(
width: 56,
height: 56,
child: Stack(
children: [
// 装饰圆
Positioned(
right: -20,
top: -20,
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primary.withValues(alpha: 0.15),
color: Colors.white.withValues(alpha: 0.12),
),
alignment: Alignment.center,
child: Text(dominantEmoji, style: const TextStyle(fontSize: 28)),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
),
// 内容
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'今日心情 · $dateStr',
style: TextStyle(
fontFamily: 'Caveat',
fontSize: 16,
color: Colors.white.withValues(alpha: 0.85),
),
),
const SizedBox(height: 16),
Row(
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),
Text(dominantEmoji,
style: const TextStyle(fontSize: 52)),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dominantLabel,
style: theme.textTheme.headlineSmall
?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
'${stats.totalJournals} 篇日记 · 连续 ${stats.streakDays}',
style: TextStyle(
fontSize: 13,
color:
Colors.white.withValues(alpha: 0.75),
),
),
],
),
),
],
),
),
],
),
],
),
],
),
);
}
}
/// 周期选择器
class _PeriodSelector extends StatelessWidget {
const _PeriodSelector({
// ===== 5B: 天气卡片 =====
enum _WeatherType {
sunny('', '☀️'),
cloudy('多云', ''),
rainy('', '🌧️'),
snowy('', '❄️'),
windy('', '💨');
const _WeatherType(this.label, this.emoji);
final String label;
final String emoji;
}
class _WeatherCard extends StatelessWidget {
const _WeatherCard({
required this.selectedWeather,
required this.onWeatherSelected,
});
final _WeatherType? selectedWeather;
final ValueChanged<_WeatherType> onWeatherSelected;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final surfaceColor =
isDark ? AppColors.surfaceDark : AppColors.surfaceLight;
final surfaceWarmColor = isDark
? AppColors.surfaceWarmDark
: AppColors.surfaceWarmLight;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: surfaceColor,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'今日天气',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: _WeatherType.values.map((w) {
final isSelected = selectedWeather == w;
return GestureDetector(
onTap: () => onWeatherSelected(w),
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(
color: isSelected
? AppColors.accent
: (isDark
? AppColors.borderDark
: AppColors.borderLight),
width: 2,
),
color: isSelected
? surfaceWarmColor
: Colors.transparent,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(w.emoji,
style: const TextStyle(fontSize: 20)),
const SizedBox(height: 2),
Text(
w.label,
style: theme.textTheme.labelSmall
?.copyWith(fontSize: 11),
),
],
),
),
);
}).toList(),
),
],
),
);
}
}
// ===== 周期选择胶囊 =====
class _PeriodPills extends StatelessWidget {
const _PeriodPills({
required this.selectedPeriod,
required this.onPeriodChanged,
});
@@ -188,119 +325,190 @@ class _PeriodSelector extends StatelessWidget {
@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),
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final periods = [
(StatsPeriod.week, '7天'),
(StatsPeriod.month, '30天'),
(StatsPeriod.quarter, '3个月'),
];
return Row(
children: periods.map((p) {
final isSelected = selectedPeriod == p.$1;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: GestureDetector(
onTap: () => onPeriodChanged(p.$1),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.pill),
color: isSelected
? AppColors.accent
: Colors.transparent,
border: Border.all(
color: isSelected
? AppColors.accent
: (isDark
? AppColors.borderDark
: AppColors.borderLight),
),
),
child: Text(
p.$2,
style: theme.textTheme.bodySmall?.copyWith(
color: isSelected
? Colors.white
: (isDark
? AppColors.fg2Dark
: AppColors.fg2Light),
fontWeight:
isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
),
),
);
}).toList(),
);
}
}
/// 心情分布饼图
class _MoodDistributionChart extends StatelessWidget {
const _MoodDistributionChart({
// ===== 5C: 柱状图 =====
class _MoodBarChart extends StatelessWidget {
const _MoodBarChart({
required this.moodCounts,
required this.colorScheme,
required this.selectedPeriod,
});
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: AppRadius.lgBorder,
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;
final StatsPeriod selectedPeriod;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color =
AppColors.moodColors[mc.mood.value] ?? theme.colorScheme.primary;
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
if (moodCounts.isEmpty) {
return const SizedBox.shrink();
}
final maxCount = moodCounts
.fold<int>(0, (max, mc) => mc.count > max ? mc.count : max);
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark
? AppColors.surfaceDark
: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(
color: isDark
? AppColors.borderDark
: AppColors.borderLight,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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,
),
),
],
Text(
'心情分布',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 12),
const SizedBox(height: 16),
SizedBox(
width: 48,
child: Text(
'${mc.count}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
height: 180,
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: (maxCount + 2).toDouble(),
barGroups: moodCounts.asMap().entries.map((entry) {
final index = entry.key;
final mc = entry.value;
final color =
AppColors.moodColors[mc.mood.value] ??
colorScheme.primary;
return BarChartGroupData(
x: index,
barRods: [
BarChartRodData(
toY: mc.count.toDouble(),
color: color,
width: 14,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(4),
),
),
],
showingTooltipIndicators: [0],
);
}).toList(),
titlesData: FlTitlesData(
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 ||
index >= moodCounts.length) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
moodToEmoji(moodCounts[index].mood),
style: const TextStyle(fontSize: 16),
),
);
},
reservedSize: 28,
),
),
),
borderData: FlBorderData(show: false),
gridData: const FlGridData(show: false),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipItem:
(group, groupIndex, rod, rodIndex) {
if (groupIndex >= moodCounts.length) {
return null;
}
final mc = moodCounts[groupIndex];
return BarTooltipItem(
'${moodToLabel(mc.mood)}: ${mc.count}',
TextStyle(
color: isDark
? AppColors.fgDark
: AppColors.fgLight,
fontWeight: FontWeight.w600,
fontSize: 12,
),
);
},
),
),
),
textAlign: TextAlign.end,
),
),
],
@@ -309,72 +517,268 @@ class _MoodCountTile extends StatelessWidget {
}
}
/// 连续天数鼓励卡片
class _StreakCard extends StatelessWidget {
const _StreakCard({required this.streakDays});
// ===== 5D: 统计网格 =====
final int streakDays;
class _StatsGrid extends StatelessWidget {
const _StatsGrid({required this.stats});
final MoodStats stats;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.lgBorder,
// 计算好心情占比
final happyCount = stats.moodCounts
.where((mc) => mc.mood == Mood.happy)
.fold<int>(0, (sum, mc) => sum + mc.count);
final totalCount = stats.moodCounts
.fold<int>(0, (sum, mc) => sum + mc.count);
final goodPercent = totalCount > 0
? (happyCount / totalCount * 100).toStringAsFixed(0)
: '0';
// TODO: 从统计数据获取实际照片数,暂时用 0
const photoCount = 0;
final items = [
_StatItem(
emoji: '📝',
value: '${stats.totalJournals}',
description: '日记总数',
color: AppColors.secondary,
),
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),
),
),
],
),
_StatItem(
emoji: '🔥',
value: '${stats.streakDays}',
description: '连续天数',
color: AppColors.accent,
),
_StatItem(
emoji: '😊',
value: '$goodPercent%',
description: '好心情占比',
color: AppColors.tertiary,
),
_StatItem(
emoji: '📷',
value: '$photoCount',
description: '照片数量',
color: AppColors.rose,
),
];
return GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 1.4,
children: items.map((item) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark
? AppColors.surfaceDark
: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(
color: isDark
? AppColors.borderDark
: AppColors.borderLight,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(item.emoji,
style: const TextStyle(fontSize: 28)),
const SizedBox(height: 8),
Text(
item.value,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
color: item.color,
),
),
const SizedBox(height: 2),
Text(
item.description,
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: isDark
? AppColors.mutedDark
: AppColors.mutedLight,
),
),
],
),
);
}).toList(),
);
}
}
class _StatItem {
final String emoji;
final String value;
final String description;
final Color color;
const _StatItem({
required this.emoji,
required this.value,
required this.description,
required this.color,
});
}
// ===== 5E: 心情洞察卡片 =====
class _InsightCard extends StatelessWidget {
const _InsightCard({required this.stats});
final MoodStats stats;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
// 计算最频繁心情
final mostFrequent = stats.moodCounts.isNotEmpty
? stats.moodCounts.reduce(
(a, b) => a.count > b.count ? a : b)
: null;
// 计算心情趋势简化基于好心情vs坏心情比例判断
final happyCount = stats.moodCounts
.where((mc) =>
mc.mood == Mood.happy || mc.mood == Mood.calm)
.fold<int>(0, (sum, mc) => sum + mc.count);
final sadCount = stats.moodCounts
.where((mc) =>
mc.mood == Mood.sad || mc.mood == Mood.angry)
.fold<int>(0, (sum, mc) => sum + mc.count);
final trendLabel = happyCount >= sadCount ? 'improving' : 'declining';
final trendEmoji = happyCount >= sadCount ? '📈' : '📉';
final trendText = happyCount >= sadCount ? '越来越好' : '需要关注';
final insights = [
_InsightItem(
emoji: mostFrequent != null
? moodToEmoji(mostFrequent.mood)
: '📝',
title: '最频繁心情',
detail: mostFrequent != null
? '${moodToLabel(mostFrequent.mood)} · ${mostFrequent.count}'
: '暂无数据',
color: AppColors.secondary,
),
_InsightItem(
emoji: '🔥',
title: '最长连续',
detail: '${stats.streakDays}',
color: AppColors.accent,
),
_InsightItem(
emoji: trendEmoji,
title: '心情趋势',
detail: trendText,
color: trendLabel == 'improving'
? AppColors.secondary
: AppColors.rose,
),
];
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark
? AppColors.surfaceDark
: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(
color: isDark
? AppColors.borderDark
: AppColors.borderLight,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'心情洞察',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
...insights.map((item) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
item.color.withValues(alpha: 0.15),
),
alignment: Alignment.center,
child: Text(item.emoji,
style: const TextStyle(fontSize: 18)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
item.title,
style:
theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
item.detail,
style:
theme.textTheme.bodySmall?.copyWith(
color: isDark
? AppColors.mutedDark
: AppColors.mutedLight,
),
),
],
),
),
],
),
)),
],
),
);
}
}
// ===== 辅助函数 =====
class _InsightItem {
final String emoji;
final String title;
final String detail;
final Color color;
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 => '思考',
};
const _InsightItem({
required this.emoji,
required this.title,
required this.detail,
required this.color,
});
}