create project

This commit is contained in:
2026-05-19 11:46:42 +08:00
commit dcdf1f60b1
60 changed files with 9309 additions and 0 deletions

View File

@@ -0,0 +1,696 @@
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<StatisticsScreen> createState() => _StatisticsScreenState();
}
class _StatisticsScreenState extends ConsumerState<StatisticsScreen> {
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<Bill> _filterBillsByMonth(List<Bill> 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<String, double> _getCategoryTotals(List<Bill> bills, bool isExpense) {
final Map<String, double> totals = {};
for (final bill in bills) {
if (isExpense ? bill.isExpense : bill.isIncome) {
totals[bill.category] = (totals[bill.category] ?? 0) + bill.amount;
}
}
return totals;
}
Map<int, double> _getDailyTotals(List<Bill> bills) {
final Map<int, double> 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<Map<String, dynamic>> _getMonthlyComparison(List<Bill> bills) {
final now = DateTime.now();
final List<Map<String, dynamic>> 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<String, double> 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<int, double> dailyTotals) {
final spots = <FlSpot>[];
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<Map<String, dynamic>> 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,
),
),
),
],
),
],
),
);
}),
],
),
),
),
);
}
}