import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fl_chart/fl_chart.dart'; import '../../providers/bill_provider.dart'; import '../../widgets/stat_card.dart'; import '../../constants.dart'; import '../../theme/colors.dart'; class StatisticsScreen extends ConsumerStatefulWidget { const StatisticsScreen({super.key}); @override ConsumerState createState() => _StatisticsScreenState(); } class _StatisticsScreenState extends ConsumerState { int _monthOffset = 0; DateTime get _selectedDate { final now = DateTime.now(); return DateTime(now.year, now.month + _monthOffset, 1); } String get _displayMonth { return '${_selectedDate.year}年${_selectedDate.month.toString().padLeft(2, '0')}月'; } List _filterBillsByMonth(List bills) { return bills.where((bill) { final billDate = DateTime.tryParse(bill.date); if (billDate == null) return false; return billDate.year == _selectedDate.year && billDate.month == _selectedDate.month; }).toList(); } Map _getCategoryTotals(List bills, bool isExpense) { final Map totals = {}; for (final bill in bills) { if (isExpense ? bill.isExpense : bill.isIncome) { totals[bill.category] = (totals[bill.category] ?? 0) + bill.amount; } } return totals; } Map _getDailyTotals(List bills) { final Map totals = {}; for (final bill in bills) { if (bill.isExpense) { final billDate = DateTime.tryParse(bill.date); if (billDate != null) { totals[billDate.day] = (totals[billDate.day] ?? 0) + bill.amount; } } } return totals; } List> _getMonthlyComparison(List bills) { final now = DateTime.now(); final List> result = []; for (int i = 5; i >= 0; i--) { final date = DateTime(now.year, now.month - i, 1); final monthBills = bills.where((bill) { final billDate = DateTime.tryParse(bill.date); return billDate != null && billDate.year == date.year && billDate.month == date.month; }).toList(); double income = 0; double expense = 0; for (final bill in monthBills) { if (bill.isIncome) income += bill.amount; else expense += bill.amount; } result.add({ 'month': '${date.year}-${date.month.toString().padLeft(2, '0')}', 'income': income, 'expense': expense, }); } return result; } @override Widget build(BuildContext context) { final billsAsync = ref.watch(billProvider); return Scaffold( backgroundColor: AppColors.pageBg, body: billsAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Error: $e')), data: (bills) { final monthBills = _filterBillsByMonth(bills); double totalIncome = 0; double totalExpense = 0; for (final bill in monthBills) { if (bill.isIncome) totalIncome += bill.amount; else totalExpense += bill.amount; } final balance = totalIncome - totalExpense; final expenseCategories = _getCategoryTotals(monthBills, true); final incomeCategories = _getCategoryTotals(monthBills, false); final dailyTotals = _getDailyTotals(monthBills); final monthlyComparison = _getMonthlyComparison(bills); return CustomScrollView( slivers: [ _buildHeader(), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ _buildMonthSelector(), const SizedBox(height: 16), _buildStatCards(totalIncome, totalExpense, balance), const SizedBox(height: 20), _buildPieChart(totalExpense, balance), const SizedBox(height: 20), _buildCategoryRanking(expenseCategories, true), const SizedBox(height: 20), _buildCategoryRanking(incomeCategories, false), const SizedBox(height: 20), _buildDailyBarChart(dailyTotals), const SizedBox(height: 20), _buildMonthlyComparison(monthlyComparison), const SizedBox(height: 40), ], ), ), ), ], ); }, ), ); } Widget _buildHeader() { return SliverAppBar( expandedHeight: 100, floating: false, pinned: true, backgroundColor: AppColors.brand, flexibleSpace: FlexibleSpaceBar( title: const Text( '统计', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), ), background: Container( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [AppColors.brand, AppColors.brandLight], ), ), ), ), ); } Widget _buildMonthSelector() { return Container( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), decoration: BoxDecoration( color: AppColors.cardBg, borderRadius: BorderRadius.circular(16), border: Border.all(color: Colors.white.withOpacity(0.8)), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ IconButton( icon: const Icon(Icons.chevron_left, color: AppColors.textPrimary), onPressed: () => setState(() => _monthOffset--), ), Text( _displayMonth, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), IconButton( icon: const Icon(Icons.chevron_right, color: AppColors.textPrimary), onPressed: _monthOffset < 0 ? () => setState(() => _monthOffset++) : null, ), ], ), ); } Widget _buildStatCards(double income, double expense, double balance) { return Row( children: [ Expanded( child: StatCard( label: '收入', value: '¥${income.toStringAsFixed(2)}', color: AppColors.income, ), ), const SizedBox(width: 8), Expanded( child: StatCard( label: '支出', value: '¥${expense.toStringAsFixed(2)}', color: AppColors.expense, ), ), const SizedBox(width: 8), Expanded( child: StatCard( label: '结余', value: '¥${balance.toStringAsFixed(2)}', color: balance >= 0 ? AppColors.income : AppColors.expense, ), ), ], ); } Widget _buildPieChart(double expense, double balance) { final total = expense + balance; final usedPercent = total > 0 ? (expense / total * 100) : 0.0; final remainingPercent = total > 0 ? (balance / total * 100) : 0.0; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppColors.cardBg, borderRadius: BorderRadius.circular(20), border: Border.all(color: Colors.white.withOpacity(0.8)), boxShadow: [ BoxShadow( color: AppColors.brand.withOpacity(0.1), blurRadius: 20, offset: const Offset(0, 8), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(20), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '预算使用', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), const SizedBox(height: 16), SizedBox( height: 180, child: total > 0 ? PieChart( PieChartData( sectionsSpace: 2, centerSpaceRadius: 40, sections: [ PieChartSectionData( value: expense, color: AppColors.expense, title: '${usedPercent.toStringAsFixed(1)}%', titleStyle: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: Colors.white, ), radius: 50, ), PieChartSectionData( value: balance, color: AppColors.income, title: '${remainingPercent.toStringAsFixed(1)}%', titleStyle: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: Colors.white, ), radius: 50, ), ], ), ) : const Center( child: Text( '暂无数据', style: TextStyle(color: AppColors.textSecondary), ), ), ), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildLegendItem('已支出', AppColors.expense), const SizedBox(width: 24), _buildLegendItem('剩余', AppColors.income), ], ), ], ), ), ), ); } Widget _buildLegendItem(String label, Color color) { return Row( children: [ Container( width: 12, height: 12, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(3), ), ), const SizedBox(width: 6), Text( label, style: const TextStyle( fontSize: 12, color: AppColors.textSecondary, ), ), ], ); } Widget _buildCategoryRanking(Map categories, bool isExpense) { if (categories.isEmpty) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppColors.cardBg, borderRadius: BorderRadius.circular(20), border: Border.all(color: Colors.white.withOpacity(0.8)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( isExpense ? '支出分类' : '收入分类', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), const SizedBox(height: 16), const Center( child: Text( '暂无数据', style: TextStyle(color: AppColors.textSecondary), ), ), ], ), ); } final sortedEntries = categories.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); final maxAmount = sortedEntries.first.value; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppColors.cardBg, borderRadius: BorderRadius.circular(20), border: Border.all(color: Colors.white.withOpacity(0.8)), ), child: ClipRRect( borderRadius: BorderRadius.circular(20), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( isExpense ? '支出分类' : '收入分类', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), const SizedBox(height: 16), ...sortedEntries.take(6).map((entry) { final emoji = AppConstants.categoryEmoji(entry.key); final progress = maxAmount > 0 ? entry.value / maxAmount : 0.0; return Padding( padding: const EdgeInsets.only(bottom: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text(emoji, style: const TextStyle(fontSize: 16)), const SizedBox(width: 8), Expanded( child: Text( entry.key, style: const TextStyle( fontSize: 13, color: AppColors.textPrimary, ), ), ), Text( '¥${entry.value.toStringAsFixed(2)}', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: isExpense ? AppColors.expense : AppColors.income, ), ), ], ), const SizedBox(height: 6), ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: progress, backgroundColor: Colors.grey.shade200, valueColor: AlwaysStoppedAnimation( isExpense ? AppColors.expense : AppColors.income, ), minHeight: 6, ), ), ], ), ); }), ], ), ), ), ); } Widget _buildDailyBarChart(Map dailyTotals) { final spots = []; for (int day = 1; day <= 31; day++) { spots.add(FlSpot(day.toDouble(), dailyTotals[day] ?? 0)); } final maxY = dailyTotals.values.isEmpty ? 100.0 : dailyTotals.values.reduce((a, b) => a > b ? a : b) * 1.2; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppColors.cardBg, borderRadius: BorderRadius.circular(20), border: Border.all(color: Colors.white.withOpacity(0.8)), ), child: ClipRRect( borderRadius: BorderRadius.circular(20), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '日消费趋势', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), const SizedBox(height: 16), SizedBox( height: 180, child: BarChart( BarChartData( alignment: BarChartAlignment.spaceAround, maxY: maxY, barTouchData: BarTouchData( touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, groupIndex, rod, rodIndex) { return BarTooltipItem( '¥${rod.toY.toStringAsFixed(2)}', const TextStyle(color: Colors.white, fontSize: 12), ); }, ), ), titlesData: FlTitlesData( show: true, bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, getTitlesWidget: (value, meta) { if (value.toInt() % 5 == 0) { return Text( '${value.toInt()}日', style: const TextStyle( fontSize: 10, color: AppColors.textSecondary, ), ); } return const SizedBox.shrink(); }, reservedSize: 20, ), ), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, getTitlesWidget: (value, meta) { return Text( '¥${value.toInt()}', style: const TextStyle( fontSize: 10, color: AppColors.textSecondary, ), ); }, reservedSize: 40, ), ), topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), ), gridData: FlGridData( show: true, drawVerticalLine: false, horizontalInterval: maxY / 4, getDrawingHorizontalLine: (value) { return FlLine( color: Colors.grey.shade200, strokeWidth: 1, ); }, ), borderData: FlBorderData(show: false), barGroups: List.generate(31, (index) { final day = index + 1; final value = dailyTotals[day] ?? 0; return BarChartGroupData( x: day, barRods: [ BarChartRodData( toY: value, color: AppColors.brand, width: 8, borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), ), ], ); }), ), ), ), ], ), ), ), ); } Widget _buildMonthlyComparison(List> months) { double maxValue = 0; for (final month in months) { final income = month['income'] as double; final expense = month['expense'] as double; if (income > maxValue) maxValue = income; if (expense > maxValue) maxValue = expense; } if (maxValue == 0) maxValue = 1000; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppColors.cardBg, borderRadius: BorderRadius.circular(20), border: Border.all(color: Colors.white.withOpacity(0.8)), ), child: ClipRRect( borderRadius: BorderRadius.circular(20), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '月度对比', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), const SizedBox(height: 16), ...months.map((month) { final monthStr = month['month'] as String; final income = month['income'] as double; final expense = month['expense'] as double; final monthLabel = monthStr.substring(5); return Padding( padding: const EdgeInsets.only(bottom: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '$monthLabel月', style: const TextStyle( fontSize: 12, color: AppColors.textSecondary, ), ), Row( children: [ Text( '收¥${income.toStringAsFixed(0)}', style: const TextStyle( fontSize: 11, color: AppColors.income, ), ), const SizedBox(width: 8), Text( '支¥${expense.toStringAsFixed(0)}', style: const TextStyle( fontSize: 11, color: AppColors.expense, ), ), ], ), ], ), const SizedBox(height: 6), Row( children: [ Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(3), child: LinearProgressIndicator( value: maxValue > 0 ? income / maxValue : 0, backgroundColor: Colors.grey.shade200, valueColor: const AlwaysStoppedAnimation(AppColors.income), minHeight: 8, ), ), ), const SizedBox(width: 8), Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(3), child: LinearProgressIndicator( value: maxValue > 0 ? expense / maxValue : 0, backgroundColor: Colors.grey.shade200, valueColor: const AlwaysStoppedAnimation(AppColors.expense), minHeight: 8, ), ), ), ], ), ], ), ); }), ], ), ), ), ); } }