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
+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, {