Add transfer tracking and secure admin access

This commit is contained in:
2026-07-26 11:57:57 +08:00
parent 0738953e6d
commit 7df25edd96
111 changed files with 6379 additions and 1934 deletions
+91 -11
View File
@@ -18,7 +18,9 @@ class AddPage extends StatefulWidget {
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>[],
@@ -26,18 +28,34 @@ class _AddPageState extends State<AddPage> {
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;
String get _categoryType => _tab == 'transfer'
? _transferDirection == 'in'
? 'income'
: 'expense'
: _tab;
String get _selectionKey =>
_tab == 'transfer' ? 'transfer_$_transferDirection' : _tab;
List<CategoryItem> get _categories =>
_categoriesByType[_tab] ?? const <CategoryItem>[];
_categoriesByType[_categoryType] ?? const <CategoryItem>[];
CategoryItem? get _selected => _selectedByType[_tab];
CategoryItem? get _selected => _selectedByType[_selectionKey];
Color get _activeColor => _tab == 'income' ? AppTheme.primary : AppTheme.red;
Color get _activeColor =>
_tab == 'income' || _tab == 'transfer' && _transferDirection == 'in'
? AppTheme.primary
: _tab == 'transfer'
? AppTheme.orange
: AppTheme.red;
@override
void initState() {
@@ -48,6 +66,7 @@ class _AddPageState extends State<AddPage> {
@override
void dispose() {
_noteCtrl.dispose();
_counterpartyCtrl.dispose();
super.dispose();
}
@@ -68,6 +87,12 @@ class _AddPageState extends State<AddPage> {
if (_selectedByType['income'] == null && results[1].isNotEmpty) {
_selectedByType['income'] = results[1].first;
}
_selectedByType['transfer_out'] ??= results[0].isEmpty
? null
: results[0].first;
_selectedByType['transfer_in'] ??= results[1].isEmpty
? null
: results[1].first;
});
} catch (error) {
if (mounted) {
@@ -85,6 +110,11 @@ class _AddPageState extends State<AddPage> {
setState(() => _tab = tab);
}
void _switchTransferDirection(String direction) {
if (_transferDirection == direction) return;
setState(() => _transferDirection = direction);
}
void _pressKey(String key) {
setState(() {
if (key == 'delete') {
@@ -123,6 +153,11 @@ class _AddPageState extends State<AddPage> {
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,
@@ -152,6 +187,17 @@ class _AddPageState extends State<AddPage> {
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,
@@ -201,6 +247,20 @@ class _AddPageState extends State<AddPage> {
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(
@@ -210,7 +270,9 @@ class _AddPageState extends State<AddPage> {
transitionBuilder: (child, animation) {
final offset = _tab == 'expense'
? const Offset(-0.04, 0)
: const Offset(0.04, 0);
: _tab == 'income'
? const Offset(0.04, 0)
: Offset.zero;
return FadeTransition(
opacity: animation,
child: SlideTransition(
@@ -244,7 +306,7 @@ class _AddPageState extends State<AddPage> {
Widget _buildTypeSelector() {
return Container(
width: 220,
width: 300,
height: 38,
margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.all(3),
@@ -258,11 +320,16 @@ class _AddPageState extends State<AddPage> {
AnimatedAlign(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
alignment: _tab == 'expense'
? Alignment.centerLeft
: Alignment.centerRight,
alignment: Alignment(
_tab == 'expense'
? -1
: _tab == 'income'
? 1
: 0,
0,
),
child: Container(
width: constraints.maxWidth / 2,
width: constraints.maxWidth / 3,
height: constraints.maxHeight,
decoration: BoxDecoration(
color: _activeColor,
@@ -271,7 +338,11 @@ class _AddPageState extends State<AddPage> {
),
),
Row(
children: [_segment('支出', 'expense'), _segment('收入', 'income')],
children: [
_segment('支出', 'expense'),
_segment('转账', 'transfer'),
_segment('收入', 'income'),
],
),
],
),
@@ -315,7 +386,8 @@ class _AddPageState extends State<AddPage> {
final selected = category.id == _selected?.id;
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => setState(() => _selectedByType[_tab] = category),
onTap: () =>
setState(() => _selectedByType[_selectionKey] = category),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@@ -423,6 +495,14 @@ class _AddPageState extends State<AddPage> {
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,