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? _activeImport; static bool _rerunRequested = false; static Future 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 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 _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 = >{}; 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 handleAction( BuildContext context, Map 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( 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 _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 _importBatch( String batchId, List candidates, ) async { final categoryCache = >{}; final drafts = []; 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 _resolveCategory( RecognitionCandidate candidate, Map> 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 _showConfirmation( BuildContext context, RecognitionCandidate candidate, ) { final palette = context.jz; return showModalBottomSheet( 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), ), ), ], ), ], ), ), ), ); } }