// 心情页面 — 今日心情 + 天气 + 柱状图 + 统计网格 + 心情洞察 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 createState() => _MoodPageState(); } class _MoodPageState extends State { late final MoodBloc _bloc; // 天气选择状态 _WeatherType? _selectedWeather; @override void initState() { super.initState(); _bloc = MoodBloc(api: context.read()); _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 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 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(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(0, (sum, mc) => sum + mc.count); final totalCount = stats.moodCounts .fold(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(0, (sum, mc) => sum + mc.count); final sadCount = stats.moodCounts .where((mc) => mc.mood == Mood.sad || mc.mood == Mood.angry) .fold(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, }); }