feat:"初始化仓库,并更新基础代码。"
This commit is contained in:
228
lib/screens/add_transaction_screen.dart
Normal file
228
lib/screens/add_transaction_screen.dart
Normal file
@@ -0,0 +1,228 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/transaction_model.dart';
|
||||
import '../providers/expense_provider.dart';
|
||||
|
||||
class AddTransactionScreen extends StatefulWidget {
|
||||
const AddTransactionScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AddTransactionScreen> createState() => _AddTransactionScreenState();
|
||||
}
|
||||
|
||||
class _AddTransactionScreenState extends State<AddTransactionScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _amountController = TextEditingController();
|
||||
final _noteController = TextEditingController();
|
||||
DateTime _selectedDate = DateTime.now();
|
||||
String _transactionType = 'expense';
|
||||
int? _selectedCategoryId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = Provider.of<ExpenseProvider>(context);
|
||||
|
||||
// Flatten categories with hierarchy logic for display
|
||||
final List<DropdownMenuItem<int>> categoryItems = [];
|
||||
final rootCategories = provider.rootCategories.where((c) => c.type == _transactionType).toList();
|
||||
|
||||
for (var root in rootCategories) {
|
||||
// Add Root
|
||||
categoryItems.add(
|
||||
DropdownMenuItem<int>(
|
||||
value: root.id,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(_getIconData(root.icon), color: Color(root.color), size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(root.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Add Children
|
||||
final children = provider.getSubCategories(root.id!);
|
||||
for (var child in children) {
|
||||
categoryItems.add(
|
||||
DropdownMenuItem<int>(
|
||||
value: child.id,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 24), // Indent
|
||||
Icon(_getIconData(child.icon), color: Color(child.color), size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text(child.name),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('记一笔'),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
children: [
|
||||
// Type Selection
|
||||
RadioGroup<String>(
|
||||
groupValue: _transactionType,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_transactionType = value!;
|
||||
_selectedCategoryId = null;
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: RadioListTile<String>(
|
||||
title: const Text('支出'),
|
||||
value: 'expense',
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RadioListTile<String>(
|
||||
title: const Text('收入'),
|
||||
value: 'income',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Amount
|
||||
TextFormField(
|
||||
controller: _amountController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '金额',
|
||||
prefixText: '¥ ',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入金额';
|
||||
}
|
||||
if (double.tryParse(value) == null) {
|
||||
return '请输入有效的数字';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Category
|
||||
DropdownButtonFormField<int>(
|
||||
key: ValueKey(_selectedCategoryId),
|
||||
initialValue: _selectedCategoryId,
|
||||
decoration: const InputDecoration(labelText: '分类'),
|
||||
items: categoryItems,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedCategoryId = value;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null) {
|
||||
return '请选择分类';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Date Picker
|
||||
Row(
|
||||
children: [
|
||||
Text('日期: ${DateFormat('yyyy-MM-dd').format(_selectedDate)}'),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final pickedDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
helpText: '选择日期',
|
||||
);
|
||||
if (pickedDate != null) {
|
||||
setState(() {
|
||||
_selectedDate = pickedDate;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: const Text('选择日期'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Note
|
||||
TextFormField(
|
||||
controller: _noteController,
|
||||
decoration: const InputDecoration(labelText: '备注'),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
ElevatedButton(
|
||||
onPressed: _saveTransaction,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
),
|
||||
child: const Text('保存', style: TextStyle(fontSize: 18)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _saveTransaction() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final newTransaction = TransactionModel(
|
||||
id: const Uuid().v4(),
|
||||
amount: double.parse(_amountController.text),
|
||||
type: _transactionType,
|
||||
categoryId: _selectedCategoryId!,
|
||||
date: _selectedDate,
|
||||
note: _noteController.text,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
Provider.of<ExpenseProvider>(context, listen: false).addTransaction(newTransaction);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
234
lib/screens/category_management_screen.dart
Normal file
234
lib/screens/category_management_screen.dart
Normal file
@@ -0,0 +1,234 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/category_model.dart';
|
||||
import '../providers/expense_provider.dart';
|
||||
|
||||
class CategoryManagementScreen extends StatelessWidget {
|
||||
const CategoryManagementScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('分类管理'),
|
||||
),
|
||||
body: Consumer<ExpenseProvider>(
|
||||
builder: (context, provider, child) {
|
||||
final rootCategories = provider.rootCategories;
|
||||
return ListView.builder(
|
||||
itemCount: rootCategories.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildCategoryTile(context, rootCategories[index], provider);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showAddEditDialog(context, null, null),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryTile(BuildContext context, Category category, ExpenseProvider provider) {
|
||||
final subCategories = provider.getSubCategories(category.id!);
|
||||
|
||||
if (subCategories.isEmpty) {
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Color(category.color),
|
||||
child: Icon(_getIconData(category.icon), color: Colors.white, size: 20),
|
||||
),
|
||||
title: Text(category.name),
|
||||
trailing: _buildActionButtons(context, category),
|
||||
);
|
||||
}
|
||||
|
||||
return ExpansionTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Color(category.color),
|
||||
child: Icon(_getIconData(category.icon), color: Colors.white, size: 20),
|
||||
),
|
||||
title: Text(category.name),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// This trailing overrides the expansion arrow, so maybe we put actions in leading or long press?
|
||||
// ExpansionTile doesn't easily support custom trailing + expansion arrow.
|
||||
// Let's use PopupMenuButton for actions instead.
|
||||
_buildActionButtons(context, category),
|
||||
const Icon(Icons.expand_more), // Visual cue, though functionality is tricky if trailing is used
|
||||
],
|
||||
),
|
||||
children: subCategories.map((sub) => _buildCategoryTile(context, sub, provider)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
// Adjusted to use PopupMenu for cleaner UI on tiles
|
||||
Widget _buildActionButtons(BuildContext context, Category category) {
|
||||
return PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
_showAddEditDialog(context, category, category.parentId);
|
||||
} else if (value == 'delete') {
|
||||
_confirmDelete(context, category);
|
||||
} else if (value == 'add_sub') {
|
||||
_showAddEditDialog(context, null, category.id);
|
||||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
|
||||
const PopupMenuItem<String>(
|
||||
value: 'edit',
|
||||
child: Text('编辑'),
|
||||
),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'add_sub',
|
||||
child: Text('添加子分类'),
|
||||
),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'delete',
|
||||
child: Text('删除', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context, Category category) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('确认删除'),
|
||||
content: Text('确定要删除分类 "${category.name}" 吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Provider.of<ExpenseProvider>(context, listen: false).deleteCategory(category.id!);
|
||||
Navigator.of(ctx).pop();
|
||||
},
|
||||
child: const Text('删除', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddEditDialog(BuildContext context, Category? category, int? parentId) {
|
||||
final isEditing = category != null;
|
||||
final nameController = TextEditingController(text: isEditing ? category.name : '');
|
||||
String selectedType = isEditing ? category.type : 'expense';
|
||||
String selectedIcon = isEditing ? category.icon : 'category';
|
||||
int selectedColor = isEditing ? category.color : 0xFF2196F3;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return AlertDialog(
|
||||
title: Text(isEditing ? '编辑分类' : '添加分类'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(labelText: '分类名称'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
key: ValueKey(selectedType),
|
||||
initialValue: selectedType,
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'expense', child: Text('支出')),
|
||||
DropdownMenuItem(value: 'income', child: Text('收入')),
|
||||
],
|
||||
onChanged: (val) {
|
||||
setState(() => selectedType = val!);
|
||||
},
|
||||
decoration: const InputDecoration(labelText: '类型'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Icon Picker (Simple version)
|
||||
DropdownButtonFormField<String>(
|
||||
key: ValueKey(selectedIcon),
|
||||
initialValue: selectedIcon,
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'restaurant', child: Icon(Icons.restaurant)),
|
||||
DropdownMenuItem(value: 'directions_bus', child: Icon(Icons.directions_bus)),
|
||||
DropdownMenuItem(value: 'shopping_cart', child: Icon(Icons.shopping_cart)),
|
||||
DropdownMenuItem(value: 'movie', child: Icon(Icons.movie)),
|
||||
DropdownMenuItem(value: 'local_hospital', child: Icon(Icons.local_hospital)),
|
||||
DropdownMenuItem(value: 'attach_money', child: Icon(Icons.attach_money)),
|
||||
DropdownMenuItem(value: 'trending_up', child: Icon(Icons.trending_up)),
|
||||
DropdownMenuItem(value: 'category', child: Icon(Icons.category)),
|
||||
DropdownMenuItem(value: 'breakfast_dining', child: Icon(Icons.breakfast_dining)),
|
||||
DropdownMenuItem(value: 'lunch_dining', child: Icon(Icons.lunch_dining)),
|
||||
DropdownMenuItem(value: 'dinner_dining', child: Icon(Icons.dinner_dining)),
|
||||
DropdownMenuItem(value: 'subway', child: Icon(Icons.subway)),
|
||||
DropdownMenuItem(value: 'local_taxi', child: Icon(Icons.local_taxi)),
|
||||
],
|
||||
onChanged: (val) {
|
||||
setState(() => selectedIcon = val!);
|
||||
},
|
||||
decoration: const InputDecoration(labelText: '图标'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (nameController.text.isEmpty) return;
|
||||
|
||||
final newCategory = Category(
|
||||
id: category?.id,
|
||||
name: nameController.text,
|
||||
type: selectedType,
|
||||
icon: selectedIcon,
|
||||
color: selectedColor, // Fixed color for now or add picker
|
||||
parentId: parentId,
|
||||
);
|
||||
|
||||
if (isEditing) {
|
||||
Provider.of<ExpenseProvider>(context, listen: false).updateCategory(newCategory);
|
||||
} else {
|
||||
Provider.of<ExpenseProvider>(context, listen: false).addCategory(newCategory);
|
||||
}
|
||||
Navigator.of(ctx).pop();
|
||||
},
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
243
lib/screens/home_screen.dart
Normal file
243
lib/screens/home_screen.dart
Normal file
@@ -0,0 +1,243 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../providers/expense_provider.dart';
|
||||
import 'add_transaction_screen.dart';
|
||||
import 'stats_screen.dart';
|
||||
import 'category_management_screen.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('我的记账本'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bar_chart),
|
||||
tooltip: '统计',
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const StatsScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
drawer: Drawer(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
const UserAccountsDrawerHeader(
|
||||
accountName: Text('用户'),
|
||||
accountEmail: Text('本地用户'),
|
||||
currentAccountPicture: CircleAvatar(
|
||||
backgroundColor: Colors.white,
|
||||
child: Icon(Icons.person, color: Colors.deepPurple),
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.deepPurple,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.category),
|
||||
title: const Text('分类管理'),
|
||||
onTap: () {
|
||||
Navigator.pop(context); // Close drawer
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const CategoryManagementScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings),
|
||||
title: const Text('设置'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('设置功能开发中...')));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Consumer<ExpenseProvider>(
|
||||
builder: (context, provider, child) {
|
||||
if (provider.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (provider.transactions.isEmpty) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.receipt_long, size: 64, color: Colors.grey),
|
||||
SizedBox(height: 16),
|
||||
Text('暂无账单,快去记一笔吧!', style: TextStyle(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildSummaryCard(provider),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: provider.transactions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final transaction = provider.transactions[index];
|
||||
final category = provider.getCategoryById(transaction.categoryId);
|
||||
|
||||
return Dismissible(
|
||||
key: Key(transaction.id),
|
||||
background: Container(
|
||||
color: Colors.red,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: const Icon(Icons.delete, color: Colors.white),
|
||||
),
|
||||
direction: DismissDirection.endToStart,
|
||||
confirmDismiss: (direction) async {
|
||||
return await showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('确认删除'),
|
||||
content: const Text('确定要删除这条记录吗?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')),
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('删除', style: TextStyle(color: Colors.red))),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
onDismissed: (direction) {
|
||||
provider.deleteTransaction(transaction.id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已删除')));
|
||||
},
|
||||
child: Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Color(category.color),
|
||||
child: Icon(_getIconData(category.icon), color: Colors.white),
|
||||
),
|
||||
title: Text(category.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(DateFormat('yyyy-MM-dd HH:mm').format(transaction.date)),
|
||||
if (transaction.note != null && transaction.note!.isNotEmpty)
|
||||
Text(transaction.note!, style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
trailing: Text(
|
||||
'${transaction.type == 'income' ? '+' : '-'}${transaction.amount.toStringAsFixed(2)}',
|
||||
style: TextStyle(
|
||||
color: transaction.type == 'income' ? Colors.green : Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const AddTransactionScreen()),
|
||||
);
|
||||
},
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCard(ExpenseProvider provider) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
gradient: LinearGradient(
|
||||
colors: [Colors.deepPurple.shade400, Colors.deepPurple.shade700],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text('本月结余', style: TextStyle(color: Colors.white70, fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'¥${(provider.totalIncome - provider.totalExpense).toStringAsFixed(2)}',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 32, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.arrow_downward, color: Colors.greenAccent, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
const Text('收入', style: TextStyle(color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
Text('¥${provider.totalIncome.toStringAsFixed(2)}', style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.arrow_upward, color: Colors.redAccent, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
const Text('支出', style: TextStyle(color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
Text('¥${provider.totalExpense.toStringAsFixed(2)}', style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
282
lib/screens/stats_screen.dart
Normal file
282
lib/screens/stats_screen.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user