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,
+4 -4
View File
@@ -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),
],
),
+87 -4
View File
@@ -15,6 +15,9 @@ class TxItem {
final String categoryName, categoryIcon, categoryColor, type, source;
final double amount;
final String? note, paymentMethod, sourceText;
final String? transferDirection, counterparty;
final String? provider, providerTransactionId, recognitionOccurrenceId;
final String? evidenceFingerprint, recognitionConfidence;
final DateTime occurredAt, updatedAt;
final bool isDeleted;
@@ -31,6 +34,13 @@ class TxItem {
paymentMethod = j['paymentMethod'] as String?,
source = j['source'] as String,
sourceText = j['sourceText'] as String?,
transferDirection = j['transferDirection'] as String?,
counterparty = j['counterparty'] as String?,
provider = j['provider'] as String?,
providerTransactionId = j['providerTransactionId'] as String?,
recognitionOccurrenceId = j['recognitionOccurrenceId'] as String?,
evidenceFingerprint = j['evidenceFingerprint'] as String?,
recognitionConfidence = j['recognitionConfidence'] as String?,
occurredAt = ShanghaiTime.parseCivil(j['occurredAt'] as String),
updatedAt = DateTime.parse(
j['updatedAt'] as String? ?? j['occurredAt'] as String,
@@ -48,7 +58,18 @@ class TxItem {
source == 'accessibility' ||
source == 'notification' ||
source == 'local_ocr';
bool get isIncome => type == 'income';
bool get isTransfer => type == 'transfer';
bool get isIncome =>
type == 'income' || isTransfer && transferDirection == 'in';
bool get isExpense =>
type == 'expense' || isTransfer && transferDirection == 'out';
String get typeLabel => isTransfer
? transferDirection == 'in'
? '转入'
: '转出'
: isIncome
? '收入'
: '支出';
String get sourceLabel => switch (source) {
'ai_chat' => 'AI 聊天',
@@ -191,6 +212,9 @@ class RecognitionBatchDraft {
final double amount;
final DateTime occurredAt;
final String? note, paymentMethod, sourceText;
final String? transferDirection, counterparty;
final String? provider, providerTransactionId, recognitionOccurrenceId;
final String? evidenceFingerprint, recognitionConfidence;
const RecognitionBatchDraft({
required this.candidateId,
@@ -203,6 +227,13 @@ class RecognitionBatchDraft {
this.note,
this.paymentMethod,
this.sourceText,
this.transferDirection,
this.counterparty,
this.provider,
this.providerTransactionId,
this.recognitionOccurrenceId,
this.evidenceFingerprint,
this.recognitionConfidence,
});
}
@@ -336,6 +367,13 @@ class TxApi {
String? sourceText,
DateTime? occurredAt,
String? clientRequestId,
String? transferDirection,
String? counterparty,
String? provider,
String? providerTransactionId,
String? recognitionOccurrenceId,
String? evidenceFingerprint,
String? recognitionConfidence,
}) async {
final payload = <String, dynamic>{
'ledgerId': _ledgerId,
@@ -350,6 +388,17 @@ class TxApi {
occurredAt ?? ShanghaiTime.now,
).toIso8601String(),
if (clientRequestId != null) 'clientRequestId': clientRequestId,
if (transferDirection != null) 'transferDirection': transferDirection,
if (counterparty != null) 'counterparty': counterparty,
if (provider != null) 'provider': provider,
if (providerTransactionId != null)
'providerTransactionId': providerTransactionId,
if (recognitionOccurrenceId != null)
'recognitionOccurrenceId': recognitionOccurrenceId,
if (evidenceFingerprint != null)
'evidenceFingerprint': evidenceFingerprint,
if (recognitionConfidence != null)
'recognitionConfidence': recognitionConfidence,
};
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || categoryId < 0 || _ledgerId < 0) {
@@ -402,6 +451,13 @@ class TxApi {
draft.occurredAt,
).toIso8601String(),
'clientRequestId': draft.clientRequestId,
'transferDirection': draft.transferDirection,
'counterparty': draft.counterparty,
'provider': draft.provider,
'providerTransactionId': draft.providerTransactionId,
'recognitionOccurrenceId': draft.recognitionOccurrenceId,
'evidenceFingerprint': draft.evidenceFingerprint,
'recognitionConfidence': draft.recognitionConfidence,
},
)
.toList(growable: false);
@@ -442,6 +498,14 @@ class TxApi {
'occurredAt': localPayloads[index]['occurredAt'],
'source': drafts[index].source,
'sourceText': drafts[index].sourceText,
'transferDirection': drafts[index].transferDirection,
'counterparty': drafts[index].counterparty,
'provider': drafts[index].provider,
'providerTransactionId': drafts[index].providerTransactionId,
'recognitionOccurrenceId':
drafts[index].recognitionOccurrenceId,
'evidenceFingerprint': drafts[index].evidenceFingerprint,
'recognitionConfidence': drafts[index].recognitionConfidence,
},
],
},
@@ -499,6 +563,8 @@ class TxApi {
required DateTime occurredAt,
String? note,
String? paymentMethod,
String? transferDirection,
String? counterparty,
}) async {
final baseUpdatedAt = LocalDatabase.instance.transaction(
id,
@@ -511,6 +577,8 @@ class TxApi {
'amount': amount,
'note': note,
'paymentMethod': paymentMethod,
'transferDirection': transferDirection,
'counterparty': counterparty,
'occurredAt': ShanghaiTime.civilToUtc(occurredAt).toIso8601String(),
if (baseUpdatedAt != null) 'baseUpdatedAt': baseUpdatedAt,
};
@@ -1447,7 +1515,7 @@ class ParsedDraft {
final int categoryId;
final String categoryName, categoryIcon, note, type;
final double amount;
final String? paymentMethod;
final String? paymentMethod, transferDirection, counterparty;
ParsedDraft.fromJson(Map<String, dynamic> j)
: matched = j['matched'] as bool,
@@ -1456,12 +1524,25 @@ class ParsedDraft {
categoryIcon = j['categoryIcon'] as String,
amount = (j['amount'] as num).toDouble(),
paymentMethod = j['paymentMethod'] as String?,
transferDirection = j['transferDirection'] as String?,
counterparty = j['counterparty'] as String?,
note = j['note'] as String,
type = switch (j['type']?.toString().toLowerCase()) {
'income' => 'income',
'expense' => 'expense',
'transfer' => 'transfer',
_ => 'unknown',
};
bool get isIncome =>
type == 'income' || type == 'transfer' && transferDirection == 'in';
String get typeLabel => type == 'transfer'
? transferDirection == 'in'
? '转入'
: '转出'
: isIncome
? '收入'
: '支出';
}
class ParseApi {
@@ -1481,8 +1562,8 @@ class ParseApi {
String source,
String sourceText,
) async {
if (d.type != 'income' && d.type != 'expense') {
throw StateError('请先确认收入或支出类型');
if (d.type != 'income' && d.type != 'expense' && d.type != 'transfer') {
throw StateError('请先确认账单类型');
}
return TxApi.create(
categoryId: d.categoryId,
@@ -1492,6 +1573,8 @@ class ParseApi {
paymentMethod: d.paymentMethod,
source: source,
sourceText: sourceText,
transferDirection: d.transferDirection,
counterparty: d.counterparty,
);
}
}
+165 -34
View File
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
@@ -13,6 +14,16 @@ class LocalDatabase {
static final instance = LocalDatabase._();
static const _secureStorage = FlutterSecureStorage();
@visibleForTesting
static LocalDatabase inMemoryForTesting() {
final database = LocalDatabase._();
database._database = sqlite3.openInMemory();
database._namespace = 'test';
database._migrate();
database._seedDefaults();
return database;
}
Database? _database;
String? _namespace;
@@ -102,9 +113,16 @@ class LocalDatabase {
amount REAL NOT NULL,
note TEXT,
payment_method TEXT,
transfer_direction TEXT,
counterparty TEXT,
source TEXT NOT NULL DEFAULT 'manual',
source_text TEXT,
client_request_id TEXT,
provider TEXT,
provider_transaction_id TEXT,
recognition_occurrence_id TEXT,
evidence_fingerprint TEXT,
recognition_confidence TEXT,
occurred_at TEXT NOT NULL,
is_deleted INTEGER NOT NULL DEFAULT 0,
deleted_at TEXT,
@@ -165,11 +183,37 @@ class LocalDatabase {
if (!transactionColumns.contains('client_request_id')) {
_db.execute('ALTER TABLE transactions ADD COLUMN client_request_id TEXT');
}
const addedTransactionColumns = <String, String>{
'transfer_direction': 'TEXT',
'counterparty': 'TEXT',
'provider': 'TEXT',
'provider_transaction_id': 'TEXT',
'recognition_occurrence_id': 'TEXT',
'evidence_fingerprint': 'TEXT',
'recognition_confidence': 'TEXT',
};
for (final entry in addedTransactionColumns.entries) {
if (!transactionColumns.contains(entry.key)) {
_db.execute(
'ALTER TABLE transactions ADD COLUMN ${entry.key} ${entry.value}',
);
}
}
_db.execute('''
CREATE UNIQUE INDEX IF NOT EXISTS ux_local_transactions_client_request
ON transactions (client_request_id)
WHERE client_request_id IS NOT NULL
''');
_db.execute('''
CREATE UNIQUE INDEX IF NOT EXISTS ux_local_transactions_occurrence
ON transactions (recognition_occurrence_id)
WHERE recognition_occurrence_id IS NOT NULL
''');
_db.execute('''
CREATE UNIQUE INDEX IF NOT EXISTS ux_local_transactions_provider_tx
ON transactions (provider, provider_transaction_id)
WHERE provider IS NOT NULL AND provider_transaction_id IS NOT NULL
''');
final conflictColumns = _db
.select('PRAGMA table_info(sync_conflicts)')
.map((row) => row['name'])
@@ -659,7 +703,7 @@ class LocalDatabase {
value['categoryName'] ?? '其他',
value['categoryIcon'] ?? 'tag',
value['categoryColor'] ?? 'mint',
value['type'],
_categoryType(value),
DateTime.now().toUtc().toIso8601String(),
],
);
@@ -668,8 +712,11 @@ class LocalDatabase {
'''
INSERT INTO transactions
(id, ledger_id, category_id, type, amount, note, payment_method,
source, source_text, occurred_at, is_deleted, deleted_at, sync_state, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 'synced', ?)
transfer_direction, counterparty, source, source_text, client_request_id,
provider, provider_transaction_id, recognition_occurrence_id,
evidence_fingerprint, recognition_confidence,
occurred_at, is_deleted, deleted_at, sync_state, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 'synced', ?)
ON CONFLICT(id) DO UPDATE SET
ledger_id = excluded.ledger_id,
category_id = excluded.category_id,
@@ -677,8 +724,16 @@ class LocalDatabase {
amount = excluded.amount,
note = excluded.note,
payment_method = excluded.payment_method,
transfer_direction = excluded.transfer_direction,
counterparty = excluded.counterparty,
source = excluded.source,
source_text = excluded.source_text,
client_request_id = excluded.client_request_id,
provider = excluded.provider,
provider_transaction_id = excluded.provider_transaction_id,
recognition_occurrence_id = excluded.recognition_occurrence_id,
evidence_fingerprint = excluded.evidence_fingerprint,
recognition_confidence = excluded.recognition_confidence,
occurred_at = excluded.occurred_at,
is_deleted = excluded.is_deleted,
sync_state = 'synced',
@@ -692,8 +747,16 @@ class LocalDatabase {
value['amount'],
value['note'],
value['paymentMethod'],
value['transferDirection'],
value['counterparty'],
value['source'] ?? 'manual',
value['sourceText'],
value['clientRequestId'],
value['provider'],
value['providerTransactionId'],
value['recognitionOccurrenceId'],
value['evidenceFingerprint'],
value['recognitionConfidence'],
DateTime.parse(value['occurredAt'] as String).toUtc().toIso8601String(),
value['isDeleted'] == true ? 1 : 0,
value['updatedAt'] ?? DateTime.now().toUtc().toIso8601String(),
@@ -902,25 +965,20 @@ class LocalDatabase {
}
Map<String, dynamic> createTransaction(Map<String, dynamic> value) {
final clientRequestId = value['clientRequestId'] as String?;
if (clientRequestId != null && clientRequestId.isNotEmpty) {
final existing = _db.select(
'SELECT id FROM transactions WHERE client_request_id = ? LIMIT 1',
[clientRequestId],
);
if (existing.isNotEmpty) {
return transaction(
(existing.first['id'] as num).toInt(),
includeDeleted: true,
)!;
}
final existingId = _existingTransactionId(value);
if (existingId != null) {
return transaction(existingId, includeDeleted: true)!;
}
final clientRequestId = _identity(value['clientRequestId']);
final occurrenceId = _identity(value['recognitionOccurrenceId']);
final provider = _identity(value['provider']);
final providerTransactionId = _identity(value['providerTransactionId']);
final categoryId = (value['categoryId'] as num).toInt();
final category = _db.select(
'SELECT * FROM categories WHERE id = ? AND is_deleted = 0',
[categoryId],
);
if (category.isEmpty || category.first['type'] != value['type']) {
if (category.isEmpty || category.first['type'] != _categoryType(value)) {
throw StateError('分类与收支类型不一致');
}
final id = _nextNegativeId('transactions');
@@ -930,8 +988,11 @@ class LocalDatabase {
'''
INSERT INTO transactions
(id, ledger_id, category_id, type, amount, note, payment_method,
source, source_text, client_request_id, occurred_at, is_deleted, sync_state, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'local', ?)
transfer_direction, counterparty, source, source_text, client_request_id,
provider, provider_transaction_id, recognition_occurrence_id,
evidence_fingerprint, recognition_confidence,
occurred_at, is_deleted, sync_state, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'local', ?)
''',
[
id,
@@ -941,9 +1002,16 @@ class LocalDatabase {
value['amount'],
value['note'],
value['paymentMethod'],
value['transferDirection'],
value['counterparty'],
value['source'] ?? 'manual',
value['sourceText'],
clientRequestId,
provider,
providerTransactionId,
occurrenceId,
value['evidenceFingerprint'],
value['recognitionConfidence'],
occurredAt,
now,
],
@@ -959,14 +1027,7 @@ class LocalDatabase {
try {
final created = <Map<String, dynamic>>[];
for (final value in values) {
final clientRequestId = value['clientRequestId'] as String?;
final existed =
clientRequestId != null &&
clientRequestId.isNotEmpty &&
_db.select(
'SELECT 1 FROM transactions WHERE client_request_id = ? LIMIT 1',
[clientRequestId],
).isNotEmpty;
final existed = _existingTransactionId(value) != null;
final transaction = createTransaction(value);
created.add(transaction);
final id = (transaction['id'] as num).toInt();
@@ -1000,13 +1061,14 @@ class LocalDatabase {
'SELECT type FROM categories WHERE id = ? AND is_deleted = 0',
[value['categoryId']],
);
if (category.isEmpty || category.first['type'] != value['type']) {
if (category.isEmpty || category.first['type'] != _categoryType(value)) {
throw StateError('分类与收支类型不一致');
}
_db.execute(
'''
UPDATE transactions SET ledger_id = ?, category_id = ?, type = ?,
amount = ?, note = ?, payment_method = ?, occurred_at = ?,
amount = ?, note = ?, payment_method = ?, transfer_direction = ?,
counterparty = ?, occurred_at = ?,
sync_state = 'local', updated_at = ? WHERE id = ?
''',
[
@@ -1016,6 +1078,8 @@ class LocalDatabase {
value['amount'],
value['note'],
value['paymentMethod'],
value['transferDirection'],
value['counterparty'],
value['occurredAt'],
DateTime.now().toUtc().toIso8601String(),
id,
@@ -1140,7 +1204,7 @@ class LocalDatabase {
ShanghaiTime.civilToUtc(start),
ShanghaiTime.civilToUtc(end),
);
final expenses = items.where((item) => item['type'] == 'expense').toList();
final expenses = items.where(_isExpense).toList();
final expense = _sumType(items, 'expense');
final income = _sumType(items, 'income');
final categories = <int, Map<String, dynamic>>{};
@@ -1236,7 +1300,7 @@ class LocalDatabase {
ShanghaiTime.civilToUtc(start),
ShanghaiTime.civilToUtc(end),
);
final expenses = items.where((item) => item['type'] == 'expense').toList();
final expenses = items.where(_isExpense).toList();
final grouped = <DateTime, List<Map<String, dynamic>>>{};
for (final item in expenses) {
final local = ShanghaiTime.parseCivil(item['occurredAt'] as String);
@@ -1331,13 +1395,15 @@ class LocalDatabase {
final keyword = query?.trim().toLowerCase();
if (keyword != null && keyword.isNotEmpty) {
final haystack =
'${item['note'] ?? ''} ${item['categoryName']} ${item['sourceText'] ?? ''}'
'${item['note'] ?? ''} ${item['counterparty'] ?? ''} '
'${item['categoryName']} ${item['sourceText'] ?? ''}'
.toLowerCase();
if (!haystack.contains(keyword)) return false;
}
if (aiOnly && item['source'] == 'manual') return false;
if (categoryId != null && item['categoryId'] != categoryId)
if (categoryId != null && item['categoryId'] != categoryId) {
return false;
}
if (type != null && item['type'] != type) return false;
if (minAmount != null && amount < minAmount) return false;
if (maxAmount != null && amount > maxAmount) return false;
@@ -1390,7 +1456,7 @@ class LocalDatabase {
final spent = monthItems
.where(
(item) =>
item['type'] == 'expense' &&
_isExpense(item) &&
(categoryKey == 0 || item['categoryId'] == categoryKey),
)
.fold<double>(
@@ -1526,18 +1592,83 @@ class LocalDatabase {
'amount': (row['amount'] as num).toDouble(),
'note': row['note'] as String?,
'paymentMethod': row['payment_method'] as String?,
'transferDirection': row['transfer_direction'] as String?,
'counterparty': row['counterparty'] as String?,
'source': row['source'] as String,
'sourceText': row['source_text'] as String?,
'clientRequestId': row['client_request_id'] as String?,
'provider': row['provider'] as String?,
'providerTransactionId': row['provider_transaction_id'] as String?,
'recognitionOccurrenceId': row['recognition_occurrence_id'] as String?,
'evidenceFingerprint': row['evidence_fingerprint'] as String?,
'recognitionConfidence': row['recognition_confidence'] as String?,
'occurredAt': row['occurred_at'] as String,
'isDeleted': (row['is_deleted'] as num).toInt() == 1,
'updatedAt': row['updated_at'] as String,
};
double _sumType(List<Map<String, dynamic>> items, String type) => items
.where((item) => item['type'] == type)
.where((item) => type == 'income' ? _isIncome(item) : _isExpense(item))
.fold<double>(0, (sum, item) => sum + (item['amount'] as num).toDouble());
bool _isIncome(Map<String, dynamic> item) =>
item['type'] == 'income' ||
item['type'] == 'transfer' && item['transferDirection'] == 'in';
bool _isExpense(Map<String, dynamic> item) =>
item['type'] == 'expense' ||
item['type'] == 'transfer' && item['transferDirection'] == 'out';
String _categoryType(Map<String, dynamic> value) {
final type = value['type'] as String;
final direction = value['transferDirection'] as String?;
if (type == 'transfer') {
if (direction == 'in') return 'income';
if (direction == 'out') return 'expense';
throw StateError('转账必须选择转入或转出');
}
if (direction != null && direction.isNotEmpty) {
throw StateError('非转账账单不能设置转账方向');
}
return type;
}
int? _existingTransactionId(Map<String, dynamic> value) {
final provider = _identity(value['provider']);
final providerTransactionId = _identity(value['providerTransactionId']);
if (provider != null && providerTransactionId != null) {
final rows = _db.select(
'SELECT id FROM transactions '
'WHERE provider = ? AND provider_transaction_id = ? LIMIT 1',
[provider, providerTransactionId],
);
if (rows.isNotEmpty) return (rows.first['id'] as num).toInt();
}
final occurrenceId = _identity(value['recognitionOccurrenceId']);
if (occurrenceId != null) {
final rows = _db.select(
'SELECT id FROM transactions '
'WHERE recognition_occurrence_id = ? LIMIT 1',
[occurrenceId],
);
if (rows.isNotEmpty) return (rows.first['id'] as num).toInt();
}
final clientRequestId = _identity(value['clientRequestId']);
if (clientRequestId != null) {
final rows = _db.select(
'SELECT id FROM transactions WHERE client_request_id = ? LIMIT 1',
[clientRequestId],
);
if (rows.isNotEmpty) return (rows.first['id'] as num).toInt();
}
return null;
}
String? _identity(Object? value) {
final normalized = value?.toString().trim();
return normalized == null || normalized.isEmpty ? null : normalized;
}
String _monthDay(DateTime date) =>
'${date.month.toString().padLeft(2, '0')}${date.day.toString().padLeft(2, '0')}';
}
@@ -194,6 +194,13 @@ class RecognitionImportService {
sourceText: candidate.sourceText,
occurredAt: ShanghaiTime.toCivil(occurredUtc),
clientRequestId: candidate.clientRequestId,
transferDirection: candidate.transferDirection,
counterparty: candidate.counterparty,
provider: candidate.provider,
providerTransactionId: candidate.providerTransactionId,
recognitionOccurrenceId: candidate.recognitionOccurrenceId,
evidenceFingerprint: candidate.evidenceFingerprint,
recognitionConfidence: candidate.identityConfidence,
);
await ScreenshotChannel.acknowledgeRecognitionCandidate(
candidate.id,
@@ -227,6 +234,13 @@ class RecognitionImportService {
source: candidate.source,
sourceText: candidate.sourceText,
occurredAt: ShanghaiTime.toCivil(occurredUtc),
transferDirection: candidate.transferDirection,
counterparty: candidate.counterparty,
provider: candidate.provider,
providerTransactionId: candidate.providerTransactionId,
recognitionOccurrenceId: candidate.recognitionOccurrenceId,
evidenceFingerprint: candidate.evidenceFingerprint,
recognitionConfidence: candidate.identityConfidence,
),
);
}
@@ -254,11 +268,23 @@ class RecognitionImportService {
RecognitionCandidate candidate,
Map<String, List<CategoryItem>> cache,
) async {
if (candidate.type != 'income' && candidate.type != 'expense') {
if (candidate.type != 'income' &&
candidate.type != 'expense' &&
candidate.type != 'transfer') {
throw StateError('识别结果缺少明确的收支类型');
}
final categories = cache[candidate.type] ??= await TxApi.categories(
candidate.type,
if (candidate.type == 'transfer' &&
candidate.transferDirection != 'in' &&
candidate.transferDirection != 'out') {
throw StateError('转账识别结果缺少方向');
}
final categoryType = candidate.type == 'transfer'
? candidate.transferDirection == 'in'
? 'income'
: 'expense'
: candidate.type;
final categories = cache[categoryType] ??= await TxApi.categories(
categoryType,
);
if (categories.isEmpty) throw StateError('当前账本没有可用分类');
return categories
@@ -315,16 +341,16 @@ class RecognitionImportService {
width: 42,
height: 42,
decoration: BoxDecoration(
color: candidate.type == 'income'
color: candidate.isIncome
? palette.primaryBackground
: palette.expenseBackground,
borderRadius: BorderRadius.circular(13),
),
child: Icon(
candidate.type == 'income'
candidate.isIncome
? Icons.south_west_rounded
: Icons.north_east_rounded,
color: candidate.type == 'income'
color: candidate.isIncome
? AppTheme.primary
: AppTheme.red,
),
@@ -343,7 +369,7 @@ class RecognitionImportService {
),
const SizedBox(height: 4),
Text(
'${candidate.appName} · ${candidate.type == 'income' ? '收入' : '支出'}',
'${candidate.appName} · ${candidate.typeLabel}',
style: TextStyle(
color: palette.text2,
fontSize: 12,
@@ -353,9 +379,9 @@ class RecognitionImportService {
),
),
Text(
'${candidate.type == 'income' ? '+' : '-'}¥${candidate.amount.toStringAsFixed(2)}',
'${candidate.isIncome ? '+' : '-'}¥${candidate.amount.toStringAsFixed(2)}',
style: TextStyle(
color: candidate.type == 'income'
color: candidate.isIncome
? AppTheme.primary
: AppTheme.red,
fontSize: 18,
@@ -136,7 +136,11 @@ class RecognitionCandidate {
final String id, clientRequestId, state, confidence, type, source, appName;
final double amount;
final String? merchant, orderId, sourceText, note;
final String? transferDirection, counterparty;
final String? provider, providerTransactionId, recognitionOccurrenceId;
final String? evidenceFingerprint;
final String recognitionKind, amountSource;
final String identityConfidence;
final String? categoryHint, resultFingerprint;
final String? batchId, aiAction, aiReason;
final int? categoryId;
@@ -155,23 +159,39 @@ class RecognitionCandidate {
orderId = value['orderId'] as String?,
sourceText = value['sourceText'] as String?,
note = value['note'] as String?,
transferDirection = value['transferDirection']?.toString(),
counterparty = value['counterparty']?.toString(),
provider = value['provider']?.toString(),
providerTransactionId = value['providerTransactionId']?.toString(),
recognitionOccurrenceId = value['recognitionOccurrenceId']?.toString(),
evidenceFingerprint = value['evidenceFingerprint']?.toString(),
recognitionKind = value['recognitionKind']?.toString() ?? 'payment',
categoryHint = value['categoryHint']?.toString(),
categoryId = (value['categoryId'] as num?)?.toInt(),
amountSource = value['amountSource']?.toString() ?? 'result',
resultFingerprint = value['resultFingerprint']?.toString(),
identityConfidence = value['identityConfidence']?.toString() ?? 'strong',
batchId = value['batchId']?.toString(),
aiAction = value['aiAction']?.toString(),
aiReason = value['aiReason']?.toString(),
occurredAtEpochMs = (value['occurredAtEpochMs'] as num).toInt();
bool get canAutoImport => state == 'auto_ready' && confidence == 'auto';
bool get isIncome =>
type == 'income' || type == 'transfer' && transferDirection == 'in';
String get typeLabel => type == 'transfer'
? transferDirection == 'in'
? '转入'
: '转出'
: isIncome
? '收入'
: '支出';
}
class RecognitionBatchItem {
final String candidateId, action, reason, state, type;
final double amount;
final String? merchant;
final String? merchant, transferDirection;
final bool canRestore;
RecognitionBatchItem.fromJson(Map<String, dynamic> value)
@@ -182,7 +202,11 @@ class RecognitionBatchItem {
type = value['type']?.toString() ?? 'expense',
amount = (value['amount'] as num?)?.toDouble() ?? 0,
merchant = value['merchant']?.toString(),
transferDirection = value['transferDirection']?.toString(),
canRestore = value['canRestore'] as bool? ?? false;
bool get isIncome =>
type == 'income' || type == 'transfer' && transferDirection == 'in';
}
class RecognitionBatch {
@@ -438,6 +462,26 @@ class ScreenshotChannel {
}
}
static Future<List<RecognitionCandidate>> listRecognitionCandidates() async {
try {
final values =
await _channel.invokeMethod<List<Object?>>(
'listRecognitionCandidates',
) ??
const [];
return values
.whereType<String>()
.map(
(value) => RecognitionCandidate.fromJson(
jsonDecode(value) as Map<String, dynamic>,
),
)
.toList(growable: false);
} on MissingPluginException {
return const [];
}
}
static Future<bool> acknowledgeRecognitionCandidate(
String id,
String state, {