feat:"初始化仓库,并更新基础代码。"

This commit is contained in:
2026-01-14 09:25:52 +08:00
commit 9a91c40a97
140 changed files with 6513 additions and 0 deletions

View File

@@ -0,0 +1,282 @@
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;
}
}
}