新增近7日支出 和月度收支
This commit is contained in:
parent
edef638500
commit
2a2857132b
58
README.md
58
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
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import './record_page.dart';
|
|||||||
import '../widgets/record_list.dart';
|
import '../widgets/record_list.dart';
|
||||||
import '../data/mock_data.dart'; // 暂时使用模拟数据
|
import '../data/mock_data.dart'; // 暂时使用模拟数据
|
||||||
import '../services/database_service.dart';
|
import '../services/database_service.dart';
|
||||||
|
import '../widgets/weekly_spending_chart.dart';
|
||||||
|
import '../widgets/monthly_summary_card.dart';
|
||||||
|
|
||||||
class HomePage extends StatefulWidget {
|
class HomePage extends StatefulWidget {
|
||||||
const HomePage({Key? key}) : super(key: key);
|
const HomePage({Key? key}) : super(key: key);
|
||||||
@ -23,10 +25,11 @@ class _HomePageState extends State<HomePage> {
|
|||||||
final _dbService = DatabaseService();
|
final _dbService = DatabaseService();
|
||||||
List<Record> records = [];
|
List<Record> records = [];
|
||||||
DateTime _selectedMonth = DateTime.now(); // 添加选中月份状态
|
DateTime _selectedMonth = DateTime.now(); // 添加选中月份状态
|
||||||
|
bool _isAmountVisible = true; // 添加可视状态
|
||||||
|
|
||||||
// 定义摇晃检测的常量
|
// 定义摇晃检测的常量
|
||||||
static const double _shakeThreshold = 8.0; // 单次摇晃的加速度阈值
|
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 int _sampleSize = 3; // 采样数量
|
||||||
static const Duration _shakeWindow = Duration(milliseconds: 800);
|
static const Duration _shakeWindow = Duration(milliseconds: 800);
|
||||||
static const Duration _cooldown = Duration(milliseconds: 50);
|
static const Duration _cooldown = Duration(milliseconds: 50);
|
||||||
@ -61,7 +64,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
_loadRecords();
|
_loadRecords();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 重置摇<EFBFBD><EFBFBD>检测状态
|
/// 重置摇晃检测状态
|
||||||
void _resetShakeDetection() {
|
void _resetShakeDetection() {
|
||||||
_lastShakeTime = null;
|
_lastShakeTime = null;
|
||||||
_recentXValues.clear();
|
_recentXValues.clear();
|
||||||
@ -234,20 +237,19 @@ class _HomePageState extends State<HomePage> {
|
|||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Row(
|
child: _buildMonthSelector(),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
MonthlySummaryCard(
|
||||||
|
records: records,
|
||||||
|
isVisible: _isAmountVisible,
|
||||||
|
onAddRecord: () => _navigateToRecordPage(),
|
||||||
|
onVisibilityChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_isAmountVisible = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
WeeklySpendingChart(records: records),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: RecordList(
|
child: RecordList(
|
||||||
records: records,
|
records: records,
|
||||||
@ -257,10 +259,6 @@ class _HomePageState extends State<HomePage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
|
||||||
onPressed: () => _navigateToRecordPage(),
|
|
||||||
child: const Icon(Icons.add),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
136
lib/widgets/monthly_summary_card.dart
Normal file
136
lib/widgets/monthly_summary_card.dart
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../models/record.dart';
|
||||||
|
|
||||||
|
class MonthlySummaryCard extends StatelessWidget {
|
||||||
|
final List<Record> 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
175
lib/widgets/weekly_spending_chart.dart
Normal file
175
lib/widgets/weekly_spending_chart.dart
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../models/record.dart';
|
||||||
|
import '../services/database_service.dart';
|
||||||
|
|
||||||
|
class WeeklySpendingChart extends StatelessWidget {
|
||||||
|
final List<Record> records;
|
||||||
|
late final DatabaseService _dbService;
|
||||||
|
|
||||||
|
WeeklySpendingChart({
|
||||||
|
Key? key,
|
||||||
|
required this.records,
|
||||||
|
}) : super(key: key) {
|
||||||
|
_dbService = DatabaseService();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取最近7天的数据
|
||||||
|
Future<List<DailySpending>> _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<DailySpending> 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<List<DailySpending>>(
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
24
pubspec.lock
24
pubspec.lock
@ -57,6 +57,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.8"
|
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:
|
device_info_plus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -128,6 +136,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
intl:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: intl
|
||||||
|
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.19.0"
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -184,6 +200,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
rational:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: rational
|
||||||
|
sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.3"
|
||||||
sensors_plus:
|
sensors_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user