Add transfer tracking and secure admin access
This commit is contained in:
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user