667 lines
22 KiB
Dart
667 lines
22 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
|
import 'package:miaoji_zhang/shared/api/business_api.dart';
|
|
import 'package:miaoji_zhang/shared/services/transaction_events.dart';
|
|
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
|
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
|
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
|
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
|
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
|
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
|
|
|
class AddPage extends StatefulWidget {
|
|
const AddPage({super.key});
|
|
|
|
@override
|
|
State<AddPage> createState() => _AddPageState();
|
|
}
|
|
|
|
class _AddPageState extends State<AddPage> {
|
|
final _noteCtrl = TextEditingController();
|
|
final _counterpartyCtrl = TextEditingController();
|
|
String _tab = 'expense';
|
|
String _transferDirection = 'out';
|
|
final Map<String, List<CategoryItem>> _categoriesByType = {
|
|
'expense': <CategoryItem>[],
|
|
'income': <CategoryItem>[],
|
|
};
|
|
final Map<String, CategoryItem?> _selectedByType = {
|
|
'expense': null,
|
|
'income': null,
|
|
'transfer_out': null,
|
|
'transfer_in': null,
|
|
};
|
|
String _amount = '0';
|
|
String? _paymentMethod;
|
|
DateTime _occurredAt = ShanghaiTime.now;
|
|
bool _saving = false;
|
|
bool _loadingCategories = true;
|
|
int _loadRevision = 0;
|
|
String get _categoryType => _tab == 'transfer'
|
|
? _transferDirection == 'in'
|
|
? 'income'
|
|
: 'expense'
|
|
: _tab;
|
|
|
|
String get _selectionKey =>
|
|
_tab == 'transfer' ? 'transfer_$_transferDirection' : _tab;
|
|
|
|
List<CategoryItem> get _categories =>
|
|
_categoriesByType[_categoryType] ?? const <CategoryItem>[];
|
|
|
|
CategoryItem? get _selected => _selectedByType[_selectionKey];
|
|
|
|
Color get _activeColor =>
|
|
_tab == 'income' || _tab == 'transfer' && _transferDirection == 'in'
|
|
? AppTheme.primary
|
|
: _tab == 'transfer'
|
|
? AppTheme.orange
|
|
: AppTheme.red;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
unawaited(_loadCategories());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_noteCtrl.dispose();
|
|
_counterpartyCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadCategories() async {
|
|
final revision = ++_loadRevision;
|
|
final expense = TxApi.categoriesLocal('expense');
|
|
final income = TxApi.categoriesLocal('income');
|
|
if (mounted) {
|
|
setState(() {
|
|
_applyCategories(expense, income);
|
|
_loadingCategories = false;
|
|
});
|
|
}
|
|
await Future.wait([
|
|
TxApi.categoriesRemote('expense').then<void>((values) {
|
|
if (!mounted || revision != _loadRevision) return;
|
|
setState(() {
|
|
_applyCategories(values, _categoriesByType['income'] ?? const []);
|
|
});
|
|
}, onError: (_) {}),
|
|
TxApi.categoriesRemote('income').then<void>((values) {
|
|
if (!mounted || revision != _loadRevision) return;
|
|
setState(() {
|
|
_applyCategories(_categoriesByType['expense'] ?? const [], values);
|
|
});
|
|
}, onError: (_) {}),
|
|
]);
|
|
}
|
|
|
|
void _applyCategories(List<CategoryItem> expense, List<CategoryItem> income) {
|
|
_categoriesByType['expense'] = expense;
|
|
_categoriesByType['income'] = income;
|
|
_selectedByType['expense'] = _preserveSelection(
|
|
_selectedByType['expense'],
|
|
expense,
|
|
);
|
|
_selectedByType['income'] = _preserveSelection(
|
|
_selectedByType['income'],
|
|
income,
|
|
);
|
|
_selectedByType['transfer_out'] = _preserveSelection(
|
|
_selectedByType['transfer_out'],
|
|
expense,
|
|
);
|
|
_selectedByType['transfer_in'] = _preserveSelection(
|
|
_selectedByType['transfer_in'],
|
|
income,
|
|
);
|
|
}
|
|
|
|
CategoryItem? _preserveSelection(
|
|
CategoryItem? selected,
|
|
List<CategoryItem> values,
|
|
) {
|
|
if (values.isEmpty) return null;
|
|
if (selected == null) return values.first;
|
|
return values.where((item) => item.id == selected.id).firstOrNull ??
|
|
values.first;
|
|
}
|
|
|
|
void _switchTab(String tab) {
|
|
if (_tab == tab) return;
|
|
setState(() => _tab = tab);
|
|
}
|
|
|
|
void _switchTransferDirection(String direction) {
|
|
if (_transferDirection == direction) return;
|
|
setState(() => _transferDirection = direction);
|
|
}
|
|
|
|
void _pressKey(String key) {
|
|
setState(() {
|
|
if (key == 'delete') {
|
|
_amount = _amount.length > 1
|
|
? _amount.substring(0, _amount.length - 1)
|
|
: '0';
|
|
return;
|
|
}
|
|
if (key == '.') {
|
|
if (!_amount.contains('.')) _amount += '.';
|
|
return;
|
|
}
|
|
if (_amount.contains('.') && _amount.split('.')[1].length >= 2) return;
|
|
_amount = _amount == '0' ? key : '$_amount$key';
|
|
if (_amount.length > 9) _amount = _amount.substring(0, 9);
|
|
});
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
final amount = double.tryParse(_amount) ?? 0;
|
|
if (amount <= 0) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('请输入金额')));
|
|
return;
|
|
}
|
|
if (_selected == null) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('请选择分类')));
|
|
return;
|
|
}
|
|
|
|
setState(() => _saving = true);
|
|
try {
|
|
await TxApi.create(
|
|
categoryId: _selected!.id,
|
|
type: _tab,
|
|
transferDirection: _tab == 'transfer' ? _transferDirection : null,
|
|
counterparty:
|
|
_tab == 'transfer' && _counterpartyCtrl.text.trim().isNotEmpty
|
|
? _counterpartyCtrl.text.trim()
|
|
: null,
|
|
amount: amount,
|
|
note: _noteCtrl.text.trim().isEmpty ? null : _noteCtrl.text.trim(),
|
|
paymentMethod: _paymentMethod,
|
|
occurredAt: _occurredAt,
|
|
);
|
|
TransactionEvents.notifyChanged();
|
|
if (mounted) context.pop(amount);
|
|
} catch (error) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _editNote() async {
|
|
final value = await showJzTextInputSheet(
|
|
context,
|
|
title: '备注',
|
|
label: '备注内容',
|
|
initialValue: _noteCtrl.text,
|
|
maxLength: 40,
|
|
);
|
|
if (value != null) setState(() => _noteCtrl.text = value);
|
|
}
|
|
|
|
Future<void> _editCounterparty() async {
|
|
final value = await showJzTextInputSheet(
|
|
context,
|
|
title: '转账对方',
|
|
label: '姓名或备注名',
|
|
initialValue: _counterpartyCtrl.text,
|
|
maxLength: 40,
|
|
);
|
|
if (value != null) setState(() => _counterpartyCtrl.text = value);
|
|
}
|
|
|
|
Future<void> _pickOccurredAt() async {
|
|
final value = await showJzDateTimeSheet(
|
|
context,
|
|
initial: _occurredAt,
|
|
firstDate: DateTime(2000),
|
|
lastDate: ShanghaiTime.now.add(const Duration(days: 1)),
|
|
title: '选择发生时间',
|
|
);
|
|
if (value != null && mounted) setState(() => _occurredAt = value);
|
|
}
|
|
|
|
Future<void> _pickPaymentMethod() async {
|
|
const methods = ['微信支付', '支付宝', '银行卡', '现金', '其他'];
|
|
final options = [
|
|
...methods.map((method) => JzOption(value: method, label: method)),
|
|
if (_paymentMethod != null) const JzOption(value: '', label: '清除支付方式'),
|
|
];
|
|
final value = await showJzOptionSheet<String>(
|
|
context,
|
|
title: '支付方式',
|
|
options: options,
|
|
selected: _paymentMethod,
|
|
);
|
|
if (value != null) {
|
|
setState(() => _paymentMethod = value.isEmpty ? null : value);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text('记一笔'),
|
|
leading: IconButton(
|
|
icon: AppIcons.icon(
|
|
AppIcons.close,
|
|
size: 20,
|
|
color: context.jz.text2,
|
|
),
|
|
onPressed: () => context.pop(),
|
|
),
|
|
actions: [BackendStatusIcon(onRetry: _loadCategories)],
|
|
),
|
|
body: SafeArea(
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final compact = constraints.maxHeight < 650;
|
|
return Column(
|
|
children: [
|
|
_buildTypeSelector(),
|
|
if (_tab == 'transfer') ...[
|
|
const SizedBox(height: 6),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 72),
|
|
child: JzSegmentedControl<String>(
|
|
value: _transferDirection,
|
|
options: const [
|
|
JzOption(value: 'out', label: '转出'),
|
|
JzOption(value: 'in', label: '转入'),
|
|
],
|
|
onChanged: _switchTransferDirection,
|
|
),
|
|
),
|
|
],
|
|
SizedBox(height: 4),
|
|
Expanded(
|
|
child: AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 260),
|
|
switchInCurve: Curves.easeOutCubic,
|
|
switchOutCurve: Curves.easeInCubic,
|
|
transitionBuilder: (child, animation) {
|
|
final offset = _tab == 'expense'
|
|
? const Offset(-0.04, 0)
|
|
: _tab == 'income'
|
|
? const Offset(0.04, 0)
|
|
: Offset.zero;
|
|
return FadeTransition(
|
|
opacity: animation,
|
|
child: SlideTransition(
|
|
position: Tween<Offset>(
|
|
begin: offset,
|
|
end: Offset.zero,
|
|
).animate(animation),
|
|
child: child,
|
|
),
|
|
);
|
|
},
|
|
child: _loadingCategories
|
|
? Center(
|
|
key: ValueKey('loading'),
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: AppTheme.primary,
|
|
),
|
|
)
|
|
: _buildCategoryGrid(key: ValueKey(_tab)),
|
|
),
|
|
),
|
|
_buildAmountKeyboard(compact),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTypeSelector() {
|
|
return Container(
|
|
width: 300,
|
|
height: 38,
|
|
margin: const EdgeInsets.only(top: 4),
|
|
padding: const EdgeInsets.all(3),
|
|
decoration: BoxDecoration(
|
|
color: context.jz.line,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) => Stack(
|
|
children: [
|
|
AnimatedAlign(
|
|
duration: const Duration(milliseconds: 260),
|
|
curve: Curves.easeOutCubic,
|
|
alignment: Alignment(
|
|
_tab == 'expense'
|
|
? -1
|
|
: _tab == 'income'
|
|
? 1
|
|
: 0,
|
|
0,
|
|
),
|
|
child: Container(
|
|
width: constraints.maxWidth / 3,
|
|
height: constraints.maxHeight,
|
|
decoration: BoxDecoration(
|
|
color: _activeColor,
|
|
borderRadius: BorderRadius.circular(7),
|
|
),
|
|
),
|
|
),
|
|
Row(
|
|
children: [
|
|
_segment('支出', 'expense'),
|
|
_segment('转账', 'transfer'),
|
|
_segment('收入', 'income'),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _segment(String label, String value) {
|
|
final selected = _tab == value;
|
|
return Expanded(
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(7),
|
|
onTap: () => _switchTab(value),
|
|
child: Center(
|
|
child: AnimatedDefaultTextStyle(
|
|
duration: const Duration(milliseconds: 260),
|
|
curve: Curves.easeOutCubic,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
|
color: selected ? Colors.white : context.jz.text2,
|
|
),
|
|
child: Text(label),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCategoryGrid({required Key key}) {
|
|
return GridView.builder(
|
|
key: key,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 4,
|
|
childAspectRatio: 0.92,
|
|
),
|
|
itemCount: _categories.length,
|
|
itemBuilder: (context, index) {
|
|
final category = _categories[index];
|
|
final selected = category.id == _selected?.id;
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(16),
|
|
onTap: () =>
|
|
setState(() => _selectedByType[_selectionKey] = category),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
AnimatedContainer(
|
|
duration: const Duration(milliseconds: 160),
|
|
width: 48,
|
|
height: 48,
|
|
decoration: BoxDecoration(
|
|
color: selected ? _activeColor : context.jz.card,
|
|
borderRadius: BorderRadius.circular(15),
|
|
border: Border.all(
|
|
color: selected ? _activeColor : context.jz.line,
|
|
width: selected ? 1 : 0.5,
|
|
),
|
|
),
|
|
child: Center(
|
|
child: AppIcons.byKey(
|
|
category.iconKey,
|
|
size: 21,
|
|
color: selected ? Colors.white : context.jz.text2,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(height: 5),
|
|
Text(
|
|
category.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: selected ? _activeColor : context.jz.text2,
|
|
fontWeight: selected ? FontWeight.w600 : null,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildAmountKeyboard(bool compact) {
|
|
const keys = [
|
|
'1',
|
|
'2',
|
|
'3',
|
|
'4',
|
|
'5',
|
|
'6',
|
|
'7',
|
|
'8',
|
|
'9',
|
|
'.',
|
|
'0',
|
|
'delete',
|
|
];
|
|
final dateLabel =
|
|
'${_occurredAt.month}/${_occurredAt.day} '
|
|
'${_occurredAt.hour.toString().padLeft(2, '0')}:'
|
|
'${_occurredAt.minute.toString().padLeft(2, '0')}';
|
|
|
|
return Container(
|
|
padding: EdgeInsets.fromLTRB(16, compact ? 7 : 10, 16, 10),
|
|
decoration: BoxDecoration(
|
|
color: context.jz.card,
|
|
border: Border(top: BorderSide(color: context.jz.line, width: 0.5)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
if (_selected != null) ...[
|
|
CategoryIconBox(
|
|
iconKey: _selected!.iconKey,
|
|
colorKey: _selected!.colorKey,
|
|
size: 30,
|
|
),
|
|
SizedBox(width: 8),
|
|
Text(
|
|
_selected!.name,
|
|
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
|
),
|
|
],
|
|
Spacer(),
|
|
AnimatedDefaultTextStyle(
|
|
duration: const Duration(milliseconds: 260),
|
|
curve: Curves.easeOutCubic,
|
|
style: TextStyle(
|
|
fontSize: compact ? 25 : 28,
|
|
fontWeight: FontWeight.w800,
|
|
color: _activeColor,
|
|
),
|
|
child: Text('¥ $_amount'),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 7),
|
|
SizedBox(
|
|
height: 34,
|
|
child: ListView(
|
|
scrollDirection: Axis.horizontal,
|
|
children: [
|
|
_metaChip(
|
|
icon: Icons.edit_note_rounded,
|
|
label: _noteCtrl.text.isEmpty ? '备注' : _noteCtrl.text,
|
|
onTap: _editNote,
|
|
),
|
|
if (_tab == 'transfer')
|
|
_metaChip(
|
|
icon: Icons.person_outline_rounded,
|
|
label: _counterpartyCtrl.text.isEmpty
|
|
? '转账对方'
|
|
: _counterpartyCtrl.text,
|
|
onTap: _editCounterparty,
|
|
),
|
|
_metaChip(
|
|
icon: Icons.schedule_rounded,
|
|
label: dateLabel,
|
|
onTap: _pickOccurredAt,
|
|
),
|
|
_metaChip(
|
|
icon: Icons.account_balance_wallet_outlined,
|
|
label: _paymentMethod ?? '支付方式',
|
|
onTap: _pickPaymentMethod,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(height: 8),
|
|
SizedBox(
|
|
height: compact ? 172 : 196,
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: GridView.count(
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
crossAxisCount: 3,
|
|
mainAxisSpacing: 6,
|
|
crossAxisSpacing: 6,
|
|
childAspectRatio: compact ? 2.15 : 2.0,
|
|
children: keys.map(_buildKey).toList(),
|
|
),
|
|
),
|
|
SizedBox(width: 7),
|
|
SizedBox(
|
|
width: 74,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 260),
|
|
curve: Curves.easeOutCubic,
|
|
decoration: BoxDecoration(
|
|
color: _saving ? context.jz.line : _activeColor,
|
|
borderRadius: BorderRadius.circular(11),
|
|
),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(11),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(11),
|
|
onTap: _saving ? null : _save,
|
|
child: Center(
|
|
child: _saving
|
|
? SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: Colors.white,
|
|
),
|
|
)
|
|
: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.check_rounded,
|
|
color: Colors.white,
|
|
size: 24,
|
|
),
|
|
SizedBox(height: 4),
|
|
Text(
|
|
'完成',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _metaChip({
|
|
required IconData icon,
|
|
required String label,
|
|
required VoidCallback onTap,
|
|
}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 7),
|
|
child: ActionChip(
|
|
avatar: Icon(icon, size: 15, color: context.jz.text2),
|
|
label: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 105),
|
|
child: Text(
|
|
label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(fontSize: 11, color: context.jz.text2),
|
|
),
|
|
),
|
|
side: BorderSide.none,
|
|
backgroundColor: context.jz.background,
|
|
onPressed: onTap,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildKey(String key) {
|
|
return Material(
|
|
color: context.jz.background,
|
|
borderRadius: BorderRadius.circular(9),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(9),
|
|
onTap: () => _pressKey(key),
|
|
child: Center(
|
|
child: key == 'delete'
|
|
? Icon(
|
|
Icons.backspace_outlined,
|
|
size: 18,
|
|
color: context.jz.text2,
|
|
)
|
|
: Text(
|
|
key,
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|