计算逻辑bug修复 记录分类icon拆分

This commit is contained in:
ddshi 2024-12-26 19:18:34 +08:00
parent 2a2857132b
commit bd8eee3bdc
8 changed files with 302 additions and 604 deletions

View File

@ -1,54 +1,200 @@
import 'package:flutter/material.dart';
import '../models/record.dart';
///
final List<Category> expenseCategories = [
Category(
id: 'food',
name: '餐饮',
icon: Icons.restaurant,
type: RecordType.expense,
),
Category(
id: 'shopping',
name: '购物',
icon: Icons.shopping_bag,
type: RecordType.expense,
),
Category(
id: 'transport',
name: '交通',
icon: Icons.directions_bus,
type: RecordType.expense,
),
Category(
id: 'entertainment',
name: '娱乐',
icon: Icons.sports_esports,
type: RecordType.expense,
),
// ...
];
///
class CategoryConfig {
static const Map<String, Map<String, dynamic>> expenseCategories = {
'food': {
'name': '餐饮',
'iconKey': 'food',
'sortOrder': 0,
},
'shopping': {
'name': '购物',
'iconKey': 'shopping',
'sortOrder': 1,
},
'transport': {
'name': '交通',
'iconKey': 'transport',
'sortOrder': 2,
},
'entertainment': {
'name': '娱乐',
'iconKey': 'entertainment',
'sortOrder': 3,
},
'housing': {
'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,
},
};
///
final List<Category> incomeCategories = [
Category(
id: 'salary',
name: '工资',
icon: Icons.account_balance_wallet,
type: RecordType.income,
),
Category(
id: 'bonus',
name: '奖金',
icon: Icons.card_giftcard,
type: RecordType.income,
),
Category(
id: 'investment',
name: '投资',
icon: Icons.trending_up,
type: RecordType.income,
),
// ...
];
static const Map<String, Map<String, dynamic>> incomeCategories = {
'salary': {
'name': '工资',
'iconKey': 'salary',
'sortOrder': 0,
},
'bonus': {
'name': '奖金',
'iconKey': 'bonus',
'sortOrder': 1,
},
'investment': {
'name': '投资',
'iconKey': 'investment',
'sortOrder': 2,
},
'partTime': {
'name': '兼职',
'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,6 +1,8 @@
import '../models/record.dart';
import 'categories.dart';
final List<Record> mockRecords = [
///
final mockRecords = [
Record(
id: '1',
type: RecordType.expense,
@ -14,8 +16,17 @@ final List<Record> mockRecords = [
type: RecordType.income,
categoryId: 'salary',
note: '工资',
amount: 5000.0,
amount: 8000.0,
createTime: DateTime.now().subtract(const Duration(days: 1)),
),
// ...
];
// ...
];
///
String getCategoryName(String categoryId, RecordType type) {
final categories = type == RecordType.expense
? CategoryConfig.expenseCategories
: CategoryConfig.incomeCategories;
return categories[categoryId]?['name'] ?? '未知分类';
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../data/icons.dart';
///
enum RecordType {
@ -10,17 +11,38 @@ enum RecordType {
class Category {
final String id;
final String name;
final IconData icon;
final String iconKey; // 使key
final RecordType type;
final String? parentId; //
final int sortOrder; //
const Category({
required this.id,
required this.name,
required this.icon,
required this.iconKey,
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'],
);
}
///

View File

@ -53,7 +53,7 @@ class _RecordPageState extends State<RecordPage>
_amount = widget.record!.amount;
_selectedDate = widget.record!.createTime;
//
_inputBuffer = _formatNumberForDisplay(widget.record!.amount);
_inputValue = _formatNumberForDisplay(widget.record!.amount);
} else {
//
_selectedCategoryId = _currentCategories.first.id;
@ -73,8 +73,7 @@ class _RecordPageState extends State<RecordPage>
_tabController.index == 0 ? RecordType.expense : RecordType.income;
///
List<Category> get _currentCategories =>
_currentType == RecordType.expense ? expenseCategories : incomeCategories;
List<Category> get _currentCategories => CategoryConfig.getCategoriesByType(_currentType);
///
void _saveRecord() {
@ -154,6 +153,8 @@ class _RecordPageState extends State<RecordPage>
}
} else if (_pendingOperation != null) {
_pendingOperation = null;
_inputValue = _pendingValue;
_pendingValue = null;
} else if (_pendingValue != null) {
if (_pendingValue!.length > 1) {
_pendingValue =
@ -174,6 +175,8 @@ class _RecordPageState extends State<RecordPage>
//
final newOperator = operator;
_calculateResult();
_pendingValue = _inputValue;
_inputValue = null;
//
setState(() {
@ -184,7 +187,7 @@ class _RecordPageState extends State<RecordPage>
_pendingOperation = newOperator;
}
});
} else if (_inputValue != null) {
} else if (_inputValue != null ) {
//
setState(() {
_pendingOperation = operator;
@ -231,10 +234,10 @@ class _RecordPageState extends State<RecordPage>
setState(() {
//
_pendingValue = _formatNumberForDisplay(result);
_pendingValue = null;
_amount = result;
_pendingOperation = null;
_inputValue = null;
_inputValue = _formatNumberForDisplay(result);
});
}

View File

@ -1,533 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/record.dart';
import '../data/categories.dart';
import 'package:uuid/uuid.dart';
class RecordPage extends StatefulWidget {
final Record? record;
final Function(Record) onSave; //
const RecordPage({
Key? key,
this.record,
required this.onSave,
}) : super(key: key);
@override
State<RecordPage> createState() => _RecordPageState();
}
class _RecordPageState extends State<RecordPage> 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; //
double? _pendingValue; //
bool _isNewInput = true; //
@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;
//
_inputBuffer = _formatNumberForDisplay(widget.record!.amount);
_isNewInput = false;
} 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 =>
_currentType == RecordType.expense ? expenseCategories : incomeCategories;
///
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('请输入金额')),
);
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) {
return '$_pendingValue $_pendingOperation ${_isNewInput ? "" : _inputBuffer}';
}
return _inputBuffer;
}
///
void _handleNumber(String digit) {
setState(() {
if (_isNewInput) {
//
_inputBuffer = digit;
_isNewInput = false;
} else if (_inputBuffer == '0' && !_inputBuffer.contains('.')) {
// 00
_inputBuffer = digit;
} else if (_inputBuffer == '0.0') {
//
_inputBuffer = digit;
_isNewInput = false;
} else {
//
_inputBuffer = _inputBuffer + digit;
}
_amount = double.parse(_inputBuffer);
});
}
///
void _handleDecimal() {
setState(() {
if (!_inputBuffer.contains('.')) {
// (0.0)00.
if (_inputBuffer == '0.0' || _inputBuffer == '0') {
_inputBuffer = '0.';
_isNewInput = false;
} else {
//
_inputBuffer = '$_inputBuffer.';
}
} else {
//
HapticFeedback.heavyImpact();
}
});
}
///
void _handleDelete() {
setState(() {
if (_pendingOperation != null && !_isNewInput) {
//
if (_inputBuffer.length > 1) {
_inputBuffer = _inputBuffer.substring(0, _inputBuffer.length - 1);
if (_inputBuffer.isEmpty || _inputBuffer == '0') {
_inputBuffer = '0.0';
_isNewInput = true;
}
} else {
_inputBuffer = '0.0';
_isNewInput = true;
}
} else if (_pendingOperation != null) {
//
_inputBuffer = _pendingValue.toString();
_pendingOperation = null;
_pendingValue = null;
_isNewInput = false;
} else {
//
if (_inputBuffer.length > 1) {
_inputBuffer = _inputBuffer.substring(0, _inputBuffer.length - 1);
if (_inputBuffer.isEmpty || _inputBuffer == '0') {
_inputBuffer = '0.0';
}
} else {
_inputBuffer = '0.0';
}
}
_amount = double.parse(_inputBuffer);
});
}
///
void _handleOperator(String operator) {
//
if (_pendingOperation != null && !_isNewInput) {
//
final newOperator = operator;
_calculateResult();
// 0
if (_inputBuffer == '0.0') {
return;
}
//
setState(() {
_pendingOperation = newOperator;
_pendingValue = double.parse(_inputBuffer);
_isNewInput = true;
});
} else {
//
setState(() {
_pendingOperation = operator;
_pendingValue = double.parse(_inputBuffer);
_isNewInput = true;
});
}
}
///
void _calculateResult() {
if (_pendingOperation != null && _pendingValue != null && !_isNewInput) {
final currentValue = double.parse(_inputBuffer);
double result;
switch (_pendingOperation) {
case '+':
result = _pendingValue! + currentValue;
if (result == 0) {
_resetToDefault();
return;
}
break;
case '-':
result = _pendingValue! - currentValue;
if (result <= 0) {
_resetToDefault();
return;
}
break;
default:
return;
}
setState(() {
//
_inputBuffer = _formatNumberForDisplay(result);
_amount = result;
_pendingOperation = null;
_pendingValue = null;
_isNewInput = true;
});
}
}
///
void _resetToDefault() {
setState(() {
_inputBuffer = '0.0';
_amount = 0;
_pendingOperation = null;
_pendingValue = null;
_isNewInput = true;
});
}
///
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) {
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

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

View File

@ -17,8 +17,8 @@ class RecordList extends StatelessWidget {
}) : super(key: key);
///
Category _getCategory(String categoryId) {
return [...expenseCategories, ...incomeCategories]
Category _getCategory(String categoryId, RecordType type) {
return CategoryConfig.getCategoriesByType(type)
.firstWhere((c) => c.id == categoryId);
}
@ -96,7 +96,7 @@ class RecordList extends StatelessWidget {
),
//
...dayRecords.map((record) {
final category = _getCategory(record.categoryId);
final category = _getCategory(record.categoryId, record.type);
return ListTile(
leading: Icon(category.icon),
title: Text(category.name),