Files
jizhi/frontend/lib/features/add/add_page.dart
T

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