225 lines
6.2 KiB
Dart
225 lines
6.2 KiB
Dart
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();
|
||
|
||
final isYearView = widget.records.any((r) =>
|
||
r.createTime.month != widget.records.first.createTime.month);
|
||
|
||
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: [
|
||
Text(
|
||
'${isYearView ? "年度" : ""}分类统计',
|
||
style: const 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;
|
||
} |