first commit

This commit is contained in:
2026-09-21 17:37:53 +08:00
commit 67d4dd5b7c
141 changed files with 8558 additions and 0 deletions
+208
View File
@@ -0,0 +1,208 @@
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);
},
),
],
),
),
);
}
}