新增年度统计

This commit is contained in:
ddshi 2025-01-02 13:39:25 +08:00
parent 3fcc6c244c
commit b665409d65
5 changed files with 434 additions and 13 deletions

View File

@ -6,6 +6,8 @@ import '../widgets/statistics/monthly_overview.dart';
import '../widgets/statistics/daily_chart.dart'; import '../widgets/statistics/daily_chart.dart';
import '../widgets/statistics/category_chart.dart'; import '../widgets/statistics/category_chart.dart';
import '../widgets/statistics/daily_report.dart'; import '../widgets/statistics/daily_report.dart';
import '../widgets/statistics/monthly_chart.dart';
import '../widgets/statistics/monthly_report.dart';
class StatisticsPage extends StatefulWidget { class StatisticsPage extends StatefulWidget {
const StatisticsPage({Key? key}) : super(key: key); const StatisticsPage({Key? key}) : super(key: key);
@ -112,8 +114,37 @@ class _StatisticsPageState extends State<StatisticsPage> {
/// ///
Widget _buildYearView() { Widget _buildYearView() {
return const Center( if (_records.isEmpty) {
child: Text('年度统计功能开发中...'), return const Center(
child: Text('暂无数据'),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
//
MonthlyOverview(
records: _records,
isYearView: true,
),
const SizedBox(height: 16),
//
if (_hasValidRecords) ...[
MonthlyChart(records: _records),
const SizedBox(height: 16),
],
//
if (_hasValidRecords) ...[
CategoryChart(records: _records),
const SizedBox(height: 16),
],
//
if (_hasValidRecords)
MonthlyReport(records: _records),
],
),
); );
} }

View File

@ -23,6 +23,9 @@ class _CategoryChartState extends State<CategoryChart> {
final categoryData = _getCategoryData(); final categoryData = _getCategoryData();
if (categoryData.isEmpty) return const SizedBox(); if (categoryData.isEmpty) return const SizedBox();
final isYearView = widget.records.any((r) =>
r.createTime.month != widget.records.first.createTime.month);
return Container( return Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -41,9 +44,9 @@ class _CategoryChartState extends State<CategoryChart> {
children: [ children: [
Row( Row(
children: [ children: [
const Text( Text(
'分类统计', '${isYearView ? "年度" : ""}分类统计',
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),

View File

@ -0,0 +1,244 @@
import 'package:flutter/material.dart';
import '../../models/record.dart';
class MonthlyChart extends StatelessWidget {
final List<Record> records;
const MonthlyChart({
Key? key,
required this.records,
}) : super(key: key);
@override
Widget build(BuildContext context) {
//
final monthlyData = _getMonthlyData();
if (monthlyData.isEmpty) return const SizedBox();
//
final maxAmount = monthlyData.fold(0.0, (max, data) {
final monthMax = data.income > data.expense ? data.income : data.expense;
return monthMax > max ? monthMax : max;
});
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'每月收支',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 20),
SizedBox(
height: 200,
child: CustomPaint(
size: const Size(double.infinity, 200),
painter: ChartPainter(
monthlyData: monthlyData,
maxAmount: maxAmount,
),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLegend('支出', Colors.red),
const SizedBox(width: 24),
_buildLegend('收入', Colors.green),
],
),
],
),
);
}
List<MonthData> _getMonthlyData() {
final Map<int, MonthData> monthlyMap = {};
// 12
for (int month = 1; month <= 12; month++) {
monthlyMap[month] = MonthData(month: month);
}
//
for (final record in records) {
final month = record.createTime.month;
if (record.type == RecordType.expense) {
monthlyMap[month]!.expense += record.amount;
} else {
monthlyMap[month]!.income += record.amount;
}
}
return monthlyMap.values.toList();
}
Widget _buildLegend(String label, Color color) {
return Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(label),
],
);
}
}
class MonthData {
final int month;
double expense;
double income;
MonthData({
required this.month,
this.expense = 0,
this.income = 0,
});
}
class ChartPainter extends CustomPainter {
final List<MonthData> monthlyData;
final double maxAmount;
ChartPainter({
required this.monthlyData,
required this.maxAmount,
});
@override
void paint(Canvas canvas, Size size) {
final expensePath = Path();
final incomePath = Path();
final expensePaint = Paint()
..color = Colors.red.withOpacity(0.8)
..style = PaintingStyle.stroke
..strokeWidth = 2;
final incomePaint = Paint()
..color = Colors.green.withOpacity(0.8)
..style = PaintingStyle.stroke
..strokeWidth = 2;
final width = size.width;
final height = size.height - 20; //
final monthWidth = width / 11; // 1211
// 线
final gridPaint = Paint()
..color = Colors.grey.withOpacity(0.2)
..style = PaintingStyle.stroke
..strokeWidth = 1;
// 线
for (var i = 0; i <= 4; i++) {
final y = height * i / 4;
canvas.drawLine(
Offset(0, y),
Offset(width, y),
gridPaint,
);
}
//
void drawAmountLabel(double x, double y, double amount) {
if (amount == 0) return;
final textPainter = TextPainter(
text: TextSpan(
text: amount >= 1000
? '${(amount / 1000).toStringAsFixed(1)}k'
: amount.toStringAsFixed(0),
style: TextStyle(
color: Colors.grey[600],
fontSize: 10,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(x - textPainter.width / 2, y - 15),
);
}
// 线
for (var i = 0; i < monthlyData.length; i++) {
final x = i * monthWidth;
final expenseY = height - (height * monthlyData[i].expense / maxAmount);
final incomeY = height - (height * monthlyData[i].income / maxAmount);
if (i == 0) {
expensePath.moveTo(x, expenseY);
incomePath.moveTo(x, incomeY);
} else {
expensePath.lineTo(x, expenseY);
incomePath.lineTo(x, incomeY);
}
//
if (monthlyData[i].expense > 0) {
canvas.drawCircle(
Offset(x, expenseY),
3,
Paint()..color = Colors.red,
);
drawAmountLabel(x, expenseY, monthlyData[i].expense);
}
if (monthlyData[i].income > 0) {
canvas.drawCircle(
Offset(x, incomeY),
3,
Paint()..color = Colors.green,
);
drawAmountLabel(x, incomeY, monthlyData[i].income);
}
//
final textPainter = TextPainter(
text: TextSpan(
text: '${monthlyData[i].month}',
style: TextStyle(
color: Colors.grey[600],
fontSize: 10,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(x - textPainter.width / 2, height + 5),
);
}
canvas.drawPath(expensePath, expensePaint);
canvas.drawPath(incomePath, incomePaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

View File

@ -3,10 +3,12 @@ import '../../models/record.dart';
class MonthlyOverview extends StatelessWidget { class MonthlyOverview extends StatelessWidget {
final List<Record> records; final List<Record> records;
final bool isYearView;
const MonthlyOverview({ const MonthlyOverview({
Key? key, Key? key,
required this.records, required this.records,
this.isYearView = false,
}) : super(key: key); }) : super(key: key);
@override @override
@ -22,12 +24,10 @@ class MonthlyOverview extends StatelessWidget {
final balance = totalIncome - totalExpense; final balance = totalIncome - totalExpense;
// //
final daysInMonth = DateTime( final daysInPeriod = isYearView
records.first.createTime.year, ? (records.isNotEmpty ? _getDaysInYear(records.first.createTime.year) : 365)
records.first.createTime.month + 1, : (records.isNotEmpty ? _getDaysInMonth(records.first.createTime) : 30);
0, final dailyAverage = totalExpense / daysInPeriod;
).day;
final dailyAverage = totalExpense / daysInMonth;
return Container( return Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
@ -48,14 +48,14 @@ class MonthlyOverview extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: _buildOverviewItem( child: _buildOverviewItem(
label: '支出', label: '${isYearView ? "" : ""}支出',
amount: totalExpense, amount: totalExpense,
textColor: Colors.red, textColor: Colors.red,
), ),
), ),
Expanded( Expanded(
child: _buildOverviewItem( child: _buildOverviewItem(
label: '收入', label: '${isYearView ? "" : ""}收入',
amount: totalIncome, amount: totalIncome,
textColor: Colors.green, textColor: Colors.green,
), ),
@ -87,6 +87,14 @@ class MonthlyOverview extends StatelessWidget {
); );
} }
int _getDaysInMonth(DateTime date) {
return DateTime(date.year, date.month + 1, 0).day;
}
int _getDaysInYear(int year) {
return DateTime(year).isLeapYear ? 366 : 365;
}
Widget _buildOverviewItem({ Widget _buildOverviewItem({
required String label, required String label,
required double amount, required double amount,
@ -120,4 +128,10 @@ class MonthlyOverview extends StatelessWidget {
], ],
); );
} }
}
extension DateTimeExtension on DateTime {
bool get isLeapYear {
return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
}
} }

View File

@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import '../../models/record.dart';
class MonthlyReport extends StatelessWidget {
final List<Record> records;
const MonthlyReport({
Key? key,
required this.records,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final monthlyData = _getMonthlyData();
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'月报表',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Table(
columnWidths: const {
0: FlexColumnWidth(1.5),
1: FlexColumnWidth(2),
2: FlexColumnWidth(2),
3: FlexColumnWidth(2),
},
children: [
const TableRow(
children: [
Text('月份', style: TextStyle(fontWeight: FontWeight.bold)),
Text('收入', style: TextStyle(fontWeight: FontWeight.bold)),
Text('支出', style: TextStyle(fontWeight: FontWeight.bold)),
Text('结余', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
...monthlyData.map((data) => TableRow(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text('${data.month}'),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'¥${data.income.toStringAsFixed(2)}',
style: const TextStyle(color: Colors.green),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'¥${data.expense.toStringAsFixed(2)}',
style: const TextStyle(color: Colors.red),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'¥${(data.income - data.expense).toStringAsFixed(2)}',
style: TextStyle(
color: data.income >= data.expense
? Colors.green
: Colors.red,
),
),
),
],
)).toList(),
],
),
],
),
);
}
List<MonthData> _getMonthlyData() {
final Map<int, MonthData> monthlyMap = {};
// 12
for (int month = 1; month <= 12; month++) {
monthlyMap[month] = MonthData(month: month);
}
//
for (final record in records) {
final month = record.createTime.month;
if (record.type == RecordType.expense) {
monthlyMap[month]!.expense += record.amount;
} else {
monthlyMap[month]!.income += record.amount;
}
}
return monthlyMap.values.toList()
..sort((a, b) => a.month.compareTo(b.month));
}
}
class MonthData {
final int month;
double expense;
double income;
MonthData({
required this.month,
this.expense = 0,
this.income = 0,
});
}