209 lines
7.8 KiB
Dart
209 lines
7.8 KiB
Dart
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';
|
||
|
||
/// 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, '✅ 已保存识别的账单');
|
||
} 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: [
|
||
Text('📷', style: TextStyle(fontSize: 36)),
|
||
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);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|