Files
Aq-Accounting-Flutter/lib/providers/ocr_provider.dart
T
2026-09-30 09:52:41 +08:00

143 lines
4.6 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/bill_api.dart';
import '../api/category_api.dart';
import '../api/ocr_api.dart';
import '../core/network/api_exception.dart';
import '../models/bill_request.dart';
import '../models/ocr_result.dart';
import '../utils/date_util.dart';
import 'bill_refresh.dart';
import 'session_provider.dart';
/// OCR 识别流程状态
class OcrState {
final String? imagePath;
final List<OcrResult> results;
final Set<int> expandedIndexes;
final bool recognizing;
final bool saving;
final String? error;
const OcrState({
this.imagePath,
this.results = const [],
this.expandedIndexes = const {},
this.recognizing = false,
this.saving = false,
this.error,
});
OcrState copyWith({
Object? imagePath = _sentinel,
List<OcrResult>? results,
Set<int>? expandedIndexes,
bool? recognizing,
bool? saving,
Object? error = _sentinel,
}) {
return OcrState(
imagePath: imagePath == _sentinel ? this.imagePath : imagePath as String?,
results: results ?? this.results,
expandedIndexes: expandedIndexes ?? this.expandedIndexes,
recognizing: recognizing ?? this.recognizing,
saving: saving ?? this.saving,
error: error == _sentinel ? this.error : error as String?,
);
}
static const _sentinel = Object();
}
class OcrNotifier extends Notifier<OcrState> {
@override
OcrState build() {
// 换人(含登出)就重建 —— 上一个人拍的那张图和识别出来的条目都归他,
// 不该出现在新用户的界面上。
ref.watch(sessionUserProvider);
return const OcrState();
}
void setImagePath(String path) {
state = OcrState(imagePath: path);
}
void clearImage() {
state = const OcrState();
}
void toggleExpanded(int index) {
final indexes = {...state.expandedIndexes};
if (!indexes.remove(index)) indexes.add(index);
state = state.copyWith(expandedIndexes: indexes);
}
/// 上传识别
Future<void> recognize() async {
final path = state.imagePath;
if (path == null) throw ApiException(-1, '请先选择图片');
state = state.copyWith(recognizing: true, error: null);
try {
final results = await ref.read(ocrApiProvider).recognize(path);
// await 之后身份可能已经变了(换人/登出会让这个 Notifier 重建,
// 或者整个 provider 被回收)。这时候再写 state,上一个人的识别结果
// 就落到新会话里了 —— 而且对已销毁的 ref 写 state 会直接抛。
if (!ref.mounted) return;
state = state.copyWith(
results: results,
// 只有一条时自动展开
expandedIndexes: results.length == 1 ? {0} : <int>{},
);
} on ApiException catch (e) {
if (!ref.mounted) return;
state = state.copyWith(error: e.message);
rethrow;
} finally {
if (ref.mounted) state = state.copyWith(recognizing: false);
}
}
/// 直接批量存储:按金额正负推断类型,分类取该类型下第一项
Future<void> saveDirectly() async {
if (state.results.isEmpty) throw ApiException(-1, '没有可存储的识别结果');
state = state.copyWith(saving: true, error: null);
try {
final categoryApi = ref.read(categoryApiProvider);
final bills = <BillRequest>[];
final cache = <int, int?>{}; // type -> 默认分类id
for (final r in state.results) {
final type = r.isExpense ? 1 : 2;
if (!cache.containsKey(type)) {
final categories = await categoryApi.getCategories(type);
cache[type] = categories.isNotEmpty ? categories.first.id : null;
}
final categoryId = cache[type];
if (categoryId == null) {
throw ApiException(-1, '缺少${type == 1 ? '支出' : '收入'}分类,请先创建分类');
}
bills.add(BillRequest(
categoryId: categoryId,
amount: r.absAmount,
description: r.merchant ?? '',
billDate: parseBillDate(r.date),
type: type,
));
}
await ref.read(billApiProvider).createBillsBatch(bills);
// 同 recognize():await 之后可能已经换人了,不能再碰 state/ref
if (!ref.mounted) return;
// 让首页列表与统计页重新取数
invalidateBillData(ref);
state = const OcrState();
} on ApiException catch (e) {
if (!ref.mounted) return;
state = state.copyWith(error: e.message);
rethrow;
} finally {
if (ref.mounted) state = state.copyWith(saving: false);
}
}
}
final ocrProvider = NotifierProvider<OcrNotifier, OcrState>(OcrNotifier.new);