283 lines
10 KiB
Dart
283 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
import 'package:intl/intl.dart';
|
|
import '../providers/expense_provider.dart';
|
|
import '../models/transaction_model.dart';
|
|
|
|
class StatsScreen extends StatefulWidget {
|
|
const StatsScreen({super.key});
|
|
|
|
@override
|
|
State<StatsScreen> createState() => _StatsScreenState();
|
|
}
|
|
|
|
class _StatsScreenState extends State<StatsScreen> with SingleTickerProviderStateMixin {
|
|
late TabController _tabController;
|
|
DateTime _selectedMonth = DateTime.now();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 3, vsync: this);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('数据统计'),
|
|
bottom: TabBar(
|
|
controller: _tabController,
|
|
tabs: const [
|
|
Tab(text: '概览'),
|
|
Tab(text: '趋势'),
|
|
Tab(text: '对比'),
|
|
],
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.calendar_month),
|
|
onPressed: _pickMonth,
|
|
),
|
|
],
|
|
),
|
|
body: Consumer<ExpenseProvider>(
|
|
builder: (context, provider, child) {
|
|
final transactions = provider.transactions.where((t) {
|
|
return t.date.year == _selectedMonth.year && t.date.month == _selectedMonth.month;
|
|
}).toList();
|
|
|
|
if (transactions.isEmpty) {
|
|
return Center(child: Text('${DateFormat('yyyy年MM月').format(_selectedMonth)} 无数据'));
|
|
}
|
|
|
|
return TabBarView(
|
|
controller: _tabController,
|
|
children: [
|
|
_buildPieChart(provider, transactions),
|
|
_buildLineChart(provider, transactions),
|
|
_buildBarChart(provider, transactions),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _pickMonth() async {
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: _selectedMonth,
|
|
firstDate: DateTime(2020),
|
|
lastDate: DateTime(2030),
|
|
helpText: '选择月份',
|
|
);
|
|
if (picked != null) {
|
|
setState(() {
|
|
_selectedMonth = picked;
|
|
});
|
|
}
|
|
}
|
|
|
|
Widget _buildPieChart(ExpenseProvider provider, List<TransactionModel> transactions) {
|
|
final expenseTransactions = transactions.where((t) => t.type == 'expense').toList();
|
|
if (expenseTransactions.isEmpty) return const Center(child: Text('无支出数据'));
|
|
|
|
final categoryTotals = <int, double>{};
|
|
for (var t in expenseTransactions) {
|
|
categoryTotals[t.categoryId] = (categoryTotals[t.categoryId] ?? 0) + t.amount;
|
|
}
|
|
|
|
final totalExpense = expenseTransactions.fold(0.0, (sum, t) => sum + t.amount);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
children: [
|
|
Text('总支出: ¥${totalExpense.toStringAsFixed(2)}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
|
const SizedBox(height: 32),
|
|
SizedBox(
|
|
height: 250,
|
|
child: PieChart(
|
|
PieChartData(
|
|
sections: categoryTotals.entries.map((entry) {
|
|
final category = provider.getCategoryById(entry.key);
|
|
final percentage = (entry.value / totalExpense) * 100;
|
|
|
|
return PieChartSectionData(
|
|
color: Color(category.color),
|
|
value: entry.value,
|
|
title: '${percentage.toStringAsFixed(1)}%',
|
|
radius: 50,
|
|
titleStyle: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
);
|
|
}).toList(),
|
|
sectionsSpace: 2,
|
|
centerSpaceRadius: 40,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
Expanded(
|
|
child: ListView.builder(
|
|
itemCount: categoryTotals.length,
|
|
itemBuilder: (context, index) {
|
|
final categoryId = categoryTotals.keys.elementAt(index);
|
|
final amount = categoryTotals[categoryId]!;
|
|
final category = provider.getCategoryById(categoryId);
|
|
|
|
return ListTile(
|
|
leading: CircleAvatar(
|
|
backgroundColor: Color(category.color),
|
|
child: Icon(_getIconData(category.icon), color: Colors.white),
|
|
),
|
|
title: Text(category.name),
|
|
trailing: Text('¥${amount.toStringAsFixed(2)}'),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLineChart(ExpenseProvider provider, List<TransactionModel> transactions) {
|
|
// Daily Expense Trend
|
|
final dailyTotals = <int, double>{};
|
|
final daysInMonth = DateUtils.getDaysInMonth(_selectedMonth.year, _selectedMonth.month);
|
|
|
|
// Initialize all days to 0
|
|
for (int i = 1; i <= daysInMonth; i++) {
|
|
dailyTotals[i] = 0.0;
|
|
}
|
|
|
|
for (var t in transactions.where((t) => t.type == 'expense')) {
|
|
dailyTotals[t.date.day] = (dailyTotals[t.date.day] ?? 0) + t.amount;
|
|
}
|
|
|
|
final spots = dailyTotals.entries
|
|
.map((e) => FlSpot(e.key.toDouble(), e.value))
|
|
.toList()
|
|
..sort((a, b) => a.x.compareTo(b.x));
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
children: [
|
|
const Text('每日支出趋势', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
|
const SizedBox(height: 32),
|
|
Expanded(
|
|
child: LineChart(
|
|
LineChartData(
|
|
gridData: const FlGridData(show: false),
|
|
titlesData: FlTitlesData(
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
interval: 5,
|
|
getTitlesWidget: (value, meta) {
|
|
return Text('${value.toInt()}日', style: const TextStyle(fontSize: 10));
|
|
},
|
|
),
|
|
),
|
|
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
),
|
|
borderData: FlBorderData(show: true, border: Border.all(color: Colors.grey.withValues(alpha: 0.2))),
|
|
lineBarsData: [
|
|
LineChartBarData(
|
|
spots: spots,
|
|
isCurved: true,
|
|
color: Colors.blue,
|
|
barWidth: 3,
|
|
dotData: const FlDotData(show: false),
|
|
belowBarData: BarAreaData(show: true, color: Colors.blue.withValues(alpha: 0.1)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBarChart(ExpenseProvider provider, List<TransactionModel> transactions) {
|
|
final income = transactions.where((t) => t.type == 'income').fold(0.0, (sum, t) => sum + t.amount);
|
|
final expense = transactions.where((t) => t.type == 'expense').fold(0.0, (sum, t) => sum + t.amount);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
children: [
|
|
const Text('收支对比', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
|
const SizedBox(height: 32),
|
|
Expanded(
|
|
child: BarChart(
|
|
BarChartData(
|
|
alignment: BarChartAlignment.spaceAround,
|
|
maxY: (income > expense ? income : expense) * 1.2,
|
|
barTouchData: BarTouchData(enabled: false),
|
|
titlesData: FlTitlesData(
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
getTitlesWidget: (value, meta) {
|
|
switch (value.toInt()) {
|
|
case 0: return const Text('收入', style: TextStyle(color: Colors.green));
|
|
case 1: return const Text('支出', style: TextStyle(color: Colors.red));
|
|
default: return const Text('');
|
|
}
|
|
},
|
|
),
|
|
),
|
|
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
),
|
|
gridData: const FlGridData(show: false),
|
|
borderData: FlBorderData(show: false),
|
|
barGroups: [
|
|
BarChartGroupData(
|
|
x: 0,
|
|
barRods: [BarChartRodData(toY: income, color: Colors.green, width: 20)],
|
|
),
|
|
BarChartGroupData(
|
|
x: 1,
|
|
barRods: [BarChartRodData(toY: expense, color: Colors.red, width: 20)],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
IconData _getIconData(String iconName) {
|
|
switch (iconName) {
|
|
case 'restaurant': return Icons.restaurant;
|
|
case 'directions_bus': return Icons.directions_bus;
|
|
case 'shopping_cart': return Icons.shopping_cart;
|
|
case 'movie': return Icons.movie;
|
|
case 'local_hospital': return Icons.local_hospital;
|
|
case 'attach_money': return Icons.attach_money;
|
|
case 'trending_up': return Icons.trending_up;
|
|
case 'breakfast_dining': return Icons.breakfast_dining;
|
|
case 'lunch_dining': return Icons.lunch_dining;
|
|
case 'dinner_dining': return Icons.dinner_dining;
|
|
case 'subway': return Icons.subway;
|
|
case 'local_taxi': return Icons.local_taxi;
|
|
default: return Icons.category;
|
|
}
|
|
}
|
|
}
|