420 lines
15 KiB
Dart
420 lines
15 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:miaoji_zhang/features/home/pages/transaction_edit_page.dart';
|
|
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
|
import 'package:miaoji_zhang/shared/api/business_api.dart';
|
|
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
|
|
import 'package:miaoji_zhang/shared/services/local_database.dart';
|
|
import 'package:miaoji_zhang/shared/services/screenshot_channel.dart';
|
|
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
|
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
|
import 'package:miaoji_zhang/shared/services/transaction_events.dart';
|
|
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
|
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
class RecognitionImportService {
|
|
RecognitionImportService._();
|
|
|
|
static Future<void>? _activeImport;
|
|
static bool _rerunRequested = false;
|
|
|
|
static Future<void> configureNativeContext() async {
|
|
final session = SessionStore.instance;
|
|
final preferences = await SharedPreferences.getInstance();
|
|
if (preferences.getBool('ai_batch_consent_v2') != true) {
|
|
await ScreenshotChannel.setRecognitionToggle('ai_screenshot', false);
|
|
}
|
|
await ScreenshotChannel.configureRecognitionContext(
|
|
hasAccount: session.isAccount,
|
|
aiAllowed: session.aiEnabled,
|
|
baseUrl: ApiClient.baseUrl,
|
|
token: session.isAccount ? await ApiClient.instance.readToken() : null,
|
|
);
|
|
}
|
|
|
|
static Future<void> importAutomatic() {
|
|
if (!SessionStore.instance.hasSession) return Future.value();
|
|
final active = _activeImport;
|
|
if (active != null) {
|
|
_rerunRequested = true;
|
|
return active;
|
|
}
|
|
final operation = _runAutomaticImports();
|
|
_activeImport = operation;
|
|
return operation.whenComplete(() {
|
|
if (identical(_activeImport, operation)) _activeImport = null;
|
|
});
|
|
}
|
|
|
|
static Future<void> _runAutomaticImports() async {
|
|
try {
|
|
do {
|
|
_rerunRequested = false;
|
|
await CurrentLedgerStore.instance.ensureLoaded();
|
|
final candidates = await ScreenshotChannel.drainRecognitionCandidates();
|
|
final automatic = candidates
|
|
.where((item) => item.canAutoImport)
|
|
.toList();
|
|
final batches = <String, List<RecognitionCandidate>>{};
|
|
for (final candidate in automatic) {
|
|
final batchId = candidate.batchId;
|
|
if (batchId == null || batchId.isEmpty) continue;
|
|
batches.putIfAbsent(batchId, () => []).add(candidate);
|
|
}
|
|
for (final entry in batches.entries) {
|
|
await _importBatch(entry.key, entry.value);
|
|
}
|
|
for (final candidate in automatic.where(
|
|
(item) => item.batchId == null || item.batchId!.isEmpty,
|
|
)) {
|
|
await _import(candidate);
|
|
}
|
|
} while (_rerunRequested);
|
|
} finally {
|
|
_rerunRequested = false;
|
|
}
|
|
}
|
|
|
|
static Future<void> handleAction(
|
|
BuildContext context,
|
|
Map<String, dynamic> action,
|
|
) async {
|
|
final kind = action['action']?.toString();
|
|
if (kind == 'ready') {
|
|
await importAutomatic();
|
|
return;
|
|
}
|
|
if (kind == 'recognition_batch_review') {
|
|
await importAutomatic();
|
|
if (!context.mounted) return;
|
|
final batchId = action['batchId']?.toString();
|
|
context.push(
|
|
Uri(
|
|
path: '/recognition-batches',
|
|
queryParameters: batchId == null || batchId.isEmpty
|
|
? null
|
|
: {'batchId': batchId},
|
|
).toString(),
|
|
);
|
|
return;
|
|
}
|
|
if (kind == 'recognition_undo') {
|
|
final transactionId = (action['transactionId'] as num?)?.toInt();
|
|
final candidateId = action['candidateId']?.toString();
|
|
if (transactionId == null || candidateId == null) return;
|
|
try {
|
|
await TxApi.delete(transactionId);
|
|
await ScreenshotChannel.acknowledgeRecognitionCandidate(
|
|
candidateId,
|
|
'undone',
|
|
);
|
|
TransactionEvents.notifyChanged();
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('已撤销这笔智能识别账单')));
|
|
}
|
|
} catch (error) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (kind == 'recognition_edit') {
|
|
final transactionId = (action['transactionId'] as num?)?.toInt();
|
|
final cached = transactionId == null
|
|
? null
|
|
: LocalDatabase.instance.transaction(transactionId);
|
|
if (!context.mounted) return;
|
|
if (cached == null) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('这笔账单尚未同步到本机,请稍后重试')));
|
|
return;
|
|
}
|
|
final updated = await Navigator.of(context).push<TxItem>(
|
|
MaterialPageRoute(
|
|
builder: (_) =>
|
|
TransactionEditPage(transaction: TxItem.fromJson(cached)),
|
|
),
|
|
);
|
|
if (updated != null) TransactionEvents.notifyChanged();
|
|
return;
|
|
}
|
|
if (kind != 'recognition_confirm' || !context.mounted) return;
|
|
|
|
await CurrentLedgerStore.instance.ensureLoaded();
|
|
final candidates = await ScreenshotChannel.drainRecognitionCandidates();
|
|
if (!context.mounted) return;
|
|
final requestedId = action['candidateId']?.toString();
|
|
final candidate = candidates.where((item) {
|
|
return item.state == 'pending_confirm' &&
|
|
(requestedId == null || item.id == requestedId);
|
|
}).firstOrNull;
|
|
if (candidate == null) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('这条识别结果已处理或已过期')));
|
|
return;
|
|
}
|
|
final confirmed = await _showConfirmation(context, candidate);
|
|
if (confirmed != true) return;
|
|
try {
|
|
await _import(candidate);
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('识别结果已入账')));
|
|
}
|
|
} catch (error) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
|
|
}
|
|
}
|
|
}
|
|
|
|
static Future<TxItem> _import(RecognitionCandidate candidate) async {
|
|
final category = await _resolveCategory(candidate, {});
|
|
final occurredUtc = validOccurredAtUtc(candidate.occurredAtEpochMs);
|
|
final transaction = await TxApi.create(
|
|
categoryId: category.id,
|
|
type: candidate.type,
|
|
amount: candidate.amount,
|
|
note: candidate.merchant?.trim().isNotEmpty == true
|
|
? candidate.merchant!.trim()
|
|
: (candidate.note ?? '智能识别'),
|
|
paymentMethod: candidate.appName,
|
|
source: candidate.source,
|
|
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,
|
|
'imported',
|
|
transactionId: transaction.id,
|
|
);
|
|
TransactionEvents.notifyChanged();
|
|
return transaction;
|
|
}
|
|
|
|
static Future<void> _importBatch(
|
|
String batchId,
|
|
List<RecognitionCandidate> candidates,
|
|
) async {
|
|
final categoryCache = <String, List<CategoryItem>>{};
|
|
final drafts = <RecognitionBatchDraft>[];
|
|
for (final candidate in candidates) {
|
|
final category = await _resolveCategory(candidate, categoryCache);
|
|
final occurredUtc = validOccurredAtUtc(candidate.occurredAtEpochMs);
|
|
drafts.add(
|
|
RecognitionBatchDraft(
|
|
candidateId: candidate.id,
|
|
clientRequestId: candidate.clientRequestId,
|
|
categoryId: category.id,
|
|
type: candidate.type,
|
|
amount: candidate.amount,
|
|
note: candidate.merchant?.trim().isNotEmpty == true
|
|
? candidate.merchant!.trim()
|
|
: (candidate.note ?? '智能识别'),
|
|
paymentMethod: candidate.appName,
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
final transactions = await TxApi.createRecognitionBatch(batchId, drafts);
|
|
if (transactions.length != drafts.length) {
|
|
throw StateError('批次入账结果不完整,请稍后重试');
|
|
}
|
|
for (final candidate in candidates) {
|
|
final transaction = transactions[candidate.id];
|
|
if (transaction == null) {
|
|
throw StateError('批次入账缺少候选 ${candidate.id}');
|
|
}
|
|
final acknowledged =
|
|
await ScreenshotChannel.acknowledgeRecognitionCandidate(
|
|
candidate.id,
|
|
'imported',
|
|
transactionId: transaction.id,
|
|
);
|
|
if (!acknowledged) throw StateError('批次状态确认失败,请稍后重试');
|
|
}
|
|
TransactionEvents.notifyChanged();
|
|
}
|
|
|
|
static Future<CategoryItem> _resolveCategory(
|
|
RecognitionCandidate candidate,
|
|
Map<String, List<CategoryItem>> cache,
|
|
) async {
|
|
if (candidate.type != 'income' &&
|
|
candidate.type != 'expense' &&
|
|
candidate.type != 'transfer') {
|
|
throw StateError('识别结果缺少明确的收支类型');
|
|
}
|
|
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
|
|
.where((item) => item.id == candidate.categoryId)
|
|
.firstOrNull ??
|
|
categories
|
|
.where((item) => item.name == candidate.categoryHint)
|
|
.firstOrNull ??
|
|
categories.where((item) => item.name == '其他').firstOrNull ??
|
|
categories.first;
|
|
}
|
|
|
|
static DateTime validOccurredAtUtc(int epochMs, {DateTime? now}) {
|
|
final current = (now ?? DateTime.now()).toUtc();
|
|
final parsed = DateTime.fromMillisecondsSinceEpoch(epochMs, isUtc: true);
|
|
final oldest = current.subtract(const Duration(days: 1));
|
|
final newest = current.add(const Duration(minutes: 5));
|
|
return parsed.isBefore(oldest) || parsed.isAfter(newest) ? current : parsed;
|
|
}
|
|
|
|
static Future<bool?> _showConfirmation(
|
|
BuildContext context,
|
|
RecognitionCandidate candidate,
|
|
) {
|
|
final palette = context.jz;
|
|
return showModalBottomSheet<bool>(
|
|
context: context,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (sheetContext) => SafeArea(
|
|
child: Container(
|
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
|
decoration: BoxDecoration(
|
|
color: palette.card,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const JzSheetHeader(
|
|
title: '确认识别结果',
|
|
subtitle: '信息来自本机解析,确认后才会写入账本',
|
|
),
|
|
const SizedBox(height: 14),
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: palette.background,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: palette.line),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 42,
|
|
height: 42,
|
|
decoration: BoxDecoration(
|
|
color: candidate.isIncome
|
|
? palette.primaryBackground
|
|
: palette.expenseBackground,
|
|
borderRadius: BorderRadius.circular(13),
|
|
),
|
|
child: Icon(
|
|
candidate.isIncome
|
|
? Icons.south_west_rounded
|
|
: Icons.north_east_rounded,
|
|
color: candidate.isIncome
|
|
? AppTheme.primary
|
|
: AppTheme.red,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
candidate.merchant ?? candidate.appName,
|
|
style: TextStyle(
|
|
color: palette.text,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'${candidate.appName} · ${candidate.typeLabel}',
|
|
style: TextStyle(
|
|
color: palette.text2,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Text(
|
|
'${candidate.isIncome ? '+' : '-'}¥${candidate.amount.toStringAsFixed(2)}',
|
|
style: TextStyle(
|
|
color: candidate.isIncome
|
|
? AppTheme.primary
|
|
: AppTheme.red,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: JzActionButton(
|
|
label: '稍后处理',
|
|
secondary: true,
|
|
onPressed: () => Navigator.pop(sheetContext, false),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: JzActionButton(
|
|
label: '确认入账',
|
|
onPressed: () => Navigator.pop(sheetContext, true),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|