diff --git a/lib/pages/statistics_page.dart b/lib/pages/statistics_page.dart index 2ad3629..32d27e0 100644 --- a/lib/pages/statistics_page.dart +++ b/lib/pages/statistics_page.dart @@ -6,6 +6,8 @@ import '../widgets/statistics/monthly_overview.dart'; import '../widgets/statistics/daily_chart.dart'; import '../widgets/statistics/category_chart.dart'; import '../widgets/statistics/daily_report.dart'; +import '../widgets/statistics/monthly_chart.dart'; +import '../widgets/statistics/monthly_report.dart'; class StatisticsPage extends StatefulWidget { const StatisticsPage({Key? key}) : super(key: key); @@ -112,8 +114,37 @@ class _StatisticsPageState extends State { /// 构建年视图 Widget _buildYearView() { - return const Center( - child: Text('年度统计功能开发中...'), + if (_records.isEmpty) { + 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), + ], + ), ); } diff --git a/lib/widgets/statistics/category_chart.dart b/lib/widgets/statistics/category_chart.dart index 0bcabdd..9e9095c 100644 --- a/lib/widgets/statistics/category_chart.dart +++ b/lib/widgets/statistics/category_chart.dart @@ -23,6 +23,9 @@ class _CategoryChartState extends State { final categoryData = _getCategoryData(); if (categoryData.isEmpty) return const SizedBox(); + final isYearView = widget.records.any((r) => + r.createTime.month != widget.records.first.createTime.month); + return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( @@ -41,9 +44,9 @@ class _CategoryChartState extends State { children: [ Row( children: [ - const Text( - '分类统计', - style: TextStyle( + Text( + '${isYearView ? "年度" : ""}分类统计', + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), diff --git a/lib/widgets/statistics/monthly_chart.dart b/lib/widgets/statistics/monthly_chart.dart new file mode 100644 index 0000000..9a7bd7d --- /dev/null +++ b/lib/widgets/statistics/monthly_chart.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import '../../models/record.dart'; + +class MonthlyChart extends StatelessWidget { + final List 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 _getMonthlyData() { + final Map 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 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; // 12个月,11个间隔 + + // 绘制网格线 + 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; +} \ No newline at end of file diff --git a/lib/widgets/statistics/monthly_overview.dart b/lib/widgets/statistics/monthly_overview.dart index f94e75f..d5a2160 100644 --- a/lib/widgets/statistics/monthly_overview.dart +++ b/lib/widgets/statistics/monthly_overview.dart @@ -3,10 +3,12 @@ import '../../models/record.dart'; class MonthlyOverview extends StatelessWidget { final List records; + final bool isYearView; const MonthlyOverview({ Key? key, required this.records, + this.isYearView = false, }) : super(key: key); @override @@ -22,12 +24,10 @@ class MonthlyOverview extends StatelessWidget { final balance = totalIncome - totalExpense; // 计算日均支出 - final daysInMonth = DateTime( - records.first.createTime.year, - records.first.createTime.month + 1, - 0, - ).day; - final dailyAverage = totalExpense / daysInMonth; + final daysInPeriod = isYearView + ? (records.isNotEmpty ? _getDaysInYear(records.first.createTime.year) : 365) + : (records.isNotEmpty ? _getDaysInMonth(records.first.createTime) : 30); + final dailyAverage = totalExpense / daysInPeriod; return Container( padding: const EdgeInsets.all(20), @@ -48,14 +48,14 @@ class MonthlyOverview extends StatelessWidget { children: [ Expanded( child: _buildOverviewItem( - label: '支出', + label: '${isYearView ? "年" : "月"}支出', amount: totalExpense, textColor: Colors.red, ), ), Expanded( child: _buildOverviewItem( - label: '收入', + label: '${isYearView ? "年" : "月"}收入', amount: totalIncome, 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({ required String label, 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); + } } \ No newline at end of file diff --git a/lib/widgets/statistics/monthly_report.dart b/lib/widgets/statistics/monthly_report.dart new file mode 100644 index 0000000..c038eb5 --- /dev/null +++ b/lib/widgets/statistics/monthly_report.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import '../../models/record.dart'; + +class MonthlyReport extends StatelessWidget { + final List 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 _getMonthlyData() { + final Map 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, + }); +} \ No newline at end of file