143 lines
4.6 KiB
Dart
143 lines
4.6 KiB
Dart
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);
|