fix(app): 修复 P2~P4 共 10 项前端问题
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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user