Compare commits

...

10 Commits

Author SHA1 Message Date
ddshi
b479dbe519 页面改名字 2025-01-03 14:00:51 +08:00
ddshi
b665409d65 新增年度统计 2025-01-02 13:39:25 +08:00
ddshi
3fcc6c244c 新增月度统计 2025-01-02 13:28:37 +08:00
ddshi
e387a8db03 新增导航 2024-12-27 13:56:59 +08:00
ddshi
bd8eee3bdc 计算逻辑bug修复 记录分类icon拆分 2024-12-26 19:18:34 +08:00
ddshi
2a2857132b 新增近7日支出 和月度收支 2024-12-25 14:06:42 +08:00
ddshi
edef638500 添加月份选择 2024-12-24 19:20:01 +08:00
ddshi
114699f015 计算精度修复+长度限制 2024-12-24 13:07:00 +08:00
ddshi
95ef2676c0 计算显示修好 输入字符长度限制和计算精度需要修复 2024-12-20 14:03:22 +08:00
00e5316b52 晃动待优化 键盘小数点逻辑待优化 时间显示ok 2024-12-19 00:44:34 +08:00
29 changed files with 3167 additions and 480 deletions

View File

@ -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
## 项目结构

View File

@ -46,7 +46,7 @@ android {
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
minSdkVersion flutter.minSdkVersion minSdkVersion flutter.minSdkVersion
targetSdkVersion flutter.targetSdkVersion targetSdkVersion 33
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
} }

View File

@ -1,9 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<application <application
android:label="swing_account" android:label="摇晃记账"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:enableOnBackInvokedCallback="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=file:///D:/Gradle/.gradle/wrapper/dists/gradle-7.5-all.zip distributionUrl=file:///C:/Users/24811/.gradle/wrapper/dists/gradle-7.5-all.zip

View File

@ -1,54 +1,200 @@
import 'package:flutter/material.dart';
import '../models/record.dart'; import '../models/record.dart';
/// ///
final List<Category> expenseCategories = [ class CategoryConfig {
Category( static const Map<String, Map<String, dynamic>> expenseCategories = {
id: 'food', 'food': {
name: '餐饮', 'name': '餐饮',
icon: Icons.restaurant, 'iconKey': 'food',
type: RecordType.expense, 'sortOrder': 0,
), },
Category( 'shopping': {
id: 'shopping', 'name': '购物',
name: '购物', 'iconKey': 'shopping',
icon: Icons.shopping_bag, 'sortOrder': 1,
type: RecordType.expense, },
), 'transport': {
Category( 'name': '交通',
id: 'transport', 'iconKey': 'transport',
name: '交通', 'sortOrder': 2,
icon: Icons.directions_bus, },
type: RecordType.expense, 'entertainment': {
), 'name': '娱乐',
Category( 'iconKey': 'entertainment',
id: 'entertainment', 'sortOrder': 3,
name: '娱乐', },
icon: Icons.sports_esports, 'housing': {
type: RecordType.expense, 'name': '住房',
), 'iconKey': 'housing',
// ... 'sortOrder': 4,
]; },
'utilities': {
'name': '水电',
'iconKey': 'utilities',
'sortOrder': 5,
},
'medical': {
'name': '医疗',
'iconKey': 'medical',
'sortOrder': 6,
},
'education': {
'name': '教育',
'iconKey': 'education',
'sortOrder': 7,
},
'clothing': {
'name': '服饰',
'iconKey': 'clothing',
'sortOrder': 8,
},
'travel': {
'name': '旅行',
'iconKey': 'travel',
'sortOrder': 9,
},
'sports': {
'name': '运动',
'iconKey': 'sports',
'sortOrder': 10,
},
'beauty': {
'name': '美容',
'iconKey': 'beauty',
'sortOrder': 11,
},
'digital': {
'name': '数码',
'iconKey': 'digital',
'sortOrder': 12,
},
'pets': {
'name': '宠物',
'iconKey': 'pets',
'sortOrder': 13,
},
'gifts': {
'name': '礼物',
'iconKey': 'gifts',
'sortOrder': 14,
},
'books': {
'name': '书籍',
'iconKey': 'books',
'sortOrder': 15,
},
'insurance': {
'name': '保险',
'iconKey': 'insurance',
'sortOrder': 16,
},
'children': {
'name': '育儿',
'iconKey': 'children',
'sortOrder': 17,
},
'social': {
'name': '社交',
'iconKey': 'social',
'sortOrder': 18,
},
'car': {
'name': '汽车',
'iconKey': 'car',
'sortOrder': 19,
},
};
/// static const Map<String, Map<String, dynamic>> incomeCategories = {
final List<Category> incomeCategories = [ 'salary': {
Category( 'name': '工资',
id: 'salary', 'iconKey': 'salary',
name: '工资', 'sortOrder': 0,
icon: Icons.account_balance_wallet, },
type: RecordType.income, 'bonus': {
), 'name': '奖金',
Category( 'iconKey': 'bonus',
id: 'bonus', 'sortOrder': 1,
name: '奖金', },
icon: Icons.card_giftcard, 'investment': {
type: RecordType.income, 'name': '投资',
), 'iconKey': 'investment',
Category( 'sortOrder': 2,
id: 'investment', },
name: '投资', 'partTime': {
icon: Icons.trending_up, 'name': '兼职',
type: RecordType.income, 'iconKey': 'partTime',
), 'sortOrder': 3,
// ... },
]; 'dividend': {
'name': '分红',
'iconKey': 'dividend',
'sortOrder': 4,
},
'rental': {
'name': '租金',
'iconKey': 'rental',
'sortOrder': 5,
},
'refund': {
'name': '报销',
'iconKey': 'refund',
'sortOrder': 6,
},
'lottery': {
'name': '中奖',
'iconKey': 'lottery',
'sortOrder': 7,
},
'gifts_received': {
'name': '礼金',
'iconKey': 'gifts_received',
'sortOrder': 8,
},
'pension': {
'name': '养老金',
'iconKey': 'pension',
'sortOrder': 9,
},
'interest': {
'name': '利息',
'iconKey': 'interest',
'sortOrder': 10,
},
'business': {
'name': '经营',
'iconKey': 'business',
'sortOrder': 11,
},
'royalties': {
'name': '版权',
'iconKey': 'royalties',
'sortOrder': 12,
},
};
///
static List<Category> getCategoriesByType(RecordType type) {
final map = type == RecordType.expense ? expenseCategories : incomeCategories;
return map.entries.map((entry) => Category(
id: entry.key,
name: entry.value['name'],
iconKey: entry.value['iconKey'],
type: type,
sortOrder: entry.value['sortOrder'],
)).toList()
..sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
}
/// JSON格式便
static Map<String, dynamic> toJson() => {
'expense': expenseCategories,
'income': incomeCategories,
};
/// JSON格式转换回对象
static CategoryConfig fromJson(Map<String, dynamic> json) {
// TODO: JSON恢复配置的逻辑
return CategoryConfig();
}
}

49
lib/data/icons.dart Normal file
View File

@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
///
class IconMapping {
static const Map<String, IconData> icons = {
//
'food': Icons.restaurant,
'shopping': Icons.shopping_cart,
'transport': Icons.directions_bus,
'entertainment': Icons.sports_esports,
'medical': Icons.local_hospital,
'education': Icons.school,
'housing': Icons.home,
'utilities': Icons.power,
'clothing': Icons.checkroom,
'travel': Icons.flight,
'sports': Icons.sports_basketball,
'beauty': Icons.face,
'digital': Icons.devices,
'pets': Icons.pets,
'gifts': Icons.card_giftcard,
'books': Icons.book,
'insurance': Icons.security,
'children': Icons.child_care,
'social': Icons.people,
'car': Icons.directions_car,
//
'salary': Icons.account_balance_wallet,
'bonus': Icons.monetization_on,
'investment': Icons.trending_up,
'partTime': Icons.work,
'refund': Icons.replay,
'dividend': Icons.account_balance,
'rental': Icons.house,
'lottery': Icons.casino,
'gifts_received': Icons.redeem,
'pension': Icons.elderly,
'interest': Icons.savings,
'business': Icons.store,
'royalties': Icons.copyright,
'other': Icons.more_horiz,
};
///
static IconData getIcon(String iconKey) {
return icons[iconKey] ?? Icons.help_outline;
}
}

View File

@ -1,21 +0,0 @@
import '../models/record.dart';
final List<Record> 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: 5000.0,
createTime: DateTime.now().subtract(const Duration(days: 1)),
),
// ...
];

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import './pages/home_page.dart'; import './pages/record_page.dart';
import './pages/statistics_page.dart';
import './pages/profile_page.dart';
void main() { void main() {
runApp(const MyApp()); runApp(const MyApp());
@ -10,14 +12,63 @@ class MyApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// debug标签
return MaterialApp( return MaterialApp(
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
title: '记账本', title: '记账本',
theme: ThemeData( theme: ThemeData(
primarySwatch: Colors.blue, primaryColor: const Color(0xFF9FE2BF),
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF9FE2BF),
),
),
home: const MainPage(),
);
}
}
class MainPage extends StatefulWidget {
const MainPage({Key? key}) : super(key: key);
@override
State<MainPage> createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> {
int _currentIndex = 0;
//
final List<Widget> _pages = [
const RecordPage(),
const StatisticsPage(),
const ProfilePage(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: _pages[_currentIndex],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.account_balance_wallet),
label: '记账',
),
BottomNavigationBarItem(
icon: Icon(Icons.insert_chart),
label: '统计',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: '我的',
),
],
), ),
home: const HomePage(),
); );
} }
} }

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../data/icons.dart';
/// ///
enum RecordType { enum RecordType {
@ -10,17 +11,38 @@ enum RecordType {
class Category { class Category {
final String id; final String id;
final String name; final String name;
final IconData icon; final String iconKey; // 使key
final RecordType type; final RecordType type;
final String? parentId; // final int sortOrder; //
const Category({ const Category({
required this.id, required this.id,
required this.name, required this.name,
required this.icon, required this.iconKey,
required this.type, required this.type,
this.parentId, required this.sortOrder,
}); });
//
IconData get icon => IconMapping.getIcon(iconKey);
// JSON格式
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'iconKey': iconKey,
'type': type.index,
'sortOrder': sortOrder,
};
// JSON格式转换回对象
factory Category.fromJson(Map<String, dynamic> json) => Category(
id: json['id'],
name: json['name'],
iconKey: json['iconKey'],
type: RecordType.values[json['type']],
sortOrder: json['sortOrder'],
);
} }
/// ///

532
lib/pages/edit_page.dart Normal file
View File

@ -0,0 +1,532 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/record.dart';
import '../data/categories.dart';
import 'package:uuid/uuid.dart';
import 'package:decimal/decimal.dart';
class EditPage extends StatefulWidget {
final Record? record;
final Function(Record) onSave; //
const EditPage({
Key? key,
this.record,
required this.onSave,
}) : super(key: key);
@override
State<EditPage> createState() => _EditPageState();
}
class _EditPageState extends State<EditPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
String? _selectedCategoryId;
String _note = '';
double _amount = 0.0;
DateTime _selectedDate = DateTime.now();
final _uuid = const Uuid();
String _inputBuffer = '0.0'; //
String? _pendingOperation; //
String? _pendingValue; //
String? _inputValue; //
final d = (String s) => Decimal.parse(s);
@override
void initState() {
super.initState();
// TabController
_tabController = TabController(
length: 2,
vsync: this,
initialIndex: widget.record?.type == RecordType.income ? 1 : 0,
);
_tabController.addListener(_onTabChanged);
//
if (widget.record != null) {
//
_selectedCategoryId = widget.record!.categoryId;
_note = widget.record!.note ?? '';
_amount = widget.record!.amount;
_selectedDate = widget.record!.createTime;
//
_inputValue = _formatNumberForDisplay(widget.record!.amount);
} else {
//
_selectedCategoryId = _currentCategories.first.id;
}
}
///
void _onTabChanged() {
setState(() {
//
_selectedCategoryId = _currentCategories.first.id;
});
}
///
RecordType get _currentType =>
_tabController.index == 0 ? RecordType.expense : RecordType.income;
///
List<Category> get _currentCategories => CategoryConfig.getCategoriesByType(_currentType);
///
void _saveRecord() {
if (_selectedCategoryId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请选择分类')),
);
return;
}
if (_amount <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('输入金额请大于0')),
);
return;
}
final record = Record(
id: widget.record?.id ?? _uuid.v4(),
type: _currentType,
categoryId: _selectedCategoryId!,
note: _note.isEmpty ? null : _note,
amount: _amount,
createTime: _selectedDate,
);
widget.onSave(record);
Navigator.of(context).pop();
}
///
String get _displayAmount {
if (_pendingOperation != null ||
_pendingValue != null ||
_inputValue != null) {
_inputBuffer =
'${_pendingValue ?? ""} ${_pendingOperation ?? ""} ${_inputValue ?? ""}';
} else {
_inputBuffer = '0.0';
}
return _inputBuffer;
}
///
void _handleNumber(String digit) {
setState(() {
if (_inputValue == '0' || _inputValue == null) {
// 0
_inputValue = digit;
} else {
//
_inputValue = _inputValue! + digit;
}
});
}
///
void _handleDecimal() {
setState(() {
if (_inputValue == null || _inputValue == '0') {
_inputValue = '0.';
} else if (_inputValue!.contains('.')) {
HapticFeedback.heavyImpact();
} else {
_inputValue = '$_inputValue.';
}
});
}
///
void _handleDelete() {
setState(() {
if (_inputValue != null) {
if (_inputValue!.length > 1) {
_inputValue = _inputValue!.substring(0, _inputValue!.length - 1);
} else {
_inputValue = null;
}
} else if (_pendingOperation != null) {
_pendingOperation = null;
_inputValue = _pendingValue;
_pendingValue = null;
} else if (_pendingValue != null) {
if (_pendingValue!.length > 1) {
_pendingValue =
_pendingValue!.substring(0, _pendingValue!.length - 1);
} else {
_pendingValue = null;
}
} else {
_pendingValue = null;
}
});
}
///
void _handleOperator(String operator) {
//
if (_pendingOperation != null) {
//
final newOperator = operator;
_calculateResult();
_pendingValue = _inputValue;
_inputValue = null;
//
setState(() {
// 0
if (_inputBuffer == '0.0') {
return;
} else {
_pendingOperation = newOperator;
}
});
} else if (_inputValue != null ) {
//
setState(() {
_pendingOperation = operator;
_pendingValue = _inputValue!;
_inputValue = null;
});
} else {
HapticFeedback.heavyImpact();
}
}
///
void _calculateResult() {
double result = 0;
if(_pendingValue == null && _inputValue== null){
return;
}
else if (_pendingOperation != null &&
_pendingValue != null &&
_inputValue != null) {
switch (_pendingOperation) {
case '+':
result = (d(_pendingValue!) + d(_inputValue!)).toDouble();
if (result == 0) {
_resetToDefault();
return;
}
break;
case '-':
result = (d(_pendingValue!) - d(_inputValue!)).toDouble();
if (result <= 0) {
_resetToDefault();
return;
}
break;
default:
return;
}
} else {
result = double.parse(_inputValue ?? _pendingValue!);
}
setState(() {
//
_pendingValue = null;
_amount = result;
_pendingOperation = null;
_inputValue = _formatNumberForDisplay(result);
});
}
///
void _resetToDefault() {
setState(() {
_inputBuffer = '0.0';
_amount = 0;
_pendingOperation = null;
_pendingValue = null;
_inputValue = null;
});
}
///
String _formatNumberForDisplay(double number) {
//
if (number % 1 == 0) {
return number.toInt().toString();
}
// 0
String numStr = number.toString();
while (numStr.endsWith('0')) {
numStr = numStr.substring(0, numStr.length - 1);
}
if (numStr.endsWith('.')) {
numStr = numStr.substring(0, numStr.length - 1);
}
return numStr;
}
///
void _onKeyboardButtonPressed(String value) {
if (_inputBuffer.length >= 20 && value != '删除' && value != '保存'&& value != 'again'){
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('输入数字过大')),
);
return;
}
switch (value) {
case '删除':
_handleDelete();
break;
case '保存':
_calculateResult();
_saveRecord();
break;
case 'again':
// TODO: again功能
break;
case '+':
case '-':
_handleOperator(value);
break;
case '.':
_handleDecimal();
break;
default:
_handleNumber(value);
}
}
///
String _formatDate(DateTime date) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final dateToCheck = DateTime(date.year, date.month, date.day);
final difference = today.difference(dateToCheck).inDays;
//
switch (difference) {
case 0:
return '今天';
case 1:
return '昨天';
case 2:
return '前天';
default:
//
if (date.year == now.year) {
return '${date.month}/${date.day}';
}
//
return '${date.year}/${date.month}/${date.day}';
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: TabBar(
controller: _tabController,
tabs: const [
Tab(text: '支出'),
Tab(text: '收入'),
],
),
),
body: Column(
children: [
//
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
),
itemCount: _currentCategories.length,
itemBuilder: (context, index) {
final category = _currentCategories[index];
final isSelected = category.id == _selectedCategoryId;
return _buildCategoryItem(category, isSelected);
},
),
),
//
_buildBottomEditor(),
],
),
);
}
///
Widget _buildCategoryItem(Category category, bool isSelected) {
return InkWell(
onTap: () => setState(() => _selectedCategoryId = category.id),
child: Container(
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).primaryColor.withOpacity(0.1)
: null,
border: Border.all(
color: isSelected ? Theme.of(context).primaryColor : Colors.grey,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
category.icon,
color: isSelected ? Theme.of(context).primaryColor : Colors.grey,
),
const SizedBox(height: 4),
Text(
category.name,
style: TextStyle(
color:
isSelected ? Theme.of(context).primaryColor : Colors.grey,
),
),
],
),
),
);
}
///
Widget _buildBottomEditor() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
//
_buildNoteAndAmount(),
//
Row(
children: [
TextButton.icon(
onPressed: () {
// TODO:
},
icon: const Icon(Icons.account_balance_wallet),
label: const Text('默认账户'),
),
TextButton.icon(
onPressed: () async {
final date = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (date != null) {
setState(() => _selectedDate = date);
}
},
icon: const Icon(Icons.calendar_today),
label: Text(_formatDate(_selectedDate)),
),
TextButton.icon(
onPressed: () {
// TODO:
},
icon: const Icon(Icons.photo),
label: const Text('图片'),
),
],
),
//
_buildNumberKeyboard(),
],
),
);
}
///
Widget _buildNumberKeyboard() {
return GridView.count(
shrinkWrap: true,
crossAxisCount: 4,
childAspectRatio: 2,
children: [
_buildKeyboardButton('7'),
_buildKeyboardButton('8'),
_buildKeyboardButton('9'),
_buildKeyboardButton('删除', isFunction: true),
_buildKeyboardButton('4'),
_buildKeyboardButton('5'),
_buildKeyboardButton('6'),
_buildKeyboardButton('+'),
_buildKeyboardButton('1'),
_buildKeyboardButton('2'),
_buildKeyboardButton('3'),
_buildKeyboardButton('-'),
_buildKeyboardButton('again'),
_buildKeyboardButton('0'),
_buildKeyboardButton('.'),
_buildKeyboardButton('保存', isFunction: true),
],
);
}
///
Widget _buildKeyboardButton(String text, {bool isFunction = false}) {
return TextButton(
onPressed: () => _onKeyboardButtonPressed(text),
child: Text(
text,
style: TextStyle(
fontSize: 20,
color: isFunction ? Theme.of(context).primaryColor : Colors.black,
),
),
);
}
///
Widget _buildNoteAndAmount() {
return Row(
children: [
Expanded(
child: TextField(
controller: TextEditingController(text: _note), // 使
decoration: const InputDecoration(
hintText: '添加备注',
border: InputBorder.none,
),
onChanged: (value) => setState(() => _note = value),
),
),
Text(
_displayAmount,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
],
);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
}

View File

@ -3,10 +3,11 @@ import 'package:sensors_plus/sensors_plus.dart';
import 'package:vibration/vibration.dart'; import 'package:vibration/vibration.dart';
import 'dart:async'; import 'dart:async';
import '../models/record.dart'; import '../models/record.dart';
import './record_page.dart'; import 'edit_page.dart';
import '../widgets/record_list.dart'; import '../widgets/record_list.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);
@ -18,10 +19,42 @@ class HomePage extends StatefulWidget {
class _HomePageState extends State<HomePage> { class _HomePageState extends State<HomePage> {
StreamSubscription? _accelerometerSubscription; StreamSubscription? _accelerometerSubscription;
DateTime? _lastShakeTime; DateTime? _lastShakeTime;
int _shakeCount = 0; List<double> _recentXValues = []; // x轴加速度值
bool _isNavigating = false; bool _isNavigating = false;
final _dbService = DatabaseService(); final _dbService = DatabaseService();
List<Record> records = []; List<Record> records = [];
DateTime _selectedMonth = DateTime.now(); //
bool _isAmountVisible = true; //
//
static const double _shakeThreshold = 8.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);
///
bool _isValidShake() {
if (_recentXValues.length < _sampleSize) return false;
//
bool hasStrongShake = _recentXValues.any((x) => x.abs() > _shakeThreshold);
if (!hasStrongShake) return false;
//
bool hasDirectionChange = false;
for (int i = 1; i < _recentXValues.length; i++) {
//
if (_recentXValues[i].abs() > _directionThreshold &&
_recentXValues[i - 1].abs() > _directionThreshold &&
_recentXValues[i].sign != _recentXValues[i - 1].sign) {
hasDirectionChange = true;
break;
}
}
return hasDirectionChange;
}
@override @override
void initState() { void initState() {
@ -30,9 +63,28 @@ class _HomePageState extends State<HomePage> {
_loadRecords(); _loadRecords();
} }
///
void _resetShakeDetection() {
_lastShakeTime = null;
_recentXValues.clear();
_isNavigating = false;
//
_accelerometerSubscription?.cancel();
_initShakeDetection();
}
/// ///
Future<void> _loadRecords() async { Future<void> _loadRecords() async {
final loadedRecords = await _dbService.getAllRecords(); //
final startDate = DateTime(_selectedMonth.year, _selectedMonth.month, 1);
final endDate = DateTime(_selectedMonth.year, _selectedMonth.month + 1, 0);
final loadedRecords = await _dbService.getRecordsByDateRange(
startDate,
endDate,
);
setState(() { setState(() {
records = loadedRecords; records = loadedRecords;
records.sort((a, b) => b.createTime.compareTo(a.createTime)); records.sort((a, b) => b.createTime.compareTo(a.createTime));
@ -41,30 +93,48 @@ class _HomePageState extends State<HomePage> {
/// ///
void _initShakeDetection() { void _initShakeDetection() {
_accelerometerSubscription = accelerometerEvents.listen((event) { _accelerometerSubscription = accelerometerEvents.listen(
// x轴 (event) {
if (event.x.abs() > 25) { if (_isNavigating) return; //
final now = DateTime.now(); final now = DateTime.now();
if (_lastShakeTime == null) {
_lastShakeTime = now; // x轴值
_shakeCount = 1; _recentXValues.add(event.x);
} else { if (_recentXValues.length > _sampleSize) {
// _recentXValues.removeAt(0);
if (now.difference(_lastShakeTime!) < const Duration(milliseconds: 500)) {
_shakeCount++;
if (_shakeCount >= 2) {
_navigateToRecordPageWithVibration();
_shakeCount = 0;
_lastShakeTime = null;
}
} else {
//
_shakeCount = 1;
}
_lastShakeTime = now;
} }
}
}); //
if (_lastShakeTime != null &&
now.difference(_lastShakeTime!) < _cooldown) {
return;
}
//
if (_isValidShake()) {
if (_lastShakeTime == null) {
_lastShakeTime = now;
_recentXValues.clear();
} else {
final timeDiff = now.difference(_lastShakeTime!);
if (timeDiff < _shakeWindow) {
_navigateToRecordPageWithVibration();
_lastShakeTime = null;
_recentXValues.clear();
} else {
_lastShakeTime = now;
_recentXValues.clear();
}
}
}
},
onError: (error) {
debugPrint('Accelerometer error: $error');
_resetShakeDetection(); //
},
cancelOnError: false, //
);
} }
/// ///
@ -82,10 +152,11 @@ class _HomePageState extends State<HomePage> {
/// ///
void _navigateToRecordPage([Record? record]) { void _navigateToRecordPage([Record? record]) {
_isNavigating = true;
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => RecordPage( builder: (context) => RecordEditPage(
record: record, record: record,
onSave: (newRecord) async { onSave: (newRecord) async {
if (record != null) { if (record != null) {
@ -99,7 +170,8 @@ class _HomePageState extends State<HomePage> {
maintainState: false, maintainState: false,
), ),
).then((_) { ).then((_) {
_isNavigating = false; //
_resetShakeDetection();
}); });
} }
@ -109,20 +181,82 @@ class _HomePageState extends State<HomePage> {
await _loadRecords(); await _loadRecords();
} }
//
Widget _buildMonthSelector() {
return GestureDetector(
onTap: () => _showMonthPicker(),
child: Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8.0),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${_selectedMonth.year}${_selectedMonth.month}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const Icon(Icons.arrow_drop_down),
],
),
),
);
}
//
Future<void> _showMonthPicker() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedMonth,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
initialDatePickerMode: DatePickerMode.year,
);
if (picked != null) {
setState(() {
_selectedMonth = DateTime(picked.year, picked.month);
});
_loadRecords(); //
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('记账本'), title: const Text('记账本'),
), ),
body: RecordList( body: Column(
records: records, children: [
onRecordTap: _navigateToRecordPage, Padding(
onRecordDelete: _deleteRecord, padding: const EdgeInsets.all(16.0),
), child: _buildMonthSelector(),
floatingActionButton: FloatingActionButton( ),
onPressed: () => _navigateToRecordPage(), MonthlySummaryCard(
child: const Icon(Icons.add), records: records,
isVisible: _isAmountVisible,
onAddRecord: () => _navigateToRecordPage(),
onVisibilityChanged: (value) {
setState(() {
_isAmountVisible = value;
});
},
),
WeeklySpendingChart(records: records),
Expanded(
child: RecordList(
records: records,
onRecordTap: _navigateToRecordPage,
onRecordDelete: _deleteRecord,
),
),
],
), ),
); );
} }

View File

@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
class ProfilePage extends StatelessWidget {
const ProfilePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('我的'),
),
body: const Center(
child: Text('个人中心开发中...'),
),
);
}
}

View File

@ -1,322 +1,269 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sensors_plus/sensors_plus.dart';
import 'package:vibration/vibration.dart';
import 'dart:async';
import '../models/record.dart'; import '../models/record.dart';
import '../data/categories.dart'; import 'edit_page.dart';
import 'package:uuid/uuid.dart'; import '../widgets/record_list.dart';
import '../services/database_service.dart';
import '../widgets/weekly_spending_chart.dart';
import '../widgets/monthly_summary_card.dart';
class RecordPage extends StatefulWidget { class RecordPage extends StatefulWidget {
final Record? record; const RecordPage({Key? key}) : super(key: key);
final Function(Record) onSave; //
const RecordPage({
Key? key,
this.record,
required this.onSave,
}) : super(key: key);
@override @override
State<RecordPage> createState() => _RecordPageState(); State<RecordPage> createState() => _RecordPageState();
} }
class _RecordPageState extends State<RecordPage> with SingleTickerProviderStateMixin { class _RecordPageState extends State<RecordPage> {
late TabController _tabController; StreamSubscription? _accelerometerSubscription;
String? _selectedCategoryId; DateTime? _lastShakeTime;
String _note = ''; final List<double> _recentXValues = []; // x轴加速度值
double _amount = 0.0; bool _isNavigating = false;
DateTime _selectedDate = DateTime.now(); final _dbService = DatabaseService();
final _uuid = const Uuid(); List<Record> records = [];
DateTime _selectedMonth = DateTime.now(); //
bool _isAmountVisible = true; //
//
static const double _shakeThreshold = 8.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);
///
bool _isValidShake() {
if (_recentXValues.length < _sampleSize) return false;
//
bool hasStrongShake = _recentXValues.any((x) => x.abs() > _shakeThreshold);
if (!hasStrongShake) return false;
//
bool hasDirectionChange = false;
for (int i = 1; i < _recentXValues.length; i++) {
//
if (_recentXValues[i].abs() > _directionThreshold &&
_recentXValues[i - 1].abs() > _directionThreshold &&
_recentXValues[i].sign != _recentXValues[i - 1].sign) {
hasDirectionChange = true;
break;
}
}
return hasDirectionChange;
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// _initShakeDetection();
if (widget.record != null) { _loadRecords();
_selectedCategoryId = widget.record!.categoryId;
_note = widget.record!.note ?? '';
_amount = widget.record!.amount;
_selectedDate = widget.record!.createTime;
}
_tabController = TabController(
length: 2,
vsync: this,
initialIndex: widget.record?.type == RecordType.income ? 1 : 0,
);
_tabController.addListener(_onTabChanged);
} }
/// ///
void _onTabChanged() { void _resetShakeDetection() {
_lastShakeTime = null;
_recentXValues.clear();
_isNavigating = false;
//
_accelerometerSubscription?.cancel();
_initShakeDetection();
}
///
Future<void> _loadRecords() async {
//
final startDate = DateTime(_selectedMonth.year, _selectedMonth.month, 1);
final endDate = DateTime(_selectedMonth.year, _selectedMonth.month + 1, 0);
final loadedRecords = await _dbService.getRecordsByDateRange(
startDate,
endDate,
);
setState(() { setState(() {
_selectedCategoryId = null; // records = loadedRecords;
records.sort((a, b) => b.createTime.compareTo(a.createTime));
}); });
} }
/// ///
RecordType get _currentType => void _initShakeDetection() {
_tabController.index == 0 ? RecordType.expense : RecordType.income; _accelerometerSubscription = accelerometerEvents.listen(
(event) {
if (_isNavigating) return; //
/// final now = DateTime.now();
List<Category> get _currentCategories =>
_currentType == RecordType.expense ? expenseCategories : incomeCategories;
/// // x轴值
void _saveRecord() { _recentXValues.add(event.x);
if (_selectedCategoryId == null) { if (_recentXValues.length > _sampleSize) {
ScaffoldMessenger.of(context).showSnackBar( _recentXValues.removeAt(0);
const SnackBar(content: Text('请选择分类')), }
);
return; //
} if (_lastShakeTime != null &&
if (_amount <= 0) { now.difference(_lastShakeTime!) < _cooldown) {
ScaffoldMessenger.of(context).showSnackBar( return;
const SnackBar(content: Text('请输入金额')), }
);
return; //
if (_isValidShake()) {
if (_lastShakeTime == null) {
_lastShakeTime = now;
_recentXValues.clear();
} else {
final timeDiff = now.difference(_lastShakeTime!);
if (timeDiff < _shakeWindow) {
_navigateToRecordPageWithVibration();
_lastShakeTime = null;
_recentXValues.clear();
} else {
_lastShakeTime = now;
_recentXValues.clear();
}
}
}
},
onError: (error) {
debugPrint('Accelerometer error: $error');
_resetShakeDetection(); //
},
cancelOnError: false, //
);
}
///
void _navigateToRecordPageWithVibration() async {
if (_isNavigating) return;
_isNavigating = true;
//
if (await Vibration.hasVibrator() ?? false) {
Vibration.vibrate(duration: 200);
} }
final record = Record( _navigateToRecordPage();
id: widget.record?.id ?? _uuid.v4(), }
type: _currentType,
categoryId: _selectedCategoryId!, ///
note: _note.isEmpty ? null : _note, void _navigateToRecordPage([Record? record]) {
amount: _amount, _isNavigating = true;
createTime: _selectedDate, Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EditPage(
record: record,
onSave: (newRecord) async {
if (record != null) {
await _dbService.updateRecord(newRecord);
} else {
await _dbService.insertRecord(newRecord);
}
await _loadRecords();
},
),
maintainState: false,
),
).then((_) {
//
_resetShakeDetection();
});
}
///
void _deleteRecord(String recordId) async {
await _dbService.deleteRecord(recordId);
await _loadRecords();
}
//
Widget _buildMonthSelector() {
return GestureDetector(
onTap: () => _showMonthPicker(),
child: Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8.0),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${_selectedMonth.year}${_selectedMonth.month}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const Icon(Icons.arrow_drop_down),
],
),
),
);
}
//
Future<void> _showMonthPicker() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedMonth,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
initialDatePickerMode: DatePickerMode.year,
); );
widget.onSave(record); if (picked != null) {
Navigator.of(context).pop(); setState(() {
_selectedMonth = DateTime(picked.year, picked.month);
});
_loadRecords(); //
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: TabBar( title: const Text('记账本'),
controller: _tabController,
tabs: const [
Tab(text: '支出'),
Tab(text: '收入'),
],
),
), ),
body: Column( body: Column(
children: [ children: [
// Padding(
padding: const EdgeInsets.all(16.0),
child: _buildMonthSelector(),
),
MonthlySummaryCard(
records: records,
isVisible: _isAmountVisible,
onAddRecord: () => _navigateToRecordPage(),
onVisibilityChanged: (value) {
setState(() {
_isAmountVisible = value;
});
},
),
WeeklySpendingChart(records: records),
Expanded( Expanded(
child: GridView.builder( child: RecordList(
padding: const EdgeInsets.all(16), records: records,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( onRecordTap: _navigateToRecordPage,
crossAxisCount: 4, onRecordDelete: _deleteRecord,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
),
itemCount: _currentCategories.length,
itemBuilder: (context, index) {
final category = _currentCategories[index];
final isSelected = category.id == _selectedCategoryId;
return _buildCategoryItem(category, isSelected);
},
), ),
), ),
//
_buildBottomEditor(),
], ],
), ),
); );
} }
///
Widget _buildCategoryItem(Category category, bool isSelected) {
return InkWell(
onTap: () => setState(() => _selectedCategoryId = category.id),
child: Container(
decoration: BoxDecoration(
color: isSelected ? Theme.of(context).primaryColor.withOpacity(0.1) : null,
border: Border.all(
color: isSelected ? Theme.of(context).primaryColor : Colors.grey,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
category.icon,
color: isSelected ? Theme.of(context).primaryColor : Colors.grey,
),
const SizedBox(height: 4),
Text(
category.name,
style: TextStyle(
color: isSelected ? Theme.of(context).primaryColor : Colors.grey,
),
),
],
),
),
);
}
///
Widget _buildBottomEditor() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
//
Row(
children: [
Expanded(
child: TextField(
decoration: const InputDecoration(
hintText: '添加备注',
border: InputBorder.none,
),
onChanged: (value) => setState(() => _note = value),
),
),
Text(
'¥${_amount.toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
],
),
//
Row(
children: [
TextButton.icon(
onPressed: () {
// TODO:
},
icon: const Icon(Icons.account_balance_wallet),
label: const Text('默认账户'),
),
TextButton.icon(
onPressed: () async {
final date = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (date != null) {
setState(() => _selectedDate = date);
}
},
icon: const Icon(Icons.calendar_today),
label: Text(_selectedDate == DateTime.now()
? '今天'
: '${_selectedDate.month}${_selectedDate.day}'),
),
TextButton.icon(
onPressed: () {
// TODO:
},
icon: const Icon(Icons.photo),
label: const Text('图片'),
),
],
),
//
_buildNumberKeyboard(),
],
),
);
}
///
Widget _buildNumberKeyboard() {
return GridView.count(
shrinkWrap: true,
crossAxisCount: 4,
childAspectRatio: 2,
children: [
_buildKeyboardButton('7'),
_buildKeyboardButton('8'),
_buildKeyboardButton('9'),
_buildKeyboardButton('删除', isFunction: true),
_buildKeyboardButton('4'),
_buildKeyboardButton('5'),
_buildKeyboardButton('6'),
_buildKeyboardButton('+'),
_buildKeyboardButton('1'),
_buildKeyboardButton('2'),
_buildKeyboardButton('3'),
_buildKeyboardButton('-'),
_buildKeyboardButton('again'),
_buildKeyboardButton('0'),
_buildKeyboardButton('.'),
_buildKeyboardButton('保存', isFunction: true),
],
);
}
///
Widget _buildKeyboardButton(String text, {bool isFunction = false}) {
return TextButton(
onPressed: () => _onKeyboardButtonPressed(text),
child: Text(
text,
style: TextStyle(
fontSize: 20,
color: isFunction ? Theme.of(context).primaryColor : Colors.black,
),
),
);
}
///
void _onKeyboardButtonPressed(String value) {
switch (value) {
case '删除':
setState(() {
final amountStr = _amount.toStringAsFixed(2);
if (amountStr.length > 1) {
_amount = double.parse(amountStr.substring(0, amountStr.length - 1));
} else {
_amount = 0;
}
});
break;
case '保存':
_saveRecord();
break;
case 'again':
// TODO: again功能
break;
case '+':
case '-':
// TODO:
break;
case '.':
// TODO:
break;
default:
if (_amount == 0) {
setState(() => _amount = double.parse(value));
} else {
setState(() => _amount = double.parse('$_amount$value'));
}
}
}
@override @override
void dispose() { void dispose() {
_tabController.dispose(); _accelerometerSubscription?.cancel();
super.dispose(); super.dispose();
} }
} }

View File

@ -0,0 +1,153 @@
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';
import '../widgets/statistics/monthly_chart.dart';
import '../widgets/statistics/monthly_report.dart';
class StatisticsPage extends StatefulWidget {
const StatisticsPage({Key? key}) : super(key: key);
@override
State<StatisticsPage> createState() => _StatisticsPageState();
}
class _StatisticsPageState extends State<StatisticsPage> {
final _dbService = DatabaseService();
bool _isMonthView = true; // true为月视图false为年视图
DateTime _selectedDate = DateTime.now();
List<Record> _records = [];
@override
void initState() {
super.initState();
_loadRecords();
}
///
Future<void> _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: Column(
children: [
//
ViewSelector(
isMonthView: _isMonthView,
selectedDate: _selectedDate,
onViewChanged: (isMonth) {
setState(() {
_isMonthView = isMonth;
_loadRecords();
});
},
onDateChanged: (date) {
setState(() {
_selectedDate = date;
_loadRecords();
});
},
),
//
Expanded(
child: _isMonthView ? _buildMonthView() : _buildYearView(),
),
],
),
);
}
///
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() {
if (_records.isEmpty) {
return const Center(
child: Text('暂无数据'),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
//
MonthlyOverview(
records: _records,
isYearView: true,
),
const SizedBox(height: 16),
//
if (_hasValidRecords) ...[
MonthlyChart(records: _records),
const SizedBox(height: 16),
],
//
if (_hasValidRecords) ...[
CategoryChart(records: _records),
const SizedBox(height: 16),
],
//
if (_hasValidRecords)
MonthlyReport(records: _records),
],
),
);
}
///
bool get _hasValidRecords => _records.isNotEmpty;
}

View File

@ -91,4 +91,32 @@ class DatabaseService {
); );
}); });
} }
/// <EFBFBD><EFBFBD><EFBFBD>
Future<List<Record>> getRecordsByDateRange(DateTime startDate, DateTime endDate) async {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query(
'records',
where: 'createTime BETWEEN ? AND ?',
whereArgs: [
startDate.toIso8601String(),
endDate.toIso8601String(),
],
orderBy: 'createTime DESC',
);
return List.generate(maps.length, (i) {
final map = maps[i];
return Record(
id: map['id'],
type: RecordType.values[map['type']],
categoryId: map['categoryId'],
note: map['note'],
amount: map['amount'],
createTime: DateTime.parse(map['createTime']),
accountId: map['accountId'],
imageUrls: map['imageUrls']?.split(','),
);
});
}
} }

View File

@ -0,0 +1,49 @@
class DateFormatter {
///
static String formatDate(DateTime date) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final dateToCheck = DateTime(date.year, date.month, date.day);
final difference = today.difference(dateToCheck).inDays;
//
String dateStr;
if (date.year == now.year) {
// -
dateStr = '${_padZero(date.month)}-${_padZero(date.day)}';
} else {
// --
dateStr = '${date.year}-${_padZero(date.month)}-${_padZero(date.day)}';
}
//
String hint;
switch (difference) {
case 0:
hint = '今天';
break;
case 1:
hint = '昨天';
break;
case 2:
hint = '前天';
break;
default:
//
hint = _getWeekDay(date);
}
return '$dateStr $hint';
}
///
static String _padZero(int number) {
return number.toString().padLeft(2, '0');
}
///
static String _getWeekDay(DateTime date) {
final weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
return weekDays[date.weekday % 7];
}
}

View 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,
),
),
],
);
}
}

View File

@ -15,14 +15,14 @@ class RecordDetailDialog extends StatelessWidget {
}) : super(key: key); }) : super(key: key);
/// ///
Category _getCategory(String categoryId) { Category _getCategory(String categoryId, RecordType type) {
return [...expenseCategories, ...incomeCategories] return CategoryConfig.getCategoriesByType(type)
.firstWhere((c) => c.id == categoryId); .firstWhere((c) => c.id == categoryId);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final category = _getCategory(record.categoryId); final category = _getCategory(record.categoryId, record.type);
return AlertDialog( return AlertDialog(
title: Row( title: Row(

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../models/record.dart'; import '../models/record.dart';
import '../data/categories.dart'; import '../data/categories.dart';
import './record_detail_dialog.dart'; import './record_detail_dialog.dart';
import '../utils/date_formatter.dart';
class RecordList extends StatelessWidget { class RecordList extends StatelessWidget {
final List<Record> records; final List<Record> records;
@ -16,29 +17,28 @@ class RecordList extends StatelessWidget {
}) : super(key: key); }) : super(key: key);
/// ///
Category _getCategory(String categoryId) { Category _getCategory(String categoryId, RecordType type) {
return [...expenseCategories, ...incomeCategories] return CategoryConfig.getCategoriesByType(type)
.firstWhere((c) => c.id == categoryId); .firstWhere((c) => c.id == categoryId);
} }
/// ///
Map<String, List<Record>> _groupByDate() { Map<DateTime, List<Record>> _groupByDate() {
final groups = <String, List<Record>>{}; final groups = <DateTime, List<Record>>{};
for (final record in records) { for (final record in records) {
final date = _formatDate(record.createTime); final dateKey = DateTime(
if (!groups.containsKey(date)) { record.createTime.year,
groups[date] = []; record.createTime.month,
record.createTime.day,
);
if (!groups.containsKey(dateKey)) {
groups[dateKey] = [];
} }
groups[date]!.add(record); groups[dateKey]!.add(record);
} }
return groups; return groups;
} }
///
String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
/// ///
String _getDailySummary(List<Record> dayRecords) { String _getDailySummary(List<Record> dayRecords) {
double expense = 0; double expense = 0;
@ -60,13 +60,15 @@ class RecordList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final groups = _groupByDate(); final groups = _groupByDate();
final dates = groups.keys.toList()..sort((a, b) => b.compareTo(a)); final dates = groups.keys.toList()
..sort((a, b) => b.compareTo(a));
return ListView.builder( return ListView.builder(
itemCount: dates.length, itemCount: dates.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final date = dates[index]; final dateKey = dates[index];
final dayRecords = groups[date]!; final dayRecords = groups[dateKey]!;
final formattedDate = DateFormatter.formatDate(dateKey);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -78,19 +80,23 @@ class RecordList extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
date, formattedDate,
style: const TextStyle( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.w500,
fontSize: 16, ),
),
Text(
_getDailySummary(dayRecords),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
), ),
), ),
Text(_getDailySummary(dayRecords)),
], ],
), ),
), ),
// //
...dayRecords.map((record) { ...dayRecords.map((record) {
final category = _getCategory(record.categoryId); final category = _getCategory(record.categoryId, record.type);
return ListTile( return ListTile(
leading: Icon(category.icon), leading: Icon(category.icon),
title: Text(category.name), title: Text(category.name),

View File

@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import '../../models/record.dart';
import '../../data/categories.dart';
import 'dart:math' as math;
class CategoryChart extends StatefulWidget {
final List<Record> records;
const CategoryChart({
Key? key,
required this.records,
}) : super(key: key);
@override
State<CategoryChart> createState() => _CategoryChartState();
}
class _CategoryChartState extends State<CategoryChart> {
bool _showExpense = true; // true显示支出false显示收入
@override
Widget build(BuildContext context) {
final categoryData = _getCategoryData();
if (categoryData.isEmpty) return const SizedBox();
final isYearView = widget.records.any((r) =>
r.createTime.month != widget.records.first.createTime.month);
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: [
Text(
'${isYearView ? "年度" : ""}分类统计',
style: const 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<CategoryData> _getCategoryData() {
final Map<String, double> 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<CategoryData> 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<CategoryData> 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;
}

View File

@ -0,0 +1,227 @@
import 'package:flutter/material.dart';
import '../../models/record.dart';
class DailyChart extends StatelessWidget {
final List<Record> 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<DailyData> _getDailyData() {
final Map<DateTime, DailyData> 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> 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;
}

View File

@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import '../../models/record.dart';
import '../../utils/date_formatter.dart';
class DailyReport extends StatelessWidget {
final List<Record> 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<DailyData> _getDailyData() {
final Map<DateTime, DailyData> 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,
});
}

View File

@ -0,0 +1,244 @@
import 'package:flutter/material.dart';
import '../../models/record.dart';
class MonthlyChart extends StatelessWidget {
final List<Record> records;
const MonthlyChart({
Key? key,
required this.records,
}) : super(key: key);
@override
Widget build(BuildContext context) {
//
final monthlyData = _getMonthlyData();
if (monthlyData.isEmpty) return const SizedBox();
//
final maxAmount = monthlyData.fold(0.0, (max, data) {
final monthMax = data.income > data.expense ? data.income : data.expense;
return monthMax > max ? monthMax : 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(
monthlyData: monthlyData,
maxAmount: maxAmount,
),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLegend('支出', Colors.red),
const SizedBox(width: 24),
_buildLegend('收入', Colors.green),
],
),
],
),
);
}
List<MonthData> _getMonthlyData() {
final Map<int, MonthData> monthlyMap = {};
// 12
for (int month = 1; month <= 12; month++) {
monthlyMap[month] = MonthData(month: month);
}
//
for (final record in records) {
final month = record.createTime.month;
if (record.type == RecordType.expense) {
monthlyMap[month]!.expense += record.amount;
} else {
monthlyMap[month]!.income += record.amount;
}
}
return monthlyMap.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 MonthData {
final int month;
double expense;
double income;
MonthData({
required this.month,
this.expense = 0,
this.income = 0,
});
}
class ChartPainter extends CustomPainter {
final List<MonthData> monthlyData;
final double maxAmount;
ChartPainter({
required this.monthlyData,
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 monthWidth = width / 11; // 1211
// 线
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,
);
}
//
void drawAmountLabel(double x, double y, double amount) {
if (amount == 0) return;
final textPainter = TextPainter(
text: TextSpan(
text: amount >= 1000
? '${(amount / 1000).toStringAsFixed(1)}k'
: amount.toStringAsFixed(0),
style: TextStyle(
color: Colors.grey[600],
fontSize: 10,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(x - textPainter.width / 2, y - 15),
);
}
// 线
for (var i = 0; i < monthlyData.length; i++) {
final x = i * monthWidth;
final expenseY = height - (height * monthlyData[i].expense / maxAmount);
final incomeY = height - (height * monthlyData[i].income / maxAmount);
if (i == 0) {
expensePath.moveTo(x, expenseY);
incomePath.moveTo(x, incomeY);
} else {
expensePath.lineTo(x, expenseY);
incomePath.lineTo(x, incomeY);
}
//
if (monthlyData[i].expense > 0) {
canvas.drawCircle(
Offset(x, expenseY),
3,
Paint()..color = Colors.red,
);
drawAmountLabel(x, expenseY, monthlyData[i].expense);
}
if (monthlyData[i].income > 0) {
canvas.drawCircle(
Offset(x, incomeY),
3,
Paint()..color = Colors.green,
);
drawAmountLabel(x, incomeY, monthlyData[i].income);
}
//
final textPainter = TextPainter(
text: TextSpan(
text: '${monthlyData[i].month}',
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;
}

View File

@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import '../../models/record.dart';
class MonthlyOverview extends StatelessWidget {
final List<Record> records;
final bool isYearView;
const MonthlyOverview({
Key? key,
required this.records,
this.isYearView = false,
}) : 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 daysInPeriod = isYearView
? (records.isNotEmpty ? _getDaysInYear(records.first.createTime.year) : 365)
: (records.isNotEmpty ? _getDaysInMonth(records.first.createTime) : 30);
final dailyAverage = totalExpense / daysInPeriod;
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: '${isYearView ? "" : ""}支出',
amount: totalExpense,
textColor: Colors.red,
),
),
Expanded(
child: _buildOverviewItem(
label: '${isYearView ? "" : ""}收入',
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]!,
),
),
],
),
],
),
);
}
int _getDaysInMonth(DateTime date) {
return DateTime(date.year, date.month + 1, 0).day;
}
int _getDaysInYear(int year) {
return DateTime(year).isLeapYear ? 366 : 365;
}
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,
),
),
],
);
}
}
extension DateTimeExtension on DateTime {
bool get isLeapYear {
return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
}
}

View File

@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import '../../models/record.dart';
class MonthlyReport extends StatelessWidget {
final List<Record> records;
const MonthlyReport({
Key? key,
required this.records,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final monthlyData = _getMonthlyData();
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(1.5),
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)),
],
),
...monthlyData.map((data) => TableRow(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text('${data.month}'),
),
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<MonthData> _getMonthlyData() {
final Map<int, MonthData> monthlyMap = {};
// 12
for (int month = 1; month <= 12; month++) {
monthlyMap[month] = MonthData(month: month);
}
//
for (final record in records) {
final month = record.createTime.month;
if (record.type == RecordType.expense) {
monthlyMap[month]!.expense += record.amount;
} else {
monthlyMap[month]!.income += record.amount;
}
}
return monthlyMap.values.toList()
..sort((a, b) => a.month.compareTo(b.month));
}
}
class MonthData {
final int month;
double expense;
double income;
MonthData({
required this.month,
this.expense = 0,
this.income = 0,
});
}

View File

@ -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<void> _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<DateTime>(
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));
}
}
}
}

View 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,
});
}

View File

@ -6,7 +6,7 @@ packages:
description: description:
name: async name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.0" version: "2.11.0"
boolean_selector: boolean_selector:
@ -14,7 +14,7 @@ packages:
description: description:
name: boolean_selector name: boolean_selector
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "2.1.1"
characters: characters:
@ -22,7 +22,7 @@ packages:
description: description:
name: characters name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.3.0"
clock: clock:
@ -30,7 +30,7 @@ packages:
description: description:
name: clock name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
collection: collection:
@ -38,7 +38,7 @@ packages:
description: description:
name: collection name: collection
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.18.0"
crypto: crypto:
@ -46,7 +46,7 @@ packages:
description: description:
name: crypto name: crypto
sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.3"
cupertino_icons: cupertino_icons:
@ -54,31 +54,39 @@ packages:
description: description:
name: cupertino_icons name: cupertino_icons
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" 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:
name: device_info_plus name: device_info_plus
sha256: "093b02a284b4969bb641a6236bbb8e626e4035c6ec9e30c20b65d505c24b3080" sha256: "093b02a284b4969bb641a6236bbb8e626e4035c6ec9e30c20b65d505c24b3080"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.0" version: "10.0.0"
device_info_plus_platform_interface: device_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: device_info_plus_platform_interface name: device_info_plus_platform_interface
sha256: "282d3cf731045a2feb66abfe61bbc40870ae50a3ed10a4d3d217556c35c8c2ba" sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.0.2"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
name: fake_async name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.1" version: "1.3.1"
ffi: ffi:
@ -86,7 +94,7 @@ packages:
description: description:
name: ffi name: ffi
sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
file: file:
@ -94,7 +102,7 @@ packages:
description: description:
name: file name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.0.1"
fixnum: fixnum:
@ -102,7 +110,7 @@ packages:
description: description:
name: fixnum name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
flutter: flutter:
@ -115,7 +123,7 @@ packages:
description: description:
name: flutter_lints name: flutter_lints
sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.3" version: "2.0.3"
flutter_test: flutter_test:
@ -128,12 +136,20 @@ 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:
name: lints name: lints
sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "2.1.1"
logging: logging:
@ -141,7 +157,7 @@ packages:
description: description:
name: logging name: logging
sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.0" version: "1.2.0"
matcher: matcher:
@ -149,7 +165,7 @@ packages:
description: description:
name: matcher name: matcher
sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.16" version: "0.12.16"
material_color_utilities: material_color_utilities:
@ -157,7 +173,7 @@ packages:
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "0.5.0" version: "0.5.0"
meta: meta:
@ -165,7 +181,7 @@ packages:
description: description:
name: meta name: meta
sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.0" version: "1.10.0"
path: path:
@ -173,7 +189,7 @@ packages:
description: description:
name: path name: path
sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "1.8.3" version: "1.8.3"
plugin_platform_interface: plugin_platform_interface:
@ -181,15 +197,23 @@ packages:
description: description:
name: plugin_platform_interface name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" 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:
name: sensors_plus name: sensors_plus
sha256: "362c8f4f001838b90dd5206b898bbad941bc0142479eab9a3415f0f79e622908" sha256: "362c8f4f001838b90dd5206b898bbad941bc0142479eab9a3415f0f79e622908"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
sensors_plus_platform_interface: sensors_plus_platform_interface:
@ -197,7 +221,7 @@ packages:
description: description:
name: sensors_plus_platform_interface name: sensors_plus_platform_interface
sha256: bc472d6cfd622acb4f020e726433ee31788b038056691ba433fec80e448a094f sha256: bc472d6cfd622acb4f020e726433ee31788b038056691ba433fec80e448a094f
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.0" version: "1.2.0"
sensors_plus_web: sensors_plus_web:
@ -205,7 +229,7 @@ packages:
description: description:
name: sensors_plus_web name: sensors_plus_web
sha256: fca8d7d9ab6233b2a059952666415508e252420be1ef54f092d07884da53ec5e sha256: fca8d7d9ab6233b2a059952666415508e252420be1ef54f092d07884da53ec5e
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
sky_engine: sky_engine:
@ -218,7 +242,7 @@ packages:
description: description:
name: source_span name: source_span
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.0" version: "1.10.0"
sprintf: sprintf:
@ -226,7 +250,7 @@ packages:
description: description:
name: sprintf name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.0" version: "7.0.0"
sqflite: sqflite:
@ -234,7 +258,7 @@ packages:
description: description:
name: sqflite name: sqflite
sha256: a9016f495c927cb90557c909ff26a6d92d9bd54fc42ba92e19d4e79d61e798c6 sha256: a9016f495c927cb90557c909ff26a6d92d9bd54fc42ba92e19d4e79d61e798c6
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" version: "2.3.2"
sqflite_common: sqflite_common:
@ -242,7 +266,7 @@ packages:
description: description:
name: sqflite_common name: sqflite_common
sha256: "28d8c66baee4968519fb8bd6cdbedad982d6e53359091f0b74544a9f32ec72d5" sha256: "28d8c66baee4968519fb8bd6cdbedad982d6e53359091f0b74544a9f32ec72d5"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.3" version: "2.5.3"
stack_trace: stack_trace:
@ -250,7 +274,7 @@ packages:
description: description:
name: stack_trace name: stack_trace
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "1.11.1" version: "1.11.1"
stream_channel: stream_channel:
@ -258,7 +282,7 @@ packages:
description: description:
name: stream_channel name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
string_scanner: string_scanner:
@ -266,7 +290,7 @@ packages:
description: description:
name: string_scanner name: string_scanner
sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.0" version: "1.2.0"
synchronized: synchronized:
@ -274,7 +298,7 @@ packages:
description: description:
name: synchronized name: synchronized
sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.0+1" version: "3.1.0+1"
term_glyph: term_glyph:
@ -282,7 +306,7 @@ packages:
description: description:
name: term_glyph name: term_glyph
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.1" version: "1.2.1"
test_api: test_api:
@ -290,7 +314,7 @@ packages:
description: description:
name: test_api name: test_api
sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.1" version: "0.6.1"
typed_data: typed_data:
@ -298,7 +322,7 @@ packages:
description: description:
name: typed_data name: typed_data
sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.2" version: "1.3.2"
uuid: uuid:
@ -306,7 +330,7 @@ packages:
description: description:
name: uuid name: uuid
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.1" version: "4.5.1"
vector_math: vector_math:
@ -314,7 +338,7 @@ packages:
description: description:
name: vector_math name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.1.4"
vibration: vibration:
@ -322,7 +346,7 @@ packages:
description: description:
name: vibration name: vibration
sha256: "06588a845a4ebc73ab7ff7da555c2b3dbcd9676164b5856a38bf0b2287f1045d" sha256: "06588a845a4ebc73ab7ff7da555c2b3dbcd9676164b5856a38bf0b2287f1045d"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.0" version: "1.9.0"
vibration_platform_interface: vibration_platform_interface:
@ -330,7 +354,7 @@ packages:
description: description:
name: vibration_platform_interface name: vibration_platform_interface
sha256: f66b39aab2447038978c16f3d6f77228e49ef5717556e3da02313e044e4a7600 sha256: f66b39aab2447038978c16f3d6f77228e49ef5717556e3da02313e044e4a7600
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.2" version: "0.0.2"
web: web:
@ -338,7 +362,7 @@ packages:
description: description:
name: web name: web
sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.0" version: "0.3.0"
win32: win32:
@ -346,7 +370,7 @@ packages:
description: description:
name: win32 name: win32
sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "5.2.0" version: "5.2.0"
win32_registry: win32_registry:
@ -354,9 +378,9 @@ packages:
description: description:
name: win32_registry name: win32_registry
sha256: "41fd8a189940d8696b1b810efb9abcf60827b6cbfab90b0c43e8439e3a39d85a" sha256: "41fd8a189940d8696b1b810efb9abcf60827b6cbfab90b0c43e8439e3a39d85a"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
sdks: sdks:
dart: ">=3.2.5 <4.0.0" dart: ">=3.2.5 <4.0.0"
flutter: ">=3.3.0" flutter: ">=3.7.0"

View File

@ -35,6 +35,7 @@ dependencies:
uuid: ^4.3.3 # 添加uuid包用于生成唯一ID uuid: ^4.3.3 # 添加uuid包用于生成唯一ID
sqflite: ^2.3.2 # 添加 SQLite 支持 sqflite: ^2.3.2 # 添加 SQLite 支持
path: ^1.8.3 # 用于处理文件路径 path: ^1.8.3 # 用于处理文件路径
decimal: ^3.0.0 # 处理计算精度
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.