Files
nj/app/lib/features/mood/views/mood_page.dart
iven 7e928ae1e1
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
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
2026-06-02 20:21:51 +08:00

785 lines
24 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 心情页面 — 今日心情 + 天气 + 柱状图 + 统计网格 + 心情洞察
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});
@override
State<MoodPage> createState() => _MoodPageState();
}
class _MoodPageState extends State<MoodPage> {
late final MoodBloc _bloc;
// 天气选择状态
_WeatherType? _selectedWeather;
@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: [
// 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),
// 周期选择器
_PeriodPills(
selectedPeriod: state.selectedPeriod,
onPeriodChanged: _bloc.changePeriod,
),
const SizedBox(height: 16),
// 5C: 柱状图
_MoodBarChart(
moodCounts: state.stats.moodCounts,
selectedPeriod: state.selectedPeriod,
),
const SizedBox(height: 24),
// 5D: 统计网格
_StatsGrid(stats: state.stats),
const SizedBox(height: 24),
// 5E: 心情洞察卡片
_InsightCard(stats: state.stats),
],
),
);
},
);
}
}
// ===== 5A: 今日心情卡片 =====
class _TodayMoodCard extends StatelessWidget {
const _TodayMoodCard({required this.stats});
final MoodStats stats;
@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
? moodToEmoji(stats.dominantMood!)
: '📝';
final dominantLabel = stats.dominantMood != null
? moodToLabel(stats.dominantMood!)
: '暂无记录';
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],
),
),
child: Stack(
children: [
// 装饰圆
Positioned(
right: -20,
top: -20,
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.12),
),
),
),
// 内容
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(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),
),
),
],
),
),
],
),
],
),
],
),
);
}
}
// ===== 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,
});
final StatsPeriod selectedPeriod;
final ValueChanged<StatsPeriod> onPeriodChanged;
@override
Widget build(BuildContext context) {
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(),
);
}
}
// ===== 5C: 柱状图 =====
class _MoodBarChart extends StatelessWidget {
const _MoodBarChart({
required this.moodCounts,
required this.selectedPeriod,
});
final List<MoodCount> moodCounts;
final StatsPeriod selectedPeriod;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
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(
'心情分布',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
SizedBox(
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,
),
);
},
),
),
),
),
),
],
),
);
}
}
// ===== 5D: 统计网格 =====
class _StatsGrid extends StatelessWidget {
const _StatsGrid({required this.stats});
final MoodStats stats;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
// 计算好心情占比
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,
),
_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;
const _InsightItem({
required this.emoji,
required this.title,
required this.detail,
required this.color,
});
}