diff --git a/lib/data/mock_data.dart b/lib/data/mock_data.dart deleted file mode 100644 index 7f4fa4c..0000000 --- a/lib/data/mock_data.dart +++ /dev/null @@ -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'] ?? '未知分类'; -} \ No newline at end of file diff --git a/lib/pages/statistics_page.dart b/lib/pages/statistics_page.dart index 76f62bb..2ad3629 100644 --- a/lib/pages/statistics_page.dart +++ b/lib/pages/statistics_page.dart @@ -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 createState() => _StatisticsPageState(); +} + +class _StatisticsPageState extends State { + final _dbService = DatabaseService(); + bool _isMonthView = true; // true为月视图,false为年视图 + DateTime _selectedDate = DateTime.now(); + List _records = []; + + @override + void initState() { + super.initState(); + _loadRecords(); + } + + /// 加载记录数据 + Future _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(), + ), + ], ), ); } -} \ No newline at end of file + + /// 构建月视图 + 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; +} \ No newline at end of file diff --git a/lib/widgets/statistics/category_chart.dart b/lib/widgets/statistics/category_chart.dart new file mode 100644 index 0000000..0bcabdd --- /dev/null +++ b/lib/widgets/statistics/category_chart.dart @@ -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 records; + + const CategoryChart({ + Key? key, + required this.records, + }) : super(key: key); + + @override + State createState() => _CategoryChartState(); +} + +class _CategoryChartState extends State { + 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 _getCategoryData() { + final Map 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 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 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; +} \ No newline at end of file diff --git a/lib/widgets/statistics/daily_chart.dart b/lib/widgets/statistics/daily_chart.dart new file mode 100644 index 0000000..bae7b06 --- /dev/null +++ b/lib/widgets/statistics/daily_chart.dart @@ -0,0 +1,227 @@ +import 'package:flutter/material.dart'; +import '../../models/record.dart'; + +class DailyChart extends StatelessWidget { + final List 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 _getDailyData() { + final Map 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; + 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; +} \ No newline at end of file diff --git a/lib/widgets/statistics/daily_report.dart b/lib/widgets/statistics/daily_report.dart new file mode 100644 index 0000000..6422e2b --- /dev/null +++ b/lib/widgets/statistics/daily_report.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import '../../models/record.dart'; +import '../../utils/date_formatter.dart'; + +class DailyReport extends StatelessWidget { + final List 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 _getDailyData() { + final Map 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, + }); +} \ No newline at end of file diff --git a/lib/widgets/statistics/monthly_overview.dart b/lib/widgets/statistics/monthly_overview.dart new file mode 100644 index 0000000..f94e75f --- /dev/null +++ b/lib/widgets/statistics/monthly_overview.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import '../../models/record.dart'; + +class MonthlyOverview extends StatelessWidget { + final List 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, + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/statistics/view_selector.dart b/lib/widgets/statistics/view_selector.dart new file mode 100644 index 0000000..c162be4 --- /dev/null +++ b/lib/widgets/statistics/view_selector.dart @@ -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 _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( + 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)); + } + } + } +} \ No newline at end of file