Add transfer tracking and secure admin access
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -174,7 +174,7 @@ class _ParseSheetState extends State<_ParseSheet> {
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: d.type == 'income'
|
||||
color: d.isIncome
|
||||
? AppTheme.primary
|
||||
: AppTheme.red,
|
||||
),
|
||||
@@ -189,18 +189,18 @@ class _ParseSheetState extends State<_ParseSheet> {
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
(d.type == 'income'
|
||||
(d.isIncome
|
||||
? AppTheme.primary
|
||||
: AppTheme.red)
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: Text(
|
||||
d.type == 'income' ? '收入' : '支出',
|
||||
d.typeLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: d.type == 'income'
|
||||
color: d.isIncome
|
||||
? AppTheme.primary
|
||||
: AppTheme.red,
|
||||
),
|
||||
|
||||
@@ -135,12 +135,18 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
final type = switch (json['type']?.toString().toLowerCase()) {
|
||||
'income' => 'income',
|
||||
'expense' => 'expense',
|
||||
'transfer' => 'transfer',
|
||||
_ => 'unknown',
|
||||
};
|
||||
final rawTransferDirection = json['transferDirection']?.toString();
|
||||
final transferDirection =
|
||||
rawTransferDirection == 'in' || rawTransferDirection == 'out'
|
||||
? rawTransferDirection!
|
||||
: 'out';
|
||||
final categoryId = (json['categoryId'] as num?)?.toInt();
|
||||
final categoryName = json['categoryName']?.toString() ?? '其他';
|
||||
final categoryIcon = json['categoryIcon']?.toString() ?? 'tag';
|
||||
final categories = _categoriesFor(type);
|
||||
final categories = _categoriesFor(type, transferDirection);
|
||||
final exists = categories.any((category) => category.id == categoryId);
|
||||
if (type != 'unknown' && !exists && categoryId != null) {
|
||||
categories.add(
|
||||
@@ -148,7 +154,11 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
'id': categoryId,
|
||||
'name': categoryName,
|
||||
'iconKey': categoryIcon,
|
||||
'type': type,
|
||||
'type': type == 'transfer'
|
||||
? transferDirection == 'in'
|
||||
? 'income'
|
||||
: 'expense'
|
||||
: type,
|
||||
'sortOrder': 999,
|
||||
'isCustom': false,
|
||||
}),
|
||||
@@ -172,6 +182,8 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
occurredAt: parsedOccurredAt == null
|
||||
? ShanghaiTime.now
|
||||
: ShanghaiTime.toCivil(parsedOccurredAt),
|
||||
transferDirection: transferDirection,
|
||||
counterparty: json['counterparty']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,10 +201,11 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
List<CategoryItem> _categoriesFor(String type) {
|
||||
List<CategoryItem> _categoriesFor(String type, [String direction = 'out']) {
|
||||
return switch (type) {
|
||||
'income' => _incomeCategories,
|
||||
'expense' => _expenseCategories,
|
||||
'transfer' => direction == 'in' ? _incomeCategories : _expenseCategories,
|
||||
_ => <CategoryItem>[],
|
||||
};
|
||||
}
|
||||
@@ -203,7 +216,7 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
|
||||
void _changeType(_ScreenshotDraft draft, String type) {
|
||||
if (draft.type == type) return;
|
||||
final categories = _categoriesFor(type);
|
||||
final categories = _categoriesFor(type, draft.transferDirection);
|
||||
setState(() {
|
||||
draft.type = type;
|
||||
draft.included = true;
|
||||
@@ -211,6 +224,15 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
});
|
||||
}
|
||||
|
||||
void _changeTransferDirection(_ScreenshotDraft draft, String direction) {
|
||||
if (draft.transferDirection == direction) return;
|
||||
final categories = _categoriesFor('transfer', direction);
|
||||
setState(() {
|
||||
draft.transferDirection = direction;
|
||||
draft.categoryId = categories.isEmpty ? null : categories.first.id;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickOccurredAt(_ScreenshotDraft draft) async {
|
||||
FocusScope.of(context).unfocus();
|
||||
final value = await showJzDateTimeSheet(
|
||||
@@ -226,7 +248,7 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
}
|
||||
|
||||
Future<void> _selectCategory(_ScreenshotDraft draft, int billIndex) async {
|
||||
final categories = _categoriesFor(draft.type);
|
||||
final categories = _categoriesFor(draft.type, draft.transferDirection);
|
||||
if (categories.isEmpty) {
|
||||
_showMessage('当前收支类型暂无可选分类');
|
||||
return;
|
||||
@@ -308,8 +330,10 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
|
||||
for (var index = 0; index < selected.length; index++) {
|
||||
final draft = selected[index];
|
||||
if (draft.type != 'income' && draft.type != 'expense') {
|
||||
_showMessage('第 ${_drafts.indexOf(draft) + 1} 笔请先确认收入或支出');
|
||||
if (draft.type != 'income' &&
|
||||
draft.type != 'expense' &&
|
||||
draft.type != 'transfer') {
|
||||
_showMessage('第 ${_drafts.indexOf(draft) + 1} 笔请先确认账单类型');
|
||||
return;
|
||||
}
|
||||
if ((double.tryParse(draft.amountController.text) ?? 0) <= 0) {
|
||||
@@ -343,6 +367,14 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
source: 'screenshot',
|
||||
sourceText: '截屏识别',
|
||||
occurredAt: draft.occurredAt,
|
||||
transferDirection: draft.type == 'transfer'
|
||||
? draft.transferDirection
|
||||
: null,
|
||||
counterparty:
|
||||
draft.type == 'transfer' &&
|
||||
draft.counterpartyController.text.trim().isNotEmpty
|
||||
? draft.counterpartyController.text.trim()
|
||||
: null,
|
||||
);
|
||||
draft.saved = true;
|
||||
savedAny = true;
|
||||
@@ -540,7 +572,7 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
}
|
||||
|
||||
Widget _buildDraftCard(_ScreenshotDraft draft, int index) {
|
||||
final categories = _categoriesFor(draft.type);
|
||||
final categories = _categoriesFor(draft.type, draft.transferDirection);
|
||||
final selectedCategory = categories
|
||||
.where((category) => category.id == draft.categoryId)
|
||||
.firstOrNull;
|
||||
@@ -583,6 +615,8 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
label: switch (draft.type) {
|
||||
'income' => '收入',
|
||||
'expense' => '支出',
|
||||
'transfer' =>
|
||||
draft.transferDirection == 'in' ? '转入' : '转出',
|
||||
_ => '待确认',
|
||||
},
|
||||
color: draft.type == 'unknown'
|
||||
@@ -629,6 +663,15 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _TypeButton(
|
||||
label: '转账',
|
||||
selected: draft.type == 'transfer',
|
||||
color: AppTheme.orange,
|
||||
onTap: () => _changeType(draft, 'transfer'),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _TypeButton(
|
||||
label: '收入',
|
||||
@@ -639,6 +682,37 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (draft.type == 'transfer') ...[
|
||||
SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TypeButton(
|
||||
label: '转出',
|
||||
selected: draft.transferDirection == 'out',
|
||||
color: AppTheme.orange,
|
||||
onTap: () =>
|
||||
_changeTransferDirection(draft, 'out'),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _TypeButton(
|
||||
label: '转入',
|
||||
selected: draft.transferDirection == 'in',
|
||||
color: AppTheme.primary,
|
||||
onTap: () =>
|
||||
_changeTransferDirection(draft, 'in'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: draft.counterpartyController,
|
||||
decoration: InputDecoration(labelText: '转账对方'),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 10),
|
||||
_CategoryField(
|
||||
category: selectedCategory,
|
||||
@@ -715,6 +789,7 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
|
||||
class _ScreenshotDraft {
|
||||
String type;
|
||||
String transferDirection;
|
||||
int? categoryId;
|
||||
bool included;
|
||||
bool saved = false;
|
||||
@@ -722,6 +797,7 @@ class _ScreenshotDraft {
|
||||
final TextEditingController amountController;
|
||||
final TextEditingController noteController;
|
||||
final TextEditingController paymentController;
|
||||
final TextEditingController counterpartyController;
|
||||
|
||||
_ScreenshotDraft({
|
||||
required this.type,
|
||||
@@ -731,15 +807,19 @@ class _ScreenshotDraft {
|
||||
required String note,
|
||||
required String paymentMethod,
|
||||
required this.occurredAt,
|
||||
this.transferDirection = 'out',
|
||||
String counterparty = '',
|
||||
}) : included = included,
|
||||
amountController = TextEditingController(text: amount),
|
||||
noteController = TextEditingController(text: note),
|
||||
paymentController = TextEditingController(text: paymentMethod);
|
||||
paymentController = TextEditingController(text: paymentMethod),
|
||||
counterpartyController = TextEditingController(text: counterparty);
|
||||
|
||||
void dispose() {
|
||||
amountController.dispose();
|
||||
noteController.dispose();
|
||||
paymentController.dispose();
|
||||
counterpartyController.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
String? _timeFilter; // today | week | month
|
||||
String? _typeFilter;
|
||||
double? _minAmount, _maxAmount;
|
||||
|
||||
Future<void> _search() async {
|
||||
@@ -29,6 +30,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
if (q.isEmpty &&
|
||||
!_aiOnly &&
|
||||
_timeFilter == null &&
|
||||
_typeFilter == null &&
|
||||
_minAmount == null &&
|
||||
_maxAmount == null)
|
||||
return;
|
||||
@@ -58,6 +60,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
maxAmount: _maxAmount,
|
||||
from: from,
|
||||
to: to,
|
||||
type: _typeFilter,
|
||||
);
|
||||
if (mounted)
|
||||
setState(() {
|
||||
@@ -74,7 +77,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final total = _results
|
||||
.where((t) => !t.isIncome)
|
||||
.where((t) => t.isExpense)
|
||||
.fold<double>(0, (s, t) => s + t.amount);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -84,7 +87,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (_) => _search(),
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜备注 / 分类 / 你说过的话',
|
||||
hintText: '搜备注 / 对方 / 分类 / 原话',
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
),
|
||||
),
|
||||
@@ -122,6 +125,20 @@ class _SearchPageState extends State<SearchPage> {
|
||||
},
|
||||
),
|
||||
),
|
||||
for (final option in const [
|
||||
('expense', '支出'),
|
||||
('transfer', '转账'),
|
||||
('income', '收入'),
|
||||
])
|
||||
FilterChip(
|
||||
label: Text(option.$2, style: TextStyle(fontSize: 11)),
|
||||
selected: _typeFilter == option.$1,
|
||||
selectedColor: context.jz.primaryBackground,
|
||||
onSelected: (selected) {
|
||||
setState(() => _typeFilter = selected ? option.$1 : null);
|
||||
_search();
|
||||
},
|
||||
),
|
||||
if (_searched && !_loading)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
|
||||
@@ -20,7 +20,9 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
late final TextEditingController _amount;
|
||||
late final TextEditingController _note;
|
||||
late final TextEditingController _payment;
|
||||
late final TextEditingController _counterparty;
|
||||
late String _type;
|
||||
late String _transferDirection;
|
||||
late int _ledgerId;
|
||||
late int _categoryId;
|
||||
late DateTime _occurredAt;
|
||||
@@ -37,7 +39,9 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
);
|
||||
_note = TextEditingController(text: transaction.note ?? '');
|
||||
_payment = TextEditingController(text: transaction.paymentMethod ?? '');
|
||||
_counterparty = TextEditingController(text: transaction.counterparty ?? '');
|
||||
_type = transaction.type;
|
||||
_transferDirection = transaction.transferDirection ?? 'out';
|
||||
_ledgerId = transaction.ledgerId;
|
||||
_categoryId = transaction.categoryId;
|
||||
_occurredAt = transaction.occurredAt;
|
||||
@@ -49,6 +53,7 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
_amount.dispose();
|
||||
_note.dispose();
|
||||
_payment.dispose();
|
||||
_counterparty.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -56,7 +61,12 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
await CurrentLedgerStore.instance.ensureLoaded();
|
||||
final categories = await TxApi.categories(_type);
|
||||
final categoryType = _type == 'transfer'
|
||||
? _transferDirection == 'in'
|
||||
? 'income'
|
||||
: 'expense'
|
||||
: _type;
|
||||
final categories = await TxApi.categories(categoryType);
|
||||
if (!categories.any((category) => category.id == _categoryId) &&
|
||||
categories.isNotEmpty) {
|
||||
_categoryId = categories.first.id;
|
||||
@@ -75,6 +85,12 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
await _loadCategories();
|
||||
}
|
||||
|
||||
Future<void> _changeTransferDirection(String direction) async {
|
||||
if (direction == _transferDirection) return;
|
||||
setState(() => _transferDirection = direction);
|
||||
await _loadCategories();
|
||||
}
|
||||
|
||||
Future<void> _pickDateTime() async {
|
||||
final value = await showJzDateTimeSheet(
|
||||
context,
|
||||
@@ -143,6 +159,11 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
amount: amount,
|
||||
note: _note.text.trim(),
|
||||
paymentMethod: _payment.text.trim(),
|
||||
transferDirection: _type == 'transfer' ? _transferDirection : null,
|
||||
counterparty:
|
||||
_type == 'transfer' && _counterparty.text.trim().isNotEmpty
|
||||
? _counterparty.text.trim()
|
||||
: null,
|
||||
occurredAt: _occurredAt,
|
||||
);
|
||||
if (mounted) Navigator.pop(context, updated);
|
||||
@@ -178,10 +199,28 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
value: _type,
|
||||
options: const [
|
||||
JzOption(value: 'expense', label: '支出'),
|
||||
JzOption(value: 'transfer', label: '转账'),
|
||||
JzOption(value: 'income', label: '收入'),
|
||||
],
|
||||
onChanged: _changeType,
|
||||
),
|
||||
if (_type == 'transfer') ...[
|
||||
SizedBox(height: 12),
|
||||
JzSegmentedControl<String>(
|
||||
value: _transferDirection,
|
||||
options: const [
|
||||
JzOption(value: 'out', label: '转出'),
|
||||
JzOption(value: 'in', label: '转入'),
|
||||
],
|
||||
onChanged: _changeTransferDirection,
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _counterparty,
|
||||
maxLength: 40,
|
||||
decoration: InputDecoration(labelText: '转账对方'),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _amount,
|
||||
|
||||
@@ -100,11 +100,13 @@ class TxDetailPage extends StatelessWidget {
|
||||
// 类型
|
||||
_Row(
|
||||
k: '类型',
|
||||
child: Text(
|
||||
tx.isIncome ? '收入' : '支出',
|
||||
style: TextStyle(fontSize: 13.5),
|
||||
),
|
||||
child: Text(tx.typeLabel, style: TextStyle(fontSize: 13.5)),
|
||||
),
|
||||
if (tx.isTransfer && tx.counterparty?.isNotEmpty == true)
|
||||
_Row(
|
||||
k: '转账对方',
|
||||
child: Text(tx.counterparty!, style: TextStyle(fontSize: 13.5)),
|
||||
),
|
||||
// 备注
|
||||
if (tx.note != null && tx.note!.isNotEmpty)
|
||||
_Row(
|
||||
|
||||
@@ -17,10 +17,12 @@ class RecognitionBatchPage extends StatefulWidget {
|
||||
|
||||
class _RecognitionBatchPageState extends State<RecognitionBatchPage> {
|
||||
List<RecognitionBatch> _batches = const [];
|
||||
List<RecognitionCandidate> _candidates = const [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
String? _restoringId;
|
||||
String? _confirmingId;
|
||||
String? _ignoringId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -30,10 +32,14 @@ class _RecognitionBatchPageState extends State<RecognitionBatchPage> {
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final batches = await ScreenshotChannel.listRecognitionBatches();
|
||||
final results = await Future.wait([
|
||||
ScreenshotChannel.listRecognitionCandidates(),
|
||||
ScreenshotChannel.listRecognitionBatches(),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_batches = batches;
|
||||
_candidates = results[0] as List<RecognitionCandidate>;
|
||||
_batches = results[1] as List<RecognitionBatch>;
|
||||
_loading = false;
|
||||
_error = null;
|
||||
});
|
||||
@@ -84,10 +90,43 @@ class _RecognitionBatchPageState extends State<RecognitionBatchPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmCandidate(RecognitionCandidate item) async {
|
||||
setState(() => _confirmingId = item.id);
|
||||
try {
|
||||
await RecognitionImportService.handleAction(context, {
|
||||
'action': 'recognition_confirm',
|
||||
'candidateId': item.id,
|
||||
});
|
||||
await _load();
|
||||
} finally {
|
||||
if (mounted) setState(() => _confirmingId = null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ignoreCandidate(RecognitionCandidate item) async {
|
||||
setState(() => _ignoringId = item.id);
|
||||
try {
|
||||
final ignored = await ScreenshotChannel.acknowledgeRecognitionCandidate(
|
||||
item.id,
|
||||
'expired',
|
||||
);
|
||||
if (!ignored) throw StateError('这条识别结果已处理');
|
||||
await _load();
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _ignoringId = null);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('最近 AI 对账')),
|
||||
appBar: AppBar(title: const Text('识别记录')),
|
||||
body: RefreshIndicator(onRefresh: _load, child: _body()),
|
||||
);
|
||||
}
|
||||
@@ -114,26 +153,108 @@ class _RecognitionBatchPageState extends State<RecognitionBatchPage> {
|
||||
],
|
||||
);
|
||||
}
|
||||
if (_batches.isEmpty) {
|
||||
final pending = _candidates
|
||||
.where((item) => item.state == 'pending_confirm')
|
||||
.toList(growable: false);
|
||||
if (_batches.isEmpty && pending.isEmpty) {
|
||||
return ListView(
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.sizeOf(context).height * 0.3),
|
||||
Icon(Icons.fact_check_outlined, size: 42, color: context.jz.text3),
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: Text(
|
||||
'暂无 AI 对账记录',
|
||||
style: TextStyle(color: context.jz.text3),
|
||||
),
|
||||
child: Text('暂无识别记录', style: TextStyle(color: context.jz.text3)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 28),
|
||||
itemCount: _batches.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (_, index) => _batchCard(_batches[index]),
|
||||
children: [
|
||||
if (pending.isNotEmpty) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(2, 4, 2, 10),
|
||||
child: Text(
|
||||
'待确认',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
for (final item in pending) ...[
|
||||
_candidateCard(item),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
if (_batches.isNotEmpty) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(2, 10, 2, 10),
|
||||
child: Text(
|
||||
'AI 对账历史',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
for (final batch in _batches) ...[
|
||||
_batchCard(batch),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _candidateCard(RecognitionCandidate item) {
|
||||
final confirming = _confirmingId == item.id;
|
||||
final ignoring = _ignoringId == item.id;
|
||||
final ambiguous = item.identityConfidence == 'ambiguous_repeat';
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
ambiguous ? Icons.content_copy_rounded : Icons.receipt_long_rounded,
|
||||
color: ambiguous ? AppTheme.orange : AppTheme.primary,
|
||||
),
|
||||
title: Text(
|
||||
item.merchant?.trim().isNotEmpty == true
|
||||
? item.merchant!.trim()
|
||||
: '${item.appName}账单',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(ambiguous ? '疑似连续同额交易,请确认是否为新账单' : '识别证据不足,请确认后入账'),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${item.isIncome ? '+' : '-'}¥${item.amount.toStringAsFixed(2)}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '忽略',
|
||||
onPressed: ignoring || confirming
|
||||
? null
|
||||
: () => _ignoreCandidate(item),
|
||||
icon: ignoring
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.close_rounded),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '确认入账',
|
||||
onPressed: confirming || ignoring
|
||||
? null
|
||||
: () => _confirmCandidate(item),
|
||||
icon: confirming
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.check_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,13 +339,11 @@ class _RecognitionBatchPageState extends State<RecognitionBatchPage> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${item.type == 'income' ? '+' : '-'}¥${item.amount.toStringAsFixed(2)}',
|
||||
'${item.isIncome ? '+' : '-'}¥${item.amount.toStringAsFixed(2)}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: item.type == 'income'
|
||||
? AppTheme.primary
|
||||
: AppTheme.red,
|
||||
color: item.isIncome ? AppTheme.primary : AppTheme.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -515,7 +515,7 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
||||
children: [
|
||||
Icon(Icons.fact_check_outlined, size: 20),
|
||||
SizedBox(width: 10),
|
||||
Expanded(child: Text('最近 AI 对账')),
|
||||
Expanded(child: Text('识别记录')),
|
||||
Icon(Icons.chevron_right_rounded),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user