新增月度统计

This commit is contained in:
ddshi 2025-01-02 13:28:37 +08:00
parent e387a8db03
commit 3fcc6c244c
7 changed files with 918 additions and 36 deletions

View File

@ -1,32 +0,0 @@
import '../models/record.dart';
import 'categories.dart';
///
final mockRecords = [
Record(
id: '1',
type: RecordType.expense,
categoryId: 'food',
note: '午餐',
amount: 25.0,
createTime: DateTime.now().subtract(const Duration(hours: 2)),
),
Record(
id: '2',
type: RecordType.income,
categoryId: 'salary',
note: '工资',
amount: 8000.0,
createTime: DateTime.now().subtract(const Duration(days: 1)),
),
// ...
];
///
String getCategoryName(String categoryId, RecordType type) {
final categories = type == RecordType.expense
? CategoryConfig.expenseCategories
: CategoryConfig.incomeCategories;
return categories[categoryId]?['name'] ?? '未知分类';
}

View File

@ -1,17 +1,122 @@
import 'package:flutter/material.dart';
import '../models/record.dart';
import '../services/database_service.dart';
import '../widgets/statistics/view_selector.dart';
import '../widgets/statistics/monthly_overview.dart';
import '../widgets/statistics/daily_chart.dart';
import '../widgets/statistics/category_chart.dart';
import '../widgets/statistics/daily_report.dart';
class StatisticsPage extends StatelessWidget {
class StatisticsPage extends StatefulWidget {
const StatisticsPage({Key? key}) : super(key: key);
@override
State<StatisticsPage> createState() => _StatisticsPageState();
}
class _StatisticsPageState extends State<StatisticsPage> {
final _dbService = DatabaseService();
bool _isMonthView = true; // true为月视图false为年视图
DateTime _selectedDate = DateTime.now();
List<Record> _records = [];
@override
void initState() {
super.initState();
_loadRecords();
}
///
Future<void> _loadRecords() async {
DateTime startDate;
DateTime endDate;
if (_isMonthView) {
startDate = DateTime(_selectedDate.year, _selectedDate.month, 1);
endDate = DateTime(_selectedDate.year, _selectedDate.month + 1, 0, 23, 59, 59);
} else {
startDate = DateTime(_selectedDate.year, 1, 1);
endDate = DateTime(_selectedDate.year, 12, 31, 23, 59, 59);
}
final records = await _dbService.getRecordsByDateRange(startDate, endDate);
setState(() => _records = records);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('统计'),
),
body: const Center(
child: Text('统计功能开发中...'),
body: Column(
children: [
//
ViewSelector(
isMonthView: _isMonthView,
selectedDate: _selectedDate,
onViewChanged: (isMonth) {
setState(() {
_isMonthView = isMonth;
_loadRecords();
});
},
onDateChanged: (date) {
setState(() {
_selectedDate = date;
_loadRecords();
});
},
),
//
Expanded(
child: _isMonthView ? _buildMonthView() : _buildYearView(),
),
],
),
);
}
///
Widget _buildMonthView() {
if (_records.isEmpty) {
return const Center(
child: Text('暂无数据'),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
//
MonthlyOverview(records: _records),
const SizedBox(height: 16),
//
if (_hasValidRecords) ...[
DailyChart(records: _records),
const SizedBox(height: 16),
],
//
if (_hasValidRecords) ...[
CategoryChart(records: _records),
const SizedBox(height: 16),
],
//
if (_hasValidRecords)
DailyReport(records: _records),
],
),
);
}
///
Widget _buildYearView() {
return const Center(
child: Text('年度统计功能开发中...'),
);
}
///
bool get _hasValidRecords => _records.isNotEmpty;
}

View File

@ -0,0 +1,222 @@
import 'package:flutter/material.dart';
import '../../models/record.dart';
import '../../data/categories.dart';
import 'dart:math' as math;
class CategoryChart extends StatefulWidget {
final List<Record> records;
const CategoryChart({
Key? key,
required this.records,
}) : super(key: key);
@override
State<CategoryChart> createState() => _CategoryChartState();
}
class _CategoryChartState extends State<CategoryChart> {
bool _showExpense = true; // true显示支出false显示收入
@override
Widget build(BuildContext context) {
final categoryData = _getCategoryData();
if (categoryData.isEmpty) return const SizedBox();
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: [
Row(
children: [
const Text(
'分类统计',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
ToggleButtons(
isSelected: [_showExpense, !_showExpense],
onPressed: (index) {
setState(() {
_showExpense = index == 0;
});
},
borderRadius: BorderRadius.circular(8),
selectedColor: Theme.of(context).primaryColor,
constraints: const BoxConstraints(
minWidth: 60,
minHeight: 32,
),
children: const [
Text('支出'),
Text('收入'),
],
),
],
),
const SizedBox(height: 20),
SizedBox(
height: 200,
child: Row(
children: [
Expanded(
flex: 3,
child: CustomPaint(
size: const Size(200, 200),
painter: PieChartPainter(
categories: categoryData,
total: categoryData.fold(
0.0,
(sum, item) => sum + item.amount,
),
),
),
),
Expanded(
flex: 2,
child: _buildLegend(categoryData),
),
],
),
),
],
),
);
}
List<CategoryData> _getCategoryData() {
final Map<String, double> categoryAmounts = {};
final targetType = _showExpense ? RecordType.expense : RecordType.income;
//
for (final record in widget.records) {
if (record.type == targetType) {
categoryAmounts[record.categoryId] =
(categoryAmounts[record.categoryId] ?? 0) + record.amount;
}
}
//
final categories = CategoryConfig.getCategoriesByType(targetType);
return categoryAmounts.entries.map((entry) {
final category = categories.firstWhere(
(c) => c.id == entry.key,
orElse: () => Category(
id: 'unknown',
name: '未知',
iconKey: 'other',
type: targetType,
sortOrder: 999,
),
);
return CategoryData(
category: category,
amount: entry.value,
);
}).toList()
..sort((a, b) => b.amount.compareTo(a.amount));
}
Widget _buildLegend(List<CategoryData> data) {
return ListView.builder(
shrinkWrap: true,
itemCount: data.length,
itemBuilder: (context, index) {
final item = data[index];
final total = data.fold(0.0, (sum, item) => sum + item.amount);
final percentage = (item.amount / total * 100).toStringAsFixed(1);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: Colors.primaries[index % Colors.primaries.length],
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
item.category.name,
style: const TextStyle(fontSize: 12),
),
),
Text(
'$percentage%',
style: const TextStyle(fontSize: 12),
),
],
),
);
},
);
}
}
class CategoryData {
final Category category;
final double amount;
CategoryData({
required this.category,
required this.amount,
});
}
class PieChartPainter extends CustomPainter {
final List<CategoryData> categories;
final double total;
PieChartPainter({
required this.categories,
required this.total,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = math.min(size.width, size.height) / 2;
var startAngle = -math.pi / 2;
for (var i = 0; i < categories.length; i++) {
final sweepAngle = 2 * math.pi * categories[i].amount / total;
final paint = Paint()
..color = Colors.primaries[i % Colors.primaries.length]
..style = PaintingStyle.fill;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle,
sweepAngle,
true,
paint,
);
startAngle += sweepAngle;
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

View File

@ -0,0 +1,227 @@
import 'package:flutter/material.dart';
import '../../models/record.dart';
class DailyChart extends StatelessWidget {
final List<Record> records;
const DailyChart({
Key? key,
required this.records,
}) : super(key: key);
@override
Widget build(BuildContext context) {
//
final dailyData = _getDailyData();
if (dailyData.isEmpty) return const SizedBox();
//
final maxAmount = dailyData.fold(0.0, (max, data) {
final dayMax = data.income > data.expense ? data.income : data.expense;
return dayMax > max ? dayMax : 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(
dailyData: dailyData,
maxAmount: maxAmount,
),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLegend('支出', Colors.red),
const SizedBox(width: 24),
_buildLegend('收入', Colors.green),
],
),
],
),
);
}
List<DailyData> _getDailyData() {
final Map<DateTime, DailyData> dailyMap = {};
//
final firstRecord = records.first;
final firstDay = DateTime(firstRecord.createTime.year, firstRecord.createTime.month, 1);
final lastDay = DateTime(firstRecord.createTime.year, firstRecord.createTime.month + 1, 0);
//
for (var day = firstDay; day.isBefore(lastDay.add(const Duration(days: 1))); day = day.add(const Duration(days: 1))) {
dailyMap[DateTime(day.year, day.month, day.day)] = DailyData(
date: day,
expense: 0,
income: 0,
);
}
//
for (final record in records) {
final date = DateTime(record.createTime.year, record.createTime.month, record.createTime.day);
final data = dailyMap[date]!;
if (record.type == RecordType.expense) {
data.expense += record.amount;
} else {
data.income += record.amount;
}
}
return dailyMap.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 DailyData {
final DateTime date;
double expense;
double income;
DailyData({
required this.date,
this.expense = 0,
this.income = 0,
});
}
class ChartPainter extends CustomPainter {
final List<DailyData> dailyData;
final double maxAmount;
ChartPainter({
required this.dailyData,
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 dayWidth = width / (dailyData.length - 1);
// 线
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,
);
}
// 线
for (var i = 0; i < dailyData.length; i++) {
final x = i * dayWidth;
final expenseY = height - (height * dailyData[i].expense / maxAmount);
final incomeY = height - (height * dailyData[i].income / maxAmount);
if (i == 0) {
expensePath.moveTo(x, expenseY);
incomePath.moveTo(x, incomeY);
} else {
expensePath.lineTo(x, expenseY);
incomePath.lineTo(x, incomeY);
}
//
canvas.drawCircle(
Offset(x, expenseY),
3,
Paint()..color = Colors.red,
);
canvas.drawCircle(
Offset(x, incomeY),
3,
Paint()..color = Colors.green,
);
//
if (i % 5 == 0 || i == dailyData.length - 1) {
final textPainter = TextPainter(
text: TextSpan(
text: '${dailyData[i].date.day}',
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

@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import '../../models/record.dart';
import '../../utils/date_formatter.dart';
class DailyReport extends StatelessWidget {
final List<Record> records;
const DailyReport({
Key? key,
required this.records,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final dailyData = _getDailyData();
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(2),
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)),
],
),
...dailyData.map((data) => TableRow(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(DateFormatter.formatDate(data.date)),
),
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<DailyData> _getDailyData() {
final Map<DateTime, DailyData> dailyMap = {};
for (final record in records) {
final date = DateTime(
record.createTime.year,
record.createTime.month,
record.createTime.day,
);
dailyMap.putIfAbsent(
date,
() => DailyData(date: date),
);
if (record.type == RecordType.expense) {
dailyMap[date]!.expense += record.amount;
} else {
dailyMap[date]!.income += record.amount;
}
}
return dailyMap.values.toList()
..sort((a, b) => b.date.compareTo(a.date));
}
}
class DailyData {
final DateTime date;
double expense;
double income;
DailyData({
required this.date,
this.expense = 0,
this.income = 0,
});
}

View File

@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import '../../models/record.dart';
class MonthlyOverview extends StatelessWidget {
final List<Record> records;
const MonthlyOverview({
Key? key,
required this.records,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final totalExpense = records
.where((r) => r.type == RecordType.expense)
.fold(0.0, (sum, r) => sum + r.amount);
final totalIncome = records
.where((r) => r.type == RecordType.income)
.fold(0.0, (sum, r) => sum + r.amount);
final balance = totalIncome - totalExpense;
//
final daysInMonth = DateTime(
records.first.createTime.year,
records.first.createTime.month + 1,
0,
).day;
final dailyAverage = totalExpense / daysInMonth;
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(
children: [
Row(
children: [
Expanded(
child: _buildOverviewItem(
label: '支出',
amount: totalExpense,
textColor: Colors.red,
),
),
Expanded(
child: _buildOverviewItem(
label: '收入',
amount: totalIncome,
textColor: Colors.green,
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildOverviewItem(
label: '结余',
amount: balance,
textColor: balance >= 0 ? Colors.green : Colors.red,
showSign: true,
),
),
Expanded(
child: _buildOverviewItem(
label: '日均支出',
amount: dailyAverage,
textColor: Colors.grey[800]!,
),
),
],
),
],
),
);
}
Widget _buildOverviewItem({
required String label,
required double amount,
required Color textColor,
bool showSign = false,
}) {
String amountText = '¥${amount.toStringAsFixed(2)}';
if (showSign && amount > 0) {
amountText = '+$amountText';
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
const SizedBox(height: 4),
Text(
amountText,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: textColor,
),
),
],
);
}
}

View File

@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
class ViewSelector extends StatelessWidget {
final bool isMonthView;
final DateTime selectedDate;
final Function(bool) onViewChanged;
final Function(DateTime) onDateChanged;
const ViewSelector({
Key? key,
required this.isMonthView,
required this.selectedDate,
required this.onViewChanged,
required this.onDateChanged,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
//
ToggleButtons(
isSelected: [isMonthView, !isMonthView],
onPressed: (index) => onViewChanged(index == 0),
borderRadius: BorderRadius.circular(8),
selectedColor: Theme.of(context).primaryColor,
children: const [
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text(''),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text(''),
),
],
),
const SizedBox(width: 16),
//
TextButton(
onPressed: () => _showDatePicker(context),
child: Text(
isMonthView
? '${selectedDate.year}${selectedDate.month}'
: '${selectedDate.year}',
),
),
],
),
);
}
Future<void> _showDatePicker(BuildContext context) async {
if (isMonthView) {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
initialDatePickerMode: DatePickerMode.year,
);
if (picked != null) {
onDateChanged(DateTime(picked.year, picked.month));
}
} else {
final DateTime? picked = await showDialog<DateTime>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('选择年份'),
content: SizedBox(
width: 300,
height: 300,
child: YearPicker(
firstDate: DateTime(2000),
lastDate: DateTime(2100),
selectedDate: selectedDate,
onChanged: (DateTime value) {
Navigator.pop(context, value);
},
),
),
);
},
);
if (picked != null) {
onDateChanged(DateTime(picked.year));
}
}
}
}