新增月度统计
This commit is contained in:
parent
e387a8db03
commit
3fcc6c244c
@ -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'] ?? '未知分类';
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
222
lib/widgets/statistics/category_chart.dart
Normal file
222
lib/widgets/statistics/category_chart.dart
Normal 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;
|
||||
}
|
||||
227
lib/widgets/statistics/daily_chart.dart
Normal file
227
lib/widgets/statistics/daily_chart.dart
Normal 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;
|
||||
}
|
||||
134
lib/widgets/statistics/daily_report.dart
Normal file
134
lib/widgets/statistics/daily_report.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
123
lib/widgets/statistics/monthly_overview.dart
Normal file
123
lib/widgets/statistics/monthly_overview.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
103
lib/widgets/statistics/view_selector.dart
Normal file
103
lib/widgets/statistics/view_selector.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user