diff --git a/README.md b/README.md index ca0a985..64e54c6 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,54 @@ -# swing_account +# Swing Account (摇摇记账) -A new Flutter project. +一个简单易用的记账应用,支持摇一摇快速记账。 -## Getting Started +## 功能特点 -This project is a starting point for a Flutter application. +### 记账功能 +- 支持收入/支出记录 +- 摇一摇快速记账 +- 记录分类管理 +- 备注和图片附件 -A few resources to get you started if this is your first Flutter project: +### 数据统计 +- 月度收支统计 + * 月支出总额 + * 月收入总额 + * 月结余计算 + * 金额显示/隐藏切换 +- 最近7日支出统计 + * 柱状图显示 + * 自动计算比例 + * 支持金额缩写(k/w) +- 按日期分组的记录列表 + * 日期分组显示 + * 每日收支统计 + * 支持编辑和删除 -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) +### 界面交互 +- 月份筛选 +- 记录详情查看 +- 支持记录编辑/删除 +- 响应式布局设计 -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +## 技术特点 + +### 核心功能 +- 使用 SQLite 本地数据存储 +- 传感器集成实现摇一摇 +- 支持图片附件存储 +- 精确的数值计算 + +### 依赖包 +- sensors_plus: 传感器支持 +- vibration: 震动反馈 +- sqflite: 数据库 +- decimal: 精确计算 +- uuid: 唯一标识 +- path: 文件路径处理 + +## 开发环境 +- Flutter SDK: >=3.2.5 <4.0.0 +- Dart SDK: >=3.2.5 <4.0.0 + +## 项目结构 diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index aca2396..e4a28cd 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -7,6 +7,8 @@ import './record_page.dart'; import '../widgets/record_list.dart'; import '../data/mock_data.dart'; // 暂时使用模拟数据 import '../services/database_service.dart'; +import '../widgets/weekly_spending_chart.dart'; +import '../widgets/monthly_summary_card.dart'; class HomePage extends StatefulWidget { const HomePage({Key? key}) : super(key: key); @@ -23,10 +25,11 @@ class _HomePageState extends State { final _dbService = DatabaseService(); List records = []; DateTime _selectedMonth = DateTime.now(); // 添加选中月份状态 + bool _isAmountVisible = true; // 添加可视状态 // 定义摇晃检测的常量 static const double _shakeThreshold = 8.0; // 单次摇晃的加速度阈值 - static const double _directionThreshold = 2.0; // 方向改变的最小加速度 + static const double _directionThreshold = 2.0; // 方向改变的最小加速 static const int _sampleSize = 3; // 采样数量 static const Duration _shakeWindow = Duration(milliseconds: 800); static const Duration _cooldown = Duration(milliseconds: 50); @@ -61,7 +64,7 @@ class _HomePageState extends State { _loadRecords(); } - /// 重置摇��检测状态 + /// 重置摇晃检测状态 void _resetShakeDetection() { _lastShakeTime = null; _recentXValues.clear(); @@ -234,20 +237,19 @@ class _HomePageState extends State { children: [ Padding( padding: const EdgeInsets.all(16.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildMonthSelector(), - Text( - '总支出: ¥${records.fold(0.0, (sum, record) => sum + record.amount).toStringAsFixed(2)}', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ], - ), + child: _buildMonthSelector(), ), + MonthlySummaryCard( + records: records, + isVisible: _isAmountVisible, + onAddRecord: () => _navigateToRecordPage(), + onVisibilityChanged: (value) { + setState(() { + _isAmountVisible = value; + }); + }, + ), + WeeklySpendingChart(records: records), Expanded( child: RecordList( records: records, @@ -257,10 +259,6 @@ class _HomePageState extends State { ), ], ), - floatingActionButton: FloatingActionButton( - onPressed: () => _navigateToRecordPage(), - child: const Icon(Icons.add), - ), ); } @@ -269,4 +267,4 @@ class _HomePageState extends State { _accelerometerSubscription?.cancel(); super.dispose(); } -} \ No newline at end of file +} diff --git a/lib/widgets/monthly_summary_card.dart b/lib/widgets/monthly_summary_card.dart new file mode 100644 index 0000000..5495a05 --- /dev/null +++ b/lib/widgets/monthly_summary_card.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import '../models/record.dart'; + +class MonthlySummaryCard extends StatelessWidget { + final List records; + final bool isVisible; + final VoidCallback onAddRecord; + final Function(bool) onVisibilityChanged; + + const MonthlySummaryCard({ + Key? key, + required this.records, + required this.isVisible, + required this.onAddRecord, + required this.onVisibilityChanged, + }) : super(key: key); + + String _formatAmount(double amount, {bool showSign = false}) { + if (!isVisible) return '¥****'; + String prefix = showSign && amount > 0 ? '+' : ''; + return '$prefix¥${amount.toStringAsFixed(2)}'; + } + + @override + Widget build(BuildContext context) { + final monthlyExpense = records + .where((r) => r.type == RecordType.expense) + .fold(0.0, (sum, r) => sum + r.amount); + + final monthlyIncome = records + .where((r) => r.type == RecordType.income) + .fold(0.0, (sum, r) => sum + r.amount); + + final monthlyBalance = monthlyIncome - monthlyExpense; + + return Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '月支出', + style: TextStyle( + fontSize: 16, + color: Colors.black54, + ), + ), + IconButton( + icon: Icon( + isVisible ? Icons.visibility : Icons.visibility_off, + size: 20, + color: Colors.black54, + ), + onPressed: () => onVisibilityChanged(!isVisible), + ), + ], + ), + const SizedBox(height: 8), + Text( + _formatAmount(monthlyExpense), + style: const TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildSummaryItem('月收入', monthlyIncome), + _buildSummaryItem('月结余', monthlyBalance, showSign: true), + ], + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 44, + child: ElevatedButton( + onPressed: onAddRecord, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF9FE2BF), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(22), + ), + ), + child: const Text( + '记一笔', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildSummaryItem(String label, double amount, {bool showSign = false}) { + return Column( + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + color: Colors.black54, + ), + ), + const SizedBox(height: 4), + Text( + _formatAmount(amount, showSign: showSign), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/weekly_spending_chart.dart b/lib/widgets/weekly_spending_chart.dart new file mode 100644 index 0000000..9b706d3 --- /dev/null +++ b/lib/widgets/weekly_spending_chart.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import '../models/record.dart'; +import '../services/database_service.dart'; + +class WeeklySpendingChart extends StatelessWidget { + final List records; + late final DatabaseService _dbService; + + WeeklySpendingChart({ + Key? key, + required this.records, + }) : super(key: key) { + _dbService = DatabaseService(); + } + + /// 获取最近7天的数据 + Future> _getWeeklyData() async { + final now = DateTime.now(); + final startDate = DateTime(now.year, now.month, now.day - 6); + final endDate = DateTime(now.year, now.month, now.day, 23, 59, 59); + + // 直接从数据库获取最近7天的数据 + final weekRecords = await _dbService.getRecordsByDateRange(startDate, endDate); + + final List weeklyData = []; + for (int i = 6; i >= 0; i--) { + final date = DateTime(now.year, now.month, now.day - i); + final dayRecords = weekRecords.where((record) => + record.createTime.year == date.year && + record.createTime.month == date.month && + record.createTime.day == date.day && + record.type == RecordType.expense + ); + + final dailyTotal = dayRecords.fold(0.0, (sum, record) => sum + record.amount); + weeklyData.add(DailySpending( + date: date, + amount: dailyTotal, + )); + } + + return weeklyData; + } + + String _formatAmount(double amount) { + if (amount >= 10000) { + return '${(amount / 10000).toStringAsFixed(2)}w'; + } else if (amount >= 1000) { + return '${(amount / 1000).toStringAsFixed(2)}k'; + } + return amount.toStringAsFixed(2); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: _getWeeklyData(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + + final weeklyData = snapshot.data!; + final totalSpending = weeklyData.fold(0.0, (sum, day) => sum + day.amount); + final maxDailySpending = weeklyData.fold(0.0, (max, day) => day.amount > max ? day.amount : max); + + return Container( + padding: const EdgeInsets.all(16), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + spreadRadius: 1, + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '最近7日支出', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + Text( + '总计: ${totalSpending.toStringAsFixed(2)}', + style: const TextStyle( + fontSize: 14, + color: Colors.grey, + ), + ), + ], + ), + const SizedBox(height: 16), + SizedBox( + height: 140, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.end, + children: weeklyData.map((day) { + final barHeight = maxDailySpending > 0 + ? (day.amount / maxDailySpending) * 80 + : 0.0; + + return SizedBox( + width: 40, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + _formatAmount(day.amount), + style: const TextStyle( + fontSize: 11, + height: 1.2, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Container( + width: 24, + height: barHeight, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withOpacity(0.8), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(4), + ), + ), + ), + const SizedBox(height: 4), + Text( + _getWeekdayText(day.date.weekday), + style: const TextStyle( + fontSize: 12, + height: 1.2, + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ], + ), + ); + }, + ); + } + + String _getWeekdayText(int weekday) { + const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; + return weekdays[weekday - 1]; + } +} + +class DailySpending { + final DateTime date; + final double amount; + + DailySpending({ + required this.date, + required this.amount, + }); +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 8664d7a..bfab6c3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + decimal: + dependency: "direct main" + description: + name: decimal + sha256: "4140a688f9e443e2f4de3a1162387bf25e1ac6d51e24c9da263f245210f41440" + url: "https://pub.dev" + source: hosted + version: "3.0.2" device_info_plus: dependency: transitive description: @@ -128,6 +136,14 @@ packages: description: flutter source: sdk version: "0.0.0" + intl: + dependency: transitive + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" lints: dependency: transitive description: @@ -184,6 +200,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + rational: + dependency: transitive + description: + name: rational + sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336 + url: "https://pub.dev" + source: hosted + version: "2.2.3" sensors_plus: dependency: "direct main" description: