Files
Aq-Accounting-Flutter/lib/pages/add/widgets/ocr_tab.dart
T
2026-09-24 14:24:34 +08:00

210 lines
7.9 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 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import '../../../core/network/api_exception.dart';
import '../../../providers/ocr_provider.dart';
import '../../../utils/toast.dart';
import 'ocr_result_card.dart';
import '../../../widgets/cute_icon.dart';
/// OCR 识别 Tab:选图 → 识别 → 结果列表(复刻 uniApp add.vue OCR Tab)
class OcrTab extends ConsumerWidget {
const OcrTab({super.key});
Future<void> _chooseImage(BuildContext context, WidgetRef ref, ImageSource source) async {
final picker = ImagePicker();
final xfile = await picker.pickImage(source: source, imageQuality: 80, maxWidth: 1600);
if (xfile != null) {
ref.read(ocrProvider.notifier).setImagePath(xfile.path);
}
}
Future<void> _recognize(BuildContext context, WidgetRef ref) async {
try {
await ref.read(ocrProvider.notifier).recognize();
final count = ref.read(ocrProvider).results.length;
if (context.mounted) {
showToast(context, count > 0 ? '识别到 $count 条记录' : '未识别到账单信息', error: count == 0);
}
} on ApiException catch (e) {
if (context.mounted) showToast(context, e.message, error: true);
} catch (_) {
if (context.mounted) showToast(context, '识别失败,请重试', error: true);
}
}
Future<void> _saveDirectly(BuildContext context, WidgetRef ref) async {
try {
await ref.read(ocrProvider.notifier).saveDirectly();
if (context.mounted) showToast(context, '已保存识别的账单', icon: 'check');
} on ApiException catch (e) {
if (context.mounted) showToast(context, e.message, error: true);
} catch (_) {
if (context.mounted) showToast(context, '保存失败,请重试', error: true);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(ocrProvider);
final notifier = ref.read(ocrProvider.notifier);
return ListView(
padding: const EdgeInsets.all(16),
children: [
// 图片预览
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('图片预览', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037))),
const SizedBox(height: 10),
GestureDetector(
onTap: () => _showSourceSheet(context, ref),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: state.imagePath != null
? SizedBox(
height: 220,
width: double.infinity,
child: Image.file(
File(state.imagePath!),
fit: BoxFit.contain,
),
)
: Container(
height: 140,
width: double.infinity,
color: const Color(0xFFF5F5F5),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CuteIcon('camera', size: 40),
SizedBox(height: 6),
Text('点击选择图片', style: TextStyle(color: Color(0xFF8D6E63))),
],
),
),
),
),
if (state.imagePath != null)
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: notifier.clearImage,
child: const Text('清除图片', style: TextStyle(color: Color(0xFF8D6E63))),
),
),
],
),
),
const SizedBox(height: 14),
// 识别按钮
if (state.imagePath != null && state.results.isEmpty)
SizedBox(
width: double.infinity,
height: 48,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFC8956E),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
onPressed: state.recognizing ? null : () => _recognize(context, ref),
child: state.recognizing
? const SizedBox(
width: 22, height: 22,
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white),
)
: const Text('识别账单', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
),
// 识别结果
if (state.results.isNotEmpty) ...[
SizedBox(
width: double.infinity,
height: 44,
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF667eea),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
),
onPressed: state.saving ? null : () => _saveDirectly(context, ref),
icon: state.saving
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.save_outlined, size: 18),
label: const Text('直接存储', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'识别到 ${state.results.length} 条账单记录',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)),
),
),
const SizedBox(height: 10),
for (var i = 0; i < state.results.length; i++)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: OcrResultCard(
index: i,
expanded: state.expandedIndexes.contains(i),
onToggle: () => notifier.toggleExpanded(i),
),
),
],
const SizedBox(height: 90), // 底部导航栏避让
],
);
}
void _showSourceSheet(BuildContext context, WidgetRef ref) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetCtx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.camera_alt_outlined),
title: const Text('拍照'),
onTap: () {
Navigator.pop(sheetCtx);
_chooseImage(context, ref, ImageSource.camera);
},
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('从相册选择'),
onTap: () {
Navigator.pop(sheetCtx);
_chooseImage(context, ref, ImageSource.gallery);
},
),
],
),
),
);
}
}