flutter_ocr_native 0.3.8
flutter_ocr_native: ^0.3.8 copied to clipboard
Flutter plugin for on-device OCR using ML Kit (Android), Vision (iOS/macOS), WinRT (Windows), Tesseract (Linux). Document parsing, validation, and masking.
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_ocr_native/flutter_ocr_native.dart';
import 'package:image_picker/image_picker.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final deviceSecurity = await OcrIntegrity.checkDeviceSecurity();
runApp(OcrExampleApp(deviceCompromised: !deviceSecurity.secure));
}
bool get isMobile => !kIsWeb && (Platform.isAndroid || Platform.isIOS);
bool get isDesktop =>
!kIsWeb && (Platform.isWindows || Platform.isMacOS || Platform.isLinux);
class OcrExampleApp extends StatelessWidget {
final bool deviceCompromised;
const OcrExampleApp({super.key, this.deviceCompromised = false});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'OCR Example',
debugShowCheckedModeBanner: false,
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
home: deviceCompromised
? const _CompromisedDeviceScreen()
: const _MainNavigation(),
);
}
}
/// Bottom navigation for switching between KYC and Vehicle screens.
class _MainNavigation extends StatefulWidget {
const _MainNavigation();
@override
State<_MainNavigation> createState() => _MainNavigationState();
}
class _MainNavigationState extends State<_MainNavigation> {
int _currentIndex = 0;
final _pages = const [
OcrHomePage(),
VehicleSearchPage(),
SoaPage(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: _pages[_currentIndex],
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (i) => setState(() => _currentIndex = i),
destinations: const [
NavigationDestination(
icon: Icon(Icons.document_scanner_outlined),
selectedIcon: Icon(Icons.document_scanner),
label: 'KYC Documents',
),
NavigationDestination(
icon: Icon(Icons.directions_car_outlined),
selectedIcon: Icon(Icons.directions_car),
label: 'Vehicle Reports',
),
NavigationDestination(
icon: Icon(Icons.receipt_long_outlined),
selectedIcon: Icon(Icons.receipt_long),
label: 'Bank Statement',
),
],
),
);
}
}
/// Full-screen block shown when device is rooted/jailbroken.
/// The app cannot be used until the device is secured.
class _CompromisedDeviceScreen extends StatelessWidget {
const _CompromisedDeviceScreen();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.red.shade900,
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.gpp_bad, size: 80, color: Colors.white),
const SizedBox(height: 24),
const Text(
'Device Security Risk',
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
const Text(
'This device appears to be rooted or jailbroken.\n\n'
'For the security of your documents and personal data, '
'this application cannot be used on compromised devices.\n\n'
'Please use a secure, unmodified device.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white70, fontSize: 15, height: 1.5),
),
const SizedBox(height: 32),
OutlinedButton.icon(
onPressed: () => SystemNavigator.pop(),
icon: const Icon(Icons.exit_to_app, color: Colors.white),
label: const Text('Exit App',
style: TextStyle(color: Colors.white)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.white54),
),
),
],
),
),
),
),
);
}
}
class OcrHomePage extends StatefulWidget {
const OcrHomePage({super.key});
@override
State<OcrHomePage> createState() => _OcrHomePageState();
}
class _OcrHomePageState extends State<OcrHomePage> {
final _reader = OcrReader(validateDocument: true, maskAadhaar: true);
final _picker = ImagePicker();
File? _imageFile;
Uint8List? _processedBytes;
OcrResult? _result;
DocumentDetails? _details;
OcrAuditRecord? _auditRecord;
List<String> _consistencyErrors = [];
bool _loading = false;
bool _sessionLocked = false;
String? _error;
DetectedDocType _docType = DetectedDocType.unknown;
OcrWatermark get _watermark => const OcrWatermark(lines: {
'Lead ID': 'LD-20250101-001',
'Lat': '12.9716',
'Long': '77.5946',
'Agent': 'Ram Kumar',
'Date': '2025-01-15 10:30',
});
Future<void> _pickFromCamera() async {
final proceed = await OcrCaptureInstructions.showAsBottomSheet(context);
if (proceed != true) return;
final picked = await _picker.pickImage(source: ImageSource.camera);
if (picked != null) _processFile(File(picked.path));
}
Future<void> _pickFromGallery() async {
if (isMobile) {
final proceed = await OcrCaptureInstructions.showAsBottomSheet(context);
if (proceed != true) return;
final picked = await _picker.pickImage(source: ImageSource.gallery);
if (picked != null) _processFile(File(picked.path));
} else {
_pickFromFileBrowser();
}
}
Future<void> _pickFromFileBrowser() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: [
'jpg',
'jpeg',
'png',
'webp',
'bmp',
'gif',
'heic',
'tiff',
'pdf'
],
allowMultiple: false,
);
if (result != null && result.files.single.path != null) {
_processFile(File(result.files.single.path!));
}
}
bool _isPdf(File file) => file.path.toLowerCase().endsWith('.pdf');
Future<void> _processFile(File file) async {
final rawBytes = await file.readAsBytes();
if (!mounted) return;
Uint8List imageBytes;
// Handle PDF: render first page to image
if (_isPdf(file)) {
final rendered = await OcrDocumentSaver.renderPdfPage(rawBytes);
if (rendered == null) {
setState(() =>
_error = 'Failed to render PDF. Platform may not support it.');
return;
}
imageBytes = rendered;
} else {
imageBytes = rawBytes;
}
if (!mounted) return;
// Auto-correct orientation
final originalBytes = await OcrDocumentSaver.correctOrientation(imageBytes);
if (!mounted) return;
final croppedBytes = await OcrImageCropper.show(
context,
imageBytes: originalBytes,
title: 'Crop Document',
);
if (croppedBytes == null) return;
// ── Gap 3: Image quality gate ─────────────────────────────────────────
// Quality check: warn only — OcrReader auto-enhances + retries internally.
final qualityError = OcrIntegrity.checkImageQuality(croppedBytes);
if (qualityError != null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: const Text(
'âš ï¸ Low quality image — attempting OCR with enhancement…'),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 3),
));
}
final Uint8List ocrBytes = croppedBytes;
setState(() {
_imageFile = file;
_processedBytes = ocrBytes;
_result = null;
_details = null;
_auditRecord = null;
_consistencyErrors = [];
_error = null;
_loading = true;
_docType = DetectedDocType.unknown;
});
try {
// OcrReader._processWithFallback already enhanced + retried internally.
final ocrResult = await _reader.readFromBytes(ocrBytes);
List<double> confidencesOf(OcrResult r) => r.blocks
.expand((b) => b.lines)
.map((l) => l.confidence ?? 0.0)
.toList();
// Confidence warning only — never block
final confidenceWarning = OcrIntegrity.checkConfidence(
confidencesOf(ocrResult),
threshold: OcrIntegrity.minConfidenceAfterEnhance,
);
// Use unified DocumentDetails — handles all doc types + face extraction
final details = await DocumentDetails.fromResult(
ocrResult,
imageBytes: ocrBytes,
);
// ── Gap 1 & 6: Create audit record with unique scanId ─────────────────
final auditRecord = OcrAuditRecord.create(
details,
ocrBytes,
// agentId: 'Raja',
sessionId: DateTime.now().microsecondsSinceEpoch.toString(),
);
// ── Gap 4: Consistency + expiry checks ────────────────────────────────
final consistencyErrors = [
...OcrIntegrity.consistencyErrors(details),
if (confidenceWarning != null) confidenceWarning,
];
// ── Gap 5: Persist audit record ───────────────────────────────────────
await OcrIntegrity.persistAuditRecord(auditRecord);
setState(() {
_result = ocrResult;
_details = details;
_docType = details.docType;
_auditRecord = auditRecord;
_consistencyErrors = consistencyErrors;
});
if (mounted) _showValidationToast(details);
} on EmptyImageException {
setState(() => _error = 'No text detected in the image');
} on HandwrittenTextException {
setState(() => _error =
'Handwritten text detected. Only printed documents are accepted');
} on LowQualityImageException catch (e) {
setState(() => _error = e.message);
} catch (e) {
setState(() => _error = e.toString());
} finally {
setState(() => _loading = false);
}
}
void _showValidationToast(DocumentDetails details) {
final String message;
final Color bgColor;
if (details.docType == DetectedDocType.unknown) {
message = 'âš ï¸ Document type not recognized';
bgColor = Colors.orange;
} else if (details.isValid) {
message = '✅ Valid ${_docTypeLabel(details.docType)}';
if (details.documentNumber != null) {
bgColor = Colors.green;
} else {
bgColor = Colors.green;
}
} else if (details.documentNumber != null) {
message = '⌠${details.validationError}';
bgColor = Colors.red;
} else {
message = 'âš ï¸ No ${_docTypeLabel(details.docType)} number found';
bgColor = Colors.orange;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: bgColor,
duration: const Duration(seconds: 3)),
);
}
Future<void> _saveImage() async {
if (_result == null || _processedBytes == null || _auditRecord == null)
return;
// ── Re-verify before save ───────────────────────────────────────────────
final verification =
OcrIntegrity.verify(_details!, _processedBytes!, _auditRecord!);
if (!verification.passed) {
await _handleTamper(verification);
return;
}
final file = await OcrDocumentSaver.downloadBytes(
imageBytes: _processedBytes!,
watermark: _watermark,
);
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Saved to ${file.path}')));
}
}
/// Full tamper response:
/// 1. Log tamper event to disk
/// 2. Wipe all sensitive state from memory
/// 3. Lock the session (no further actions allowed)
/// 4. Show a blocking, non-dismissible alert dialog
Future<void> _handleTamper(OcrVerificationResult verification) async {
// 1. Log
final event = TamperEvent.fromVerification(verification, _auditRecord!);
await OcrIntegrity.persistTamperEvent(event);
// TODO(production): POST event.toJson() to your security endpoint here
// 2. Wipe sensitive state
setState(() {
_processedBytes = null;
_result = null;
_details = null;
_auditRecord = null;
_consistencyErrors = [];
_imageFile = null;
_sessionLocked = true;
});
// 3 & 4. Show blocking dialog — user must acknowledge, cannot dismiss
if (!mounted) return;
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
icon: const Icon(Icons.gpp_bad, color: Colors.red, size: 48),
title: const Text('Security Alert'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
verification.reason,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
const Text(
'All captured data has been cleared.\n'
'This incident has been logged.\n\n'
'Please restart the scan with the original document.',
),
const SizedBox(height: 12),
Text(
'Scan ID: ${event.scanId}',
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
),
],
),
actions: [
FilledButton(
onPressed: () => Navigator.of(context).pop(),
style: FilledButton.styleFrom(backgroundColor: Colors.red),
child: const Text('I Understand'),
),
],
),
);
}
void _viewImage() {
if (_result == null || _processedBytes == null) return;
OcrDocumentViewer.show(
context,
result: _result!,
originalBytes: _processedBytes!,
title: _docTypeLabel(_docType),
watermark: _watermark,
onSave: (bytes) async {
final file = await OcrDocumentSaver.downloadBytes(imageBytes: bytes);
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Saved to ${file.path}')));
}
},
);
}
@override
void dispose() {
_reader.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final hasResult = _details != null;
return Scaffold(
appBar: AppBar(
title: const Text('OCR Reader'),
actions: [
if (hasResult && !_loading && !_sessionLocked)
IconButton(
onPressed: _viewImage, icon: const Icon(Icons.fullscreen)),
if (hasResult && !_loading && !_sessionLocked)
IconButton(onPressed: _saveImage, icon: const Icon(Icons.download)),
],
),
body: _sessionLocked
? _buildLockedScreen()
: ListView(
padding: const EdgeInsets.all(16),
children: [
// Action buttons
_buildActionButtons(),
const SizedBox(height: 16),
// Image preview
if (hasResult && _result!.hasAadhaar)
GestureDetector(
onTap: _viewImage,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(_result!.maskedImageBytes!,
height: 250,
width: double.infinity,
fit: BoxFit.contain),
),
)
else if (_processedBytes != null)
GestureDetector(
onTap: hasResult ? _viewImage : null,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(_processedBytes!,
height: 250,
width: double.infinity,
fit: BoxFit.contain),
),
),
if (hasResult && !_loading) ...[
const SizedBox(height: 12),
if (_docType != DetectedDocType.unknown)
Chip(
avatar: Icon(_docTypeIcon(_docType), size: 18),
label: Text('Detected: ${_docTypeLabel(_docType)}'),
backgroundColor: Colors.indigo.shade50,
),
const SizedBox(height: 8),
Row(children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _viewImage,
icon: const Icon(Icons.visibility),
label: const Text('View'))),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _saveImage,
icon: const Icon(Icons.save_alt),
label: const Text('Download'))),
]),
],
const SizedBox(height: 16),
if (_loading) const Center(child: CircularProgressIndicator()),
if (_error != null)
Card(
color: Theme.of(context).colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(_error!,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onErrorContainer))),
),
// Unified results display
if (hasResult) ...[
// Face photo
if (_details!.hasPhoto) _buildPhotoCard(),
if (_details!.hasPhoto) const SizedBox(height: 12),
// Details card
_buildDetailsCard(),
const SizedBox(height: 12),
// Validation card
_buildValidationCard(),
const SizedBox(height: 12),
// Integrity card
_buildIntegrityCard(),
const SizedBox(height: 12),
// Raw text
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text('Raw OCR Text',
style: Theme.of(context)
.textTheme
.titleMedium)),
IconButton(
icon: const Icon(Icons.copy, size: 18),
tooltip: 'Copy raw text',
onPressed: () {
Clipboard.setData(
ClipboardData(text: _result!.text));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Raw OCR text copied!'),
duration: Duration(seconds: 2)),
);
},
),
],
),
const Divider(),
SelectableText(
_result!.text.isEmpty
? 'No text found'
: _result!.text,
style: Theme.of(context).textTheme.bodyMedium),
]),
),
),
],
],
),
);
}
Widget _buildLockedScreen() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.lock, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text('Session Locked',
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(color: Colors.red)),
const SizedBox(height: 8),
const Text(
'A tamper attempt was detected and logged.\n'
'Start a new scan to continue.',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () => setState(() {
_sessionLocked = false;
_error = null;
_docType = DetectedDocType.unknown;
}),
icon: const Icon(Icons.refresh),
label: const Text('Start New Scan'),
style: FilledButton.styleFrom(backgroundColor: Colors.red),
),
],
),
),
);
}
Widget _buildPhotoCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(_details!.photoBytes!,
width: 80, height: 100, fit: BoxFit.cover),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Photo Extracted',
style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
Text(
'Face detected from ${_docTypeLabel(_docType)} document',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
),
);
}
Widget _buildDetailsCard() {
final fields = _details!.toDisplayMap(maskAadhaar: true);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('${_docTypeLabel(_docType)} Details',
style: Theme.of(context).textTheme.titleMedium),
if (kDebugMode)
Padding(
padding: const EdgeInsets.only(top: 4, bottom: 2),
child: Text(
'Tap any field to edit — triggers tamper detection on download',
style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
),
),
const Divider(),
if (fields.isEmpty)
const Text('No details detected')
else
...fields.entries.map((e) => _detailRow(e.key, e.value)),
]),
),
);
}
Widget _buildValidationCard() {
final details = _details!;
if (details.isValid) {
final label = details.documentNumber != null
? '${_docTypeLabel(_docType)} Valid: ${details.documentNumber}'
: '${_docTypeLabel(_docType)} Valid';
return _validationChip(label, true);
}
if (details.documentNumber != null && details.validationError != null) {
return _validationChip(details.validationError!, false);
}
return _validationChip(
details.validationError ?? 'No document number found', false);
}
Widget _validationChip(String text, bool valid) {
return Card(
color: valid ? Colors.green.shade50 : Colors.red.shade50,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(children: [
Icon(valid ? Icons.check_circle : Icons.cancel,
color: valid ? Colors.green : Colors.red, size: 20),
const SizedBox(width: 8),
Expanded(child: Text(text)),
]),
),
);
}
Widget _detailRow(String label, String value) {
if (kDebugMode && _details != null) {
return _EditableDetailRow(
label: label,
value: value,
onChanged: (newValue) {
setState(() {
_details = _details!.copyWithField(label, newValue);
});
},
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(
width: 120,
child: Text(label,
style: TextStyle(
color: Colors.grey.shade700,
fontWeight: FontWeight.w600,
fontSize: 13))),
const Text(': '),
Expanded(
child: SelectableText(value,
style: const TextStyle(fontWeight: FontWeight.w500))),
]),
);
}
Widget _buildIntegrityCard() {
final record = _auditRecord;
if (record == null) return const SizedBox.shrink();
final hasConsistencyIssues = _consistencyErrors.isNotEmpty;
return Card(
color: hasConsistencyIssues ? Colors.orange.shade50 : Colors.blue.shade50,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(
hasConsistencyIssues
? Icons.warning_amber
: Icons.verified_user,
color: hasConsistencyIssues ? Colors.orange : Colors.blue,
size: 20,
),
const SizedBox(width: 8),
Text(
hasConsistencyIssues
? 'Integrity: Warnings'
: 'Integrity: Verified',
style: Theme.of(context).textTheme.titleSmall,
),
]),
const Divider(),
_detailRow('Scan ID', record.scanId),
_detailRow('Data Hash', '${record.dataHash.substring(0, 16)}…'),
_detailRow('Image Hash', '${record.imageHash.substring(0, 16)}…'),
_detailRow('Captured At', record.capturedAt.toLocal().toString()),
if (record.agentId != null) _detailRow('Agent', record.agentId!),
if (hasConsistencyIssues) ...[
const SizedBox(height: 8),
Text('Consistency Issues:',
style: TextStyle(
color: Colors.orange.shade800,
fontWeight: FontWeight.w600,
fontSize: 13)),
..._consistencyErrors.map(
(e) => Padding(
padding: const EdgeInsets.only(top: 2),
child: Text('• $e',
style: TextStyle(
color: Colors.orange.shade900, fontSize: 13)),
),
),
],
],
),
),
);
}
Widget _buildActionButtons() {
if (isMobile) {
return Column(children: [
Row(children: [
Expanded(
child: FilledButton.icon(
onPressed: _loading ? null : _pickFromCamera,
icon: const Icon(Icons.camera_alt),
label: const Text('Camera'))),
const SizedBox(width: 12),
Expanded(
child: FilledButton.tonalIcon(
onPressed: _loading ? null : _pickFromGallery,
icon: const Icon(Icons.photo_library),
label: const Text('Gallery'))),
]),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const _GenericOcrPage())),
icon: const Icon(Icons.text_fields),
label: const Text('Generic Label-Value OCR'),
),
]);
}
return Column(children: [
Row(children: [
Expanded(
child: FilledButton.icon(
onPressed: _loading ? null : _pickFromFileBrowser,
icon: const Icon(Icons.folder_open),
label: const Text('Open Image / PDF'))),
if (_imageFile != null) ...[
const SizedBox(width: 12),
Text(_imageFile!.path.split(Platform.pathSeparator).last,
style: Theme.of(context).textTheme.bodySmall)
],
]),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const _GenericOcrPage())),
icon: const Icon(Icons.text_fields),
label: const Text('Generic Label-Value OCR'),
),
),
]);
}
String _docTypeLabel(DetectedDocType type) =>
DocumentTypeDetector.label(type);
IconData _docTypeIcon(DetectedDocType type) =>
DocumentTypeDetector.icon(type);
}
/// Editable detail row — debug only.
/// Tapping the value opens an inline text field.
/// Any edit mutates _details via copyWithField, breaking the data hash.
class _EditableDetailRow extends StatefulWidget {
final String label;
final String value;
final ValueChanged<String> onChanged;
const _EditableDetailRow({
required this.label,
required this.value,
required this.onChanged,
});
@override
State<_EditableDetailRow> createState() => _EditableDetailRowState();
}
class _EditableDetailRowState extends State<_EditableDetailRow> {
bool _editing = false;
late final TextEditingController _ctrl;
@override
void initState() {
super.initState();
_ctrl = TextEditingController(text: widget.value);
}
@override
void didUpdateWidget(_EditableDetailRow old) {
super.didUpdateWidget(old);
if (!_editing && old.value != widget.value) {
_ctrl.text = widget.value;
}
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
SizedBox(
width: 120,
child: Text(widget.label,
style: TextStyle(
color: Colors.grey.shade700,
fontWeight: FontWeight.w600,
fontSize: 13)),
),
const Text(': '),
Expanded(
child: _editing
? TextField(
controller: _ctrl,
autofocus: true,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
color: Colors.deepOrange),
decoration: const InputDecoration(
isDense: true,
contentPadding:
EdgeInsets.symmetric(horizontal: 6, vertical: 4),
border: OutlineInputBorder(),
),
onSubmitted: (v) {
setState(() => _editing = false);
if (v != widget.value) widget.onChanged(v);
},
onTapOutside: (_) {
setState(() => _editing = false);
if (_ctrl.text != widget.value) {
widget.onChanged(_ctrl.text);
}
},
)
: GestureDetector(
onTap: () => setState(() => _editing = true),
child: Row(children: [
Expanded(
child: Text(widget.value,
style: const TextStyle(fontWeight: FontWeight.w500)),
),
Icon(Icons.edit, size: 13, color: Colors.grey.shade400),
]),
),
),
]),
);
}
}
// ── Generic Label-Value OCR Page ─────────────────────────────────────────────
/// Uploads any PDF or image, runs OCR, parses every `Label: Value` /
/// `Label - Value` / `Label = Value` line, and shows them as editable fields.
class _GenericOcrPage extends StatefulWidget {
const _GenericOcrPage();
@override
State<_GenericOcrPage> createState() => _GenericOcrPageState();
}
class _GenericOcrPageState extends State<_GenericOcrPage> {
final _reader = OcrReader(validateDocument: false);
final _picker = ImagePicker();
bool _loading = false;
String? _error;
String _rawText = '';
/// Parsed label → TextEditingController
final Map<String, TextEditingController> _fields = {};
/// Lines that had no separator — shown as plain text below the fields
final List<String> _unparsed = [];
@override
void dispose() {
for (final c in _fields.values) c.dispose();
_reader.dispose();
super.dispose();
}
Future<void> _pick() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'pdf'],
allowMultiple: false,
);
if (result == null || result.files.single.path == null) return;
_process(File(result.files.single.path!));
}
Future<void> _pickMobile(ImageSource src) async {
final picked = await _picker.pickImage(source: src);
if (picked != null) _process(File(picked.path));
}
Future<void> _process(File file) async {
setState(() {
_loading = true;
_error = null;
_rawText = '';
for (final c in _fields.values) {
c.dispose();
}
_fields.clear();
_unparsed.clear();
});
try {
final rawBytes = await file.readAsBytes();
final Uint8List imageBytes;
if (file.path.toLowerCase().endsWith('.pdf')) {
final rendered = await OcrDocumentSaver.renderPdfPage(rawBytes);
if (rendered == null) throw Exception('PDF render failed');
imageBytes = rendered;
} else {
imageBytes = rawBytes;
}
final corrected = await OcrDocumentSaver.correctOrientation(imageBytes);
// Image quality + blur gate — enhance then re-check
final qualityError = OcrIntegrity.checkImageQuality(corrected);
if (qualityError != null) throw LowQualityImageException(qualityError);
Uint8List ocrReady = corrected;
if (await OcrIntegrity.checkBlurriness(corrected) != null) {
ocrReady = await OcrDocumentSaver.enhanceForOcr(corrected);
final stillBlurry = await OcrIntegrity.checkBlurriness(ocrReady);
if (stillBlurry != null) throw LowQualityImageException(stillBlurry);
}
final ocrResult = await _reader.readFromBytes(ocrReady);
final text = ocrResult.text;
// Parse label-value pairs — separators: : - =
final sep = RegExp(r'^(.{2,50}?)\s*[:\-=]\s*(.+)$');
final newFields = <String, TextEditingController>{};
final unparsed = <String>[];
for (final line in text.split('\n')) {
final t = line.trim();
if (t.isEmpty) continue;
final m = sep.firstMatch(t);
if (m != null) {
final label = m.group(1)!.trim();
final value = m.group(2)!.trim();
// Skip lines where the "label" part looks like a number or is too short
if (label.length < 2 || RegExp(r'^\d+$').hasMatch(label)) {
unparsed.add(t);
} else {
// Deduplicate: append index if label already exists
var key = label;
var idx = 2;
while (newFields.containsKey(key)) key = '$label ($idx++)';
newFields[key] = TextEditingController(text: value);
}
} else {
unparsed.add(t);
}
}
setState(() {
_rawText = text;
_fields.addAll(newFields);
_unparsed.addAll(unparsed);
});
} on LowQualityImageException catch (e) {
setState(() => _error = e.message);
} catch (e) {
setState(() => _error = e.toString());
} finally {
setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Generic Label-Value OCR')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// Pick buttons
if (isMobile)
Row(children: [
Expanded(
child: FilledButton.icon(
onPressed: _loading
? null
: () => _pickMobile(ImageSource.camera),
icon: const Icon(Icons.camera_alt),
label: const Text('Camera'))),
const SizedBox(width: 12),
Expanded(
child: FilledButton.tonalIcon(
onPressed: _loading
? null
: () => _pickMobile(ImageSource.gallery),
icon: const Icon(Icons.photo_library),
label: const Text('Gallery'))),
])
else
FilledButton.icon(
onPressed: _loading ? null : _pick,
icon: const Icon(Icons.folder_open),
label: const Text('Open Image / PDF'),
),
const SizedBox(height: 16),
if (_loading) const Center(child: CircularProgressIndicator()),
if (_error != null)
Card(
color: Theme.of(context).colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(_error!,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer)),
),
),
// Parsed fields
if (_fields.isNotEmpty) ...[
Text('Extracted Fields (${_fields.length})',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
..._fields.entries.map((e) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: e.value,
decoration: InputDecoration(
labelText: e.key,
border: const OutlineInputBorder(),
isDense: true,
),
),
)),
],
// Unparsed lines
if (_unparsed.isNotEmpty) ...[
const SizedBox(height: 8),
ExpansionTile(
title: Text('Other lines (${_unparsed.length})',
style: Theme.of(context).textTheme.titleSmall),
children: _unparsed
.map((l) => ListTile(dense: true, title: Text(l)))
.toList(),
),
],
// Raw OCR text
if (_rawText.isNotEmpty) ...[
const SizedBox(height: 8),
ExpansionTile(
title: Text('Raw OCR Text',
style: Theme.of(context).textTheme.titleSmall),
children: [
Padding(
padding: const EdgeInsets.all(12),
child: SelectableText(_rawText,
style: Theme.of(context).textTheme.bodySmall),
),
],
),
],
],
),
);
}
}
// ── Vehicle Report Search Page ───────────────────────────────────────────────
/// Dedicated screen for uploading vehicle documents and extracting data.
/// Supports RC, loan documents, inspection reports, valuation reports.
// ── Vehicle Report Search Page ───────────────────────────────────────────────
/// Dedicated screen for uploading vehicle documents and extracting data.
/// Supports RC, loan documents, inspection reports, valuation reports.
/// Includes search to filter extracted fields.
class VehicleSearchPage extends StatefulWidget {
const VehicleSearchPage({super.key});
@override
State<VehicleSearchPage> createState() => _VehicleSearchPageState();
}
class _VehicleSearchPageState extends State<VehicleSearchPage> {
final _reader = OcrReader(validateDocument: false);
final _picker = ImagePicker();
final _searchController = TextEditingController();
Uint8List? _imageBytes;
DocumentDetails? _details;
bool _loading = false;
String? _error;
String _searchQuery = '';
@override
void dispose() {
_reader.dispose();
_searchController.dispose();
super.dispose();
}
Future<void> _pickFromCamera() async {
final picked = await _picker.pickImage(source: ImageSource.camera);
if (picked != null) _processFile(File(picked.path));
}
Future<void> _pickFromGallery() async {
if (isMobile) {
final picked = await _picker.pickImage(source: ImageSource.gallery);
if (picked != null) _processFile(File(picked.path));
} else {
_pickFromFileBrowser();
}
}
Future<void> _pickFromFileBrowser() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'pdf'],
allowMultiple: false,
);
if (result != null && result.files.single.path != null) {
_processFile(File(result.files.single.path!));
}
}
Future<void> _processFile(File file) async {
setState(() {
_loading = true;
_error = null;
_details = null;
_imageBytes = null;
_searchQuery = '';
_searchController.clear();
});
try {
final rawBytes = await file.readAsBytes();
Uint8List imageBytes;
if (file.path.toLowerCase().endsWith('.pdf')) {
final rendered = await OcrDocumentSaver.renderPdfPage(rawBytes);
if (rendered == null) throw Exception('PDF render failed');
imageBytes = rendered;
} else {
imageBytes = rawBytes;
}
final corrected = await OcrDocumentSaver.correctOrientation(imageBytes);
if (mounted) {
final cropped = await OcrImageCropper.show(
context,
imageBytes: corrected,
title: 'Crop Vehicle Document',
);
imageBytes = cropped ?? corrected;
}
final result = await _reader.readFromBytes(
imageBytes,
docType: DetectedDocType.vehicleReport,
);
final details = DocumentDetails.fromResultSync(
result,
DetectedDocType.vehicleReport,
);
setState(() {
_imageBytes = imageBytes;
_details = details;
});
if (mounted) {
final fieldCount = details.toDisplayMap().length;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('✅ Extracted $fieldCount fields'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
setState(() => _error = e.toString());
} finally {
setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Vehicle Report Scanner'),
actions: [
if (_details != null)
IconButton(
icon: const Icon(Icons.copy),
tooltip: 'Copy all',
onPressed: _copyAllData,
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildUploadButtons(),
const SizedBox(height: 16),
if (_details != null && !_loading) ...[
_buildSearchBar(),
const SizedBox(height: 16),
],
if (_loading) const Center(child: CircularProgressIndicator()),
if (_error != null)
Card(
color: Theme.of(context).colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(_error!,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer)),
),
),
if (_imageBytes != null && !_loading) ...[
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(_imageBytes!,
height: 200, width: double.infinity, fit: BoxFit.contain),
),
const SizedBox(height: 16),
],
if (_details != null && !_loading) _buildVehicleDataCard(),
if (_details == null && !_loading && _error == null) _buildEmptyState(),
],
),
);
}
Widget _buildSearchBar() {
return TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search fields (chassis, owner, loan...)',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
)
: null,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
fillColor: Colors.grey.shade100,
),
onChanged: (v) => setState(() => _searchQuery = v.toLowerCase()),
);
}
Widget _buildUploadButtons() {
if (isMobile) {
return Row(children: [
Expanded(
child: FilledButton.icon(
onPressed: _loading ? null : _pickFromCamera,
icon: const Icon(Icons.camera_alt),
label: const Text('Camera'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.tonalIcon(
onPressed: _loading ? null : _pickFromGallery,
icon: const Icon(Icons.photo_library),
label: const Text('Gallery'),
),
),
]);
}
return FilledButton.icon(
onPressed: _loading ? null : _pickFromFileBrowser,
icon: const Icon(Icons.folder_open),
label: const Text('Open Vehicle Document'),
);
}
Widget _buildEmptyState() {
return Card(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(children: [
Icon(Icons.directions_car_filled, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text('Upload Vehicle Document',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Supported:\n• RC / Vahan reports\n• Loan documents\n• Inspection / Valuation reports\n• Insurance documents',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600, height: 1.5),
),
]),
),
);
}
Widget _buildVehicleDataCard() {
final allFields = _details!.toDisplayMap();
if (allFields.isEmpty) {
return Card(
color: Colors.orange.shade50,
child: const Padding(
padding: EdgeInsets.all(16),
child: Row(children: [
Icon(Icons.warning_amber, color: Colors.orange),
SizedBox(width: 12),
Expanded(child: Text('No data extracted from this document.')),
]),
),
);
}
// Filter by search
final fields = _searchQuery.isEmpty
? allFields
: Map.fromEntries(allFields.entries.where((e) =>
e.key.toLowerCase().contains(_searchQuery) ||
e.value.toLowerCase().contains(_searchQuery)));
if (fields.isEmpty) {
return Card(
color: Colors.blue.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
const Icon(Icons.search_off, color: Colors.blue),
const SizedBox(width: 12),
Expanded(
child: Text(
'No fields matching "$_searchQuery"\nTry: chassis, engine, owner, loan, insurance',
style: TextStyle(color: Colors.blue.shade800),
),
),
]),
),
);
}
// Group by category
final groups = <String, Map<String, String>>{
'Registration': {},
'Owner': {},
'Vehicle': {},
'Validity': {},
'Finance': {},
'Other': {},
};
for (final e in fields.entries) {
final k = e.key.toLowerCase();
if (k.contains('registration') || k.contains('chassis') ||
k.contains('engine') || k.contains('rto')) {
groups['Registration']![e.key] = e.value;
} else if (k.contains('owner') || k.contains('father') ||
k.contains('address') || k.contains('name')) {
groups['Owner']![e.key] = e.value;
} else if (k.contains('maker') || k.contains('model') ||
k.contains('fuel') || k.contains('color') || k.contains('class') ||
k.contains('type') || k.contains('cc') || k.contains('seat') ||
k.contains('mfg') || k.contains('year')) {
groups['Vehicle']![e.key] = e.value;
} else if (k.contains('valid') || k.contains('fitness') ||
k.contains('insurance') || k.contains('puc') ||
k.contains('expiry') || k.contains('date')) {
groups['Validity']![e.key] = e.value;
} else if (k.contains('loan') || k.contains('hypothec') ||
k.contains('financ') || k.contains('emi') ||
k.contains('bank') || k.contains('nbfc')) {
groups['Finance']![e.key] = e.value;
} else {
groups['Other']![e.key] = e.value;
}
}
final icons = {
'Registration': Icons.article,
'Owner': Icons.person,
'Vehicle': Icons.directions_car,
'Validity': Icons.calendar_today,
'Finance': Icons.account_balance,
'Other': Icons.more_horiz,
};
return Column(children: [
if (_searchQuery.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text('${fields.length} field(s) matching "$_searchQuery"',
style: TextStyle(color: Colors.grey.shade600, fontStyle: FontStyle.italic)),
),
...groups.entries
.where((g) => g.value.isNotEmpty)
.map((g) => _buildSection(g.key, g.value, icons[g.key]!)),
]);
}
Widget _buildSection(String title, Map<String, String> fields, IconData icon) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(icon, size: 20, color: Colors.indigo),
const SizedBox(width: 8),
Text(title, style: Theme.of(context).textTheme.titleMedium),
]),
const Divider(),
...fields.entries.map((e) => _buildRow(e.key, e.value)),
]),
),
);
}
Widget _buildRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(
width: 130,
child: Text(label,
style: TextStyle(
color: Colors.grey.shade700,
fontWeight: FontWeight.w600,
fontSize: 13)),
),
const Text(': '),
Expanded(
child: SelectableText(value,
style: const TextStyle(fontWeight: FontWeight.w500)),
),
IconButton(
icon: const Icon(Icons.copy, size: 16),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$label copied'), duration: const Duration(seconds: 1)),
);
},
),
]),
);
}
void _copyAllData() {
if (_details == null) return;
final buf = StringBuffer();
for (final e in _details!.toDisplayMap().entries) {
buf.writeln('${e.key}: ${e.value}');
}
Clipboard.setData(ClipboardData(text: buf.toString()));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('All data copied'), duration: Duration(seconds: 2)),
);
}
}
// ── Statement of Account Page ─────────────────────────────────────────────────
class SoaPage extends StatefulWidget {
const SoaPage({super.key});
@override
State<SoaPage> createState() => _SoaPageState();
}
class _SoaPageState extends State<SoaPage> {
final _reader = OcrReader(validateDocument: false);
final _picker = ImagePicker();
bool _loading = false;
String? _error;
SoaDetails? _soa;
LoanStatementDetails? _loanStatement;
DetectedDocType _docType = DetectedDocType.unknown;
int _pageCount = 0;
String _rawText = '';
@override
void dispose() {
_reader.dispose();
super.dispose();
}
Future<void> _pick() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf', 'jpg', 'jpeg', 'png'],
allowMultiple: false,
);
if (result == null || result.files.single.path == null) return;
_process(File(result.files.single.path!));
}
Future<void> _pickMobile(ImageSource src) async {
final picked = await _picker.pickImage(source: src);
if (picked != null) _process(File(picked.path));
}
Future<void> _process(File file) async {
setState(() {
_loading = true;
_error = null;
_soa = null;
_loanStatement = null;
_docType = DetectedDocType.unknown;
_pageCount = 0;
_rawText = '';
});
try {
final rawBytes = await file.readAsBytes();
final allText = StringBuffer();
int pages = 1;
if (file.path.toLowerCase().endsWith('.pdf')) {
pages = await OcrDocumentSaver.getPdfPageCount(rawBytes);
for (int p = 0; p < pages; p++) {
final img = await OcrDocumentSaver.renderPdfPage(rawBytes, page: p, scale: 2.0);
if (img == null) continue;
final r = await _reader.readFromBytes(img);
allText.writeln(r.text);
}
} else {
final corrected = await OcrDocumentSaver.correctOrientation(rawBytes);
final r = await _reader.readFromBytes(corrected);
allText.writeln(r.text);
}
final text = allText.toString();
_rawText = text;
// Auto-detect document type
final detectedType = DocumentTypeDetector.detect(text);
setState(() {
_pageCount = pages;
_docType = detectedType;
if (detectedType == DetectedDocType.loanStatement) {
_loanStatement = LoanStatementDetails.fromText(text);
_soa = null;
} else {
// Default to bank SOA parser for statementOfAccount or unknown
_soa = SoaDetails.fromText(text);
_loanStatement = null;
}
});
} catch (e) {
setState(() => _error = e.toString());
} finally {
setState(() => _loading = false);
}
}
void _copyAll() {
final buf = StringBuffer();
if (_loanStatement != null) {
final ls = _loanStatement!;
for (final e in ls.fields.entries) {
buf.writeln('${e.key}: ${e.value}');
}
if (ls.disbursements.isNotEmpty) {
buf.writeln('\n--- Disbursements ---');
for (final d in ls.disbursements) {
buf.writeln('${d.date}: ${d.formattedAmount}');
}
}
if (ls.initialCharges.isNotEmpty) {
buf.writeln('\n--- Initial Charges ---');
for (final c in ls.initialCharges) {
buf.writeln('${c.name}: ${c.formattedAmount}');
}
}
if (ls.transactions.isNotEmpty) {
buf.writeln('\n--- Transactions ---');
buf.writeln('Date | Particulars | Due | Interest | Principal | Balance');
for (final tx in ls.transactions) {
buf.writeln('${tx.date} | ${tx.particulars} | ${tx.dueForRepayment ?? '-'} | ${tx.interestPaid ?? '-'} | ${tx.principalPaid ?? '-'} | ${tx.runningBalance ?? '-'}');
}
}
} else if (_soa != null) {
for (final e in _soa!.fields.entries) {
buf.writeln('${e.key}: ${e.value}');
}
if (_soa!.transactions.isNotEmpty) {
buf.writeln('\n--- Transactions ---');
buf.writeln('Date | Description | Debit | Credit | Balance');
for (final tx in _soa!.transactions) {
buf.writeln('${tx.date} | ${tx.description} | ${tx.debit ?? '-'} | ${tx.credit ?? '-'} | ${tx.balance ?? '-'}');
}
}
}
Clipboard.setData(ClipboardData(text: buf.toString()));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Copied to clipboard'), duration: Duration(seconds: 2)),
);
}
void _showSearch() {
if (_soa == null && _loanStatement == null) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => _loanStatement != null
? _LoanStatementSearchPage(loanStatement: _loanStatement!)
: _SoaSearchPage(soa: _soa!),
),
);
}
bool get _hasData => _soa != null || _loanStatement != null;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isLoan = _loanStatement != null;
final title = isLoan ? 'Loan Account Statement' : 'Statement of Account';
return Scaffold(
backgroundColor: Colors.grey.shade100,
appBar: AppBar(
title: Text(title),
backgroundColor: isLoan ? Colors.teal : Colors.indigo,
foregroundColor: Colors.white,
actions: [
if (_hasData)
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Search in document',
onPressed: _showSearch,
),
if (_hasData)
IconButton(
icon: const Icon(Icons.copy_all),
tooltip: 'Copy all',
onPressed: _copyAll,
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildUploadCard(theme),
const SizedBox(height: 12),
if (_docType != DetectedDocType.unknown && _hasData)
_buildDocTypeChip(theme),
if (_loading) _buildLoadingCard(),
if (_error != null) _buildErrorCard(theme),
if (_loanStatement != null && !_loading) ...[
_buildLoanStatsRow(theme),
const SizedBox(height: 12),
_buildLoanInfoCard(theme),
const SizedBox(height: 12),
_buildDisbursementsCard(theme),
const SizedBox(height: 12),
_buildInitialChargesCard(theme),
if (_loanStatement!.transactions.isNotEmpty) ...[
const SizedBox(height: 12),
_buildLoanTransactionsCard(theme),
],
const SizedBox(height: 12),
_buildLoanFieldsCard(theme),
const SizedBox(height: 12),
_buildRawTextTile(theme),
] else if (_soa != null && !_loading) ...[
_buildStatsRow(theme),
const SizedBox(height: 12),
_buildFieldsCard(theme),
if (_soa!.transactions.isNotEmpty) ...[
const SizedBox(height: 12),
_buildTransactionsCard(theme),
],
const SizedBox(height: 12),
_buildRawTextTile(theme),
],
if (!_hasData && !_loading && _error == null) _buildEmptyState(theme),
],
),
);
}
Widget _buildDocTypeChip(ThemeData theme) {
final isLoan = _docType == DetectedDocType.loanStatement;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Chip(
avatar: Icon(
isLoan ? Icons.home_work : Icons.receipt_long,
size: 18,
color: isLoan ? Colors.teal : Colors.indigo,
),
label: Text(
DocumentTypeDetector.label(_docType),
style: TextStyle(color: isLoan ? Colors.teal.shade700 : Colors.indigo.shade700),
),
backgroundColor: isLoan ? Colors.teal.shade50 : Colors.indigo.shade50,
),
const SizedBox(width: 8),
Text(
'$_pageCount page${_pageCount > 1 ? 's' : ''} processed',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
],
),
);
}
Widget _buildUploadCard(ThemeData theme) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.indigo.shade200),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: isMobile
? Row(children: [
Expanded(
child: FilledButton.icon(
onPressed: _loading ? null : () => _pickMobile(ImageSource.camera),
icon: const Icon(Icons.camera_alt),
label: const Text('Camera'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.tonalIcon(
onPressed: _loading ? null : () => _pickMobile(ImageSource.gallery),
icon: const Icon(Icons.photo_library),
label: const Text('Gallery'),
),
),
])
: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _loading ? null : _pick,
icon: const Icon(Icons.upload_file),
label: const Text('Upload PDF / Image'),
style: FilledButton.styleFrom(
backgroundColor: Colors.indigo,
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
),
);
}
Widget _buildLoadingCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(children: [
const CircularProgressIndicator(),
const SizedBox(height: 12),
Text(
_pageCount > 1 ? 'Reading $_pageCount pages...' : 'Reading document...',
style: const TextStyle(color: Colors.grey),
),
]),
),
);
}
Widget _buildErrorCard(ThemeData theme) {
return Card(
color: theme.colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
Icon(Icons.error_outline, color: theme.colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Text(_error!,
style: TextStyle(color: theme.colorScheme.onErrorContainer)),
),
]),
),
);
}
Widget _buildStatsRow(ThemeData theme) {
final soa = _soa!;
final totalDebit = soa.transactions
.where((t) => t.debit != null)
.fold(0.0, (s, t) => s + _parseAmount(t.debit!));
final totalCredit = soa.transactions
.where((t) => t.credit != null)
.fold(0.0, (s, t) => s + _parseAmount(t.credit!));
return Row(children: [
_statChip(Icons.receipt_long, '${soa.fields.length}', 'Fields', Colors.indigo),
const SizedBox(width: 8),
_statChip(Icons.swap_horiz, '${soa.transactions.length}', 'Transactions', Colors.teal),
if (soa.transactions.isNotEmpty) ...[
const SizedBox(width: 8),
_statChip(Icons.arrow_downward, _formatAmount(totalDebit), 'Total Debit', Colors.red),
const SizedBox(width: 8),
_statChip(Icons.arrow_upward, _formatAmount(totalCredit), 'Total Credit', Colors.green),
],
]);
}
Widget _statChip(IconData icon, String value, String label, Color color) {
return Expanded(
child: Card(
color: color.withValues(alpha: 0.08),
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
child: Column(children: [
Icon(icon, color: color, size: 20),
const SizedBox(height: 4),
Text(value,
style: TextStyle(
fontWeight: FontWeight.bold, fontSize: 13, color: color)),
Text(label,
style: TextStyle(fontSize: 10, color: color.withValues(alpha: 0.8)),
textAlign: TextAlign.center),
]),
),
),
);
}
Widget _buildFieldsCard(ThemeData theme) {
final fields = _soa!.fields;
if (fields.isEmpty) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
Icon(Icons.info_outline, color: Colors.orange.shade700),
const SizedBox(width: 12),
const Text('No label-value fields found in this document.'),
]),
),
);
}
return Card(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.indigo.shade50,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.list_alt, color: Colors.indigo, size: 20),
),
const SizedBox(width: 10),
Text('Extracted Fields',
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold)),
const Spacer(),
Text('${fields.length} fields',
style: TextStyle(
fontSize: 12, color: Colors.grey.shade600)),
]),
const SizedBox(height: 12),
...fields.entries.toList().asMap().entries.map((entry) {
final isEven = entry.key.isEven;
final e = entry.value;
return Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),
decoration: BoxDecoration(
color: isEven ? Colors.grey.shade50 : Colors.white,
borderRadius: BorderRadius.circular(6),
),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(
width: 150,
child: Text(
e.key,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.grey.shade700),
),
),
const Padding(
padding: EdgeInsets.only(right: 6),
child: Text(':', style: TextStyle(color: Colors.grey)),
),
Expanded(
child: SelectableText(
e.value,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w500),
),
),
GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: e.value));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('${e.key} copied'),
duration: const Duration(seconds: 1),
));
},
child: Icon(Icons.copy, size: 14, color: Colors.grey.shade400),
),
]),
);
}),
]),
),
);
}
Widget _buildTransactionsCard(ThemeData theme) {
final txs = _soa!.transactions;
return Card(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.teal.shade50,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.table_rows, color: Colors.teal, size: 20),
),
const SizedBox(width: 10),
Text('Transactions',
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold)),
const Spacer(),
Text('${txs.length} rows',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
]),
const SizedBox(height: 12),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Table(
defaultColumnWidth: const IntrinsicColumnWidth(),
border: TableBorder.all(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
children: [
// Header
TableRow(
decoration: BoxDecoration(color: Colors.indigo.shade50),
children: ['Date', 'Description', 'Debit', 'Credit', 'Balance']
.map((h) => _tableHeader(h))
.toList(),
),
// Rows
...txs.asMap().entries.map((entry) {
final i = entry.key;
final tx = entry.value;
final isEven = i.isEven;
return TableRow(
decoration: BoxDecoration(
color: isEven ? Colors.white : Colors.grey.shade50),
children: [
_tableCell(tx.date),
_tableCellWide(tx.description),
_tableCellAmount(tx.debit, Colors.red.shade700),
_tableCellAmount(tx.credit, Colors.green.shade700),
_tableCell(tx.balance ?? '-',
bold: true),
],
);
}),
],
),
),
),
]),
),
);
}
Widget _tableHeader(String text) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Text(text,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: Colors.indigo)),
);
}
Widget _tableCell(String text, {bool bold = false}) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(text,
style: TextStyle(
fontSize: 12,
fontWeight: bold ? FontWeight.w600 : FontWeight.normal)),
);
}
Widget _tableCellWide(String text) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 220),
child: Text(text,
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis),
),
);
}
Widget _tableCellAmount(String? amount, Color color) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(
amount ?? '-',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: amount != null ? color : Colors.grey.shade400),
),
);
}
Widget _buildRawTextTile(ThemeData theme) {
final text = _loanStatement?.rawText ?? _soa?.rawText ?? '';
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.shade300),
),
child: ExpansionTile(
leading: const Icon(Icons.text_snippet_outlined, color: Colors.grey),
title: Text('Raw OCR Text',
style: theme.textTheme.titleSmall
?.copyWith(color: Colors.grey.shade700)),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: SelectableText(
text,
style: theme.textTheme.bodySmall
?.copyWith(fontFamily: 'monospace'),
),
),
],
),
);
}
// ── Loan Statement UI Methods ─────────────────────────────────────────────
Widget _buildLoanStatsRow(ThemeData theme) {
final ls = _loanStatement!;
return Row(children: [
_statChip(Icons.account_balance, ls.loanAccountNumber ?? '-', 'Loan A/C', Colors.teal),
const SizedBox(width: 8),
_statChip(Icons.currency_rupee, ls.emiAmount ?? '-', 'EMI', Colors.orange),
const SizedBox(width: 8),
_statChip(Icons.swap_horiz, '${ls.transactions.length}', 'Transactions', Colors.blue),
]);
}
Widget _buildLoanInfoCard(ThemeData theme) {
final ls = _loanStatement!;
return Card(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.teal.shade50,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.home_work, color: Colors.teal, size: 20),
),
const SizedBox(width: 10),
Text('Loan Details', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
]),
const SizedBox(height: 12),
_loanInfoRow('Customer', ls.customerName),
_loanInfoRow('Co-Applicant', ls.coApplicantName),
_loanInfoRow('Branch', ls.branch),
_loanInfoRow('Product', ls.product),
_loanInfoRow('Sanction Date', ls.sanctionDate),
_loanInfoRow('Amt Sanctioned', ls.amtSanctioned),
_loanInfoRow('Amt Disbursed', ls.amtDisbursed),
_loanInfoRow('Tenure', ls.tenure != null ? '${ls.tenure} months' : null),
_loanInfoRow('Remaining Tenure', ls.remainingTenure != null ? '${ls.remainingTenure} months' : null),
_loanInfoRow('ROI', ls.rateOfInterest != null ? '${ls.rateOfInterest}%' : null),
_loanInfoRow('EMI Amount', ls.emiAmount),
_loanInfoRow('Future Principal', ls.futurePrincipal),
_loanInfoRow('Net Receivable', ls.netReceivable),
_loanInfoRow('Status', ls.status),
]),
),
);
}
Widget _loanInfoRow(String label, String? value) {
if (value == null || value.isEmpty || value == '-') return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(
width: 130,
child: Text(label, style: TextStyle(fontSize: 13, color: Colors.grey.shade700, fontWeight: FontWeight.w600)),
),
const Text(': ', style: TextStyle(color: Colors.grey)),
Expanded(child: SelectableText(value, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500))),
]),
);
}
Widget _buildDisbursementsCard(ThemeData theme) {
final disbursements = _loanStatement!.disbursements;
if (disbursements.isEmpty) return const SizedBox.shrink();
return Card(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(color: Colors.green.shade50, borderRadius: BorderRadius.circular(8)),
child: const Icon(Icons.payments, color: Colors.green, size: 20),
),
const SizedBox(width: 10),
Text('Disbursements', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
const Spacer(),
Text('Total: ₹${_loanStatement!.totalDisbursedAmount.toStringAsFixed(0)}',
style: TextStyle(fontSize: 13, color: Colors.green.shade700, fontWeight: FontWeight.bold)),
]),
const SizedBox(height: 12),
...disbursements.map((d) => Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),
margin: const EdgeInsets.only(bottom: 4),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(d.date, style: const TextStyle(fontSize: 13)),
Text('₹${d.formattedAmount}', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold)),
],
),
)),
]),
),
);
}
Widget _buildInitialChargesCard(ThemeData theme) {
final charges = _loanStatement!.initialCharges;
if (charges.isEmpty) return const SizedBox.shrink();
return Card(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(color: Colors.orange.shade50, borderRadius: BorderRadius.circular(8)),
child: const Icon(Icons.receipt, color: Colors.orange, size: 20),
),
const SizedBox(width: 10),
Text('Initial Charges', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
const Spacer(),
Text('Total: ₹${_loanStatement!.totalInitialCharges.toStringAsFixed(0)}',
style: TextStyle(fontSize: 13, color: Colors.orange.shade700, fontWeight: FontWeight.bold)),
]),
const SizedBox(height: 12),
...charges.map((c) => Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),
margin: const EdgeInsets.only(bottom: 4),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
Expanded(child: Text(c.name, style: const TextStyle(fontSize: 12))),
Text('₹${c.formattedAmount}', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
],
),
)),
]),
),
);
}
Widget _buildLoanTransactionsCard(ThemeData theme) {
final txs = _loanStatement!.transactions;
return Card(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(color: Colors.blue.shade50, borderRadius: BorderRadius.circular(8)),
child: const Icon(Icons.table_rows, color: Colors.blue, size: 20),
),
const SizedBox(width: 10),
Text('Transaction History', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
const Spacer(),
Text('${txs.length} entries', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
]),
const SizedBox(height: 12),
// Summary row
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.blue.shade50, borderRadius: BorderRadius.circular(8)),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_txSummaryItem('Interest Paid', '₹${_loanStatement!.totalInterestPaidAmount.toStringAsFixed(0)}', Colors.blue),
_txSummaryItem('Principal Paid', '₹${_loanStatement!.totalPrincipalPaidAmount.toStringAsFixed(0)}', Colors.green),
],
),
),
const SizedBox(height: 12),
// Transaction list
...txs.take(20).map((tx) => _buildLoanTxRow(tx)),
if (txs.length > 20)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text('+ ${txs.length - 20} more transactions...',
style: TextStyle(color: Colors.grey.shade600, fontSize: 12)),
),
]),
),
);
}
Widget _txSummaryItem(String label, String value, Color color) {
return Column(children: [
Text(value, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: color)),
Text(label, style: TextStyle(fontSize: 11, color: color.withValues(alpha: 0.8))),
]);
}
Widget _buildLoanTxRow(LoanTransaction tx) {
final isPayment = tx.isPayment;
final isPenalty = tx.isPenalty;
Color bgColor;
IconData icon;
if (isPayment) {
bgColor = Colors.green.shade50;
icon = Icons.arrow_downward;
} else if (isPenalty) {
bgColor = Colors.red.shade50;
icon = Icons.warning_amber;
} else {
bgColor = Colors.grey.shade50;
icon = Icons.event_note;
}
return Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),
margin: const EdgeInsets.only(bottom: 4),
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(6)),
child: Row(
children: [
Icon(icon, size: 16, color: isPayment ? Colors.green : (isPenalty ? Colors.red : Colors.grey)),
const SizedBox(width: 8),
SizedBox(width: 70, child: Text(tx.date, style: const TextStyle(fontSize: 11))),
Expanded(
child: Text(tx.particulars, style: const TextStyle(fontSize: 11), maxLines: 2, overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 8),
SizedBox(
width: 70,
child: Text(
tx.runningBalance != null ? '₹${tx.runningBalance!.toStringAsFixed(0)}' : '-',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
textAlign: TextAlign.right,
),
),
],
),
);
}
Widget _buildLoanFieldsCard(ThemeData theme) {
final fields = _loanStatement!.fields;
if (fields.isEmpty) return const SizedBox.shrink();
return Card(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ExpansionTile(
leading: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(color: Colors.purple.shade50, borderRadius: BorderRadius.circular(8)),
child: const Icon(Icons.list_alt, color: Colors.purple, size: 20),
),
title: Text('All Extracted Fields (${fields.length})',
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
children: fields.entries.map((e) => Container(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
margin: const EdgeInsets.only(bottom: 4),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(4),
),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(width: 140, child: Text(e.key, style: TextStyle(fontSize: 12, color: Colors.grey.shade700))),
const Text(': ', style: TextStyle(color: Colors.grey)),
Expanded(child: SelectableText(e.value, style: const TextStyle(fontSize: 12))),
]),
)).toList(),
),
),
],
),
);
}
Widget _buildEmptyState(ThemeData theme) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.grey.shade300, style: BorderStyle.solid),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 32),
child: Column(children: [
Icon(Icons.receipt_long_outlined, size: 72, color: Colors.indigo.shade200),
const SizedBox(height: 16),
Text('Upload a Statement',
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text(
'Supports Bank SOA and Loan Account Statements.\nAuto-detects document type and extracts all fields.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600, height: 1.6),
),
const SizedBox(height: 24),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: ['Bank SOA', 'Loan Statement', 'PDF', 'Multi-page']
.map((f) => Chip(
label: Text(f, style: const TextStyle(fontSize: 12)),
backgroundColor: Colors.indigo.shade50,
side: BorderSide(color: Colors.indigo.shade200),
))
.toList(),
),
]),
),
);
}
double _parseAmount(String s) =>
double.tryParse(s.replaceAll(',', '')) ?? 0.0;
String _formatAmount(double v) {
if (v == 0) return '-';
if (v >= 100000) return '${(v / 100000).toStringAsFixed(1)}L';
if (v >= 1000) return '${(v / 1000).toStringAsFixed(1)}K';
return v.toStringAsFixed(0);
}
}
// ── SOA Search Page ─────────────────────────────────────────────────────────────
class _SoaSearchPage extends StatefulWidget {
final SoaDetails soa;
const _SoaSearchPage({required this.soa});
@override
State<_SoaSearchPage> createState() => _SoaSearchPageState();
}
class _SoaSearchPageState extends State<_SoaSearchPage>
with SingleTickerProviderStateMixin {
late final SmartDocumentSearch _smartSearch;
late final TabController _tabController;
List<SoaTransaction> _filteredTransactions = [];
String _transactionFilter = '';
@override
void initState() {
super.initState();
_smartSearch = SmartDocumentSearch(
text: widget.soa.rawText,
extractedFields: widget.soa.fields,
documentType: 'Bank Statement',
);
_tabController = TabController(length: 3, vsync: this);
_filteredTransactions = widget.soa.transactions;
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _filterTransactions(String query) {
setState(() {
_transactionFilter = query;
if (query.isEmpty) {
_filteredTransactions = widget.soa.transactions;
} else {
final q = query.toLowerCase();
_filteredTransactions = widget.soa.transactions.where((tx) {
return tx.description.toLowerCase().contains(q) ||
tx.date.toLowerCase().contains(q) ||
(tx.debit?.contains(q) ?? false) ||
(tx.credit?.contains(q) ?? false) ||
(tx.balance?.contains(q) ?? false);
}).toList();
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey.shade50,
appBar: AppBar(
title: const Text('Search Statement'),
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
bottom: TabBar(
controller: _tabController,
indicatorColor: Colors.white,
labelColor: Colors.white,
unselectedLabelColor: Colors.white70,
tabs: const [
Tab(icon: Icon(Icons.auto_awesome), text: 'Smart Search'),
Tab(icon: Icon(Icons.swap_horiz), text: 'Transactions'),
Tab(icon: Icon(Icons.list_alt), text: 'All Fields'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
// Smart Search Tab
SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: SmartSearchWidget(
searchService: _smartSearch,
title: 'Ask about your statement',
),
),
// Transactions Tab
_buildTransactionSearch(),
// Fields Tab
_buildFieldsTab(),
],
),
);
}
Widget _buildTransactionSearch() {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
decoration: InputDecoration(
hintText: 'Filter transactions by description, date, amount...',
prefixIcon: const Icon(Icons.filter_list),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
filled: true,
fillColor: Colors.white,
),
onChanged: _filterTransactions,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Text(
'${_filteredTransactions.length} of ${widget.soa.transactions.length} transactions',
style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
),
const Spacer(),
if (_transactionFilter.isNotEmpty)
TextButton(
onPressed: () => _filterTransactions(''),
child: const Text('Clear'),
),
],
),
),
Expanded(
child: _filteredTransactions.isEmpty
? Center(
child: Text(
'No transactions match your filter',
style: TextStyle(color: Colors.grey.shade500),
),
)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _filteredTransactions.length,
itemBuilder: (context, index) {
final tx = _filteredTransactions[index];
final isDebit = tx.debit != null && tx.debit!.isNotEmpty;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: isDebit ? Colors.red.shade50 : Colors.green.shade50,
child: Icon(
isDebit ? Icons.arrow_downward : Icons.arrow_upward,
color: isDebit ? Colors.red : Colors.green,
size: 20,
),
),
title: Text(
tx.description,
style: const TextStyle(fontSize: 13),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
tx.date,
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
isDebit ? '-${tx.debit}' : '+${tx.credit ?? '0'}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: isDebit ? Colors.red : Colors.green,
),
),
if (tx.balance != null)
Text(
'Bal: ${tx.balance}',
style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
),
],
),
onTap: () {
Clipboard.setData(ClipboardData(
text: '${tx.date} | ${tx.description} | ${tx.debit ?? tx.credit ?? ''} | ${tx.balance ?? ''}',
));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Transaction copied'), duration: Duration(seconds: 1)),
);
},
),
);
},
),
),
],
);
}
Widget _buildFieldsTab() {
final fields = widget.soa.fields;
if (fields.isEmpty) {
return const Center(child: Text('No fields found'));
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: fields.length,
itemBuilder: (context, index) {
final entry = fields.entries.elementAt(index);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
title: Text(
entry.key,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
fontWeight: FontWeight.w600,
),
),
subtitle: SelectableText(
entry.value,
style: const TextStyle(fontSize: 14),
),
trailing: IconButton(
icon: const Icon(Icons.copy, size: 18),
onPressed: () {
Clipboard.setData(ClipboardData(text: entry.value));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${entry.key} copied'), duration: const Duration(seconds: 1)),
);
},
),
),
);
},
);
}
}
// ── Loan Statement Search Page ──────────────────────────────────────────────────
class _LoanStatementSearchPage extends StatefulWidget {
final LoanStatementDetails loanStatement;
const _LoanStatementSearchPage({required this.loanStatement});
@override
State<_LoanStatementSearchPage> createState() => _LoanStatementSearchPageState();
}
class _LoanStatementSearchPageState extends State<_LoanStatementSearchPage>
with SingleTickerProviderStateMixin {
late final SmartDocumentSearch _smartSearch;
late final TabController _tabController;
List<LoanTransaction> _filteredTransactions = [];
String _transactionFilter = '';
@override
void initState() {
super.initState();
_smartSearch = SmartDocumentSearch(
text: widget.loanStatement.rawText,
extractedFields: widget.loanStatement.fields,
documentType: 'Loan Statement',
);
_tabController = TabController(length: 4, vsync: this);
_filteredTransactions = widget.loanStatement.transactions;
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _filterTransactions(String query) {
setState(() {
_transactionFilter = query;
if (query.isEmpty) {
_filteredTransactions = widget.loanStatement.transactions;
} else {
final q = query.toLowerCase();
_filteredTransactions = widget.loanStatement.transactions.where((tx) {
return tx.particulars.toLowerCase().contains(q) ||
tx.date.toLowerCase().contains(q) ||
tx.transactionType.toLowerCase().contains(q);
}).toList();
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey.shade50,
appBar: AppBar(
title: const Text('Search Loan Statement'),
backgroundColor: Colors.teal,
foregroundColor: Colors.white,
bottom: TabBar(
controller: _tabController,
indicatorColor: Colors.white,
labelColor: Colors.white,
unselectedLabelColor: Colors.white70,
tabs: const [
Tab(icon: Icon(Icons.auto_awesome), text: 'Smart Search'),
Tab(icon: Icon(Icons.swap_horiz), text: 'Transactions'),
Tab(icon: Icon(Icons.list_alt), text: 'All Fields'),
Tab(icon: Icon(Icons.receipt), text: 'Charges'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
// Smart Search Tab
SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: SmartSearchWidget(
searchService: _smartSearch,
title: 'Ask about your loan',
),
),
// Transactions Tab
_buildTransactionSearch(),
// Fields Tab
_buildFieldsTab(),
// Charges Tab
_buildChargesTab(),
],
),
);
}
Widget _buildTransactionSearch() {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
decoration: InputDecoration(
hintText: 'Filter by description, date, type...',
prefixIcon: const Icon(Icons.filter_list),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
filled: true,
fillColor: Colors.white,
),
onChanged: _filterTransactions,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Text(
'${_filteredTransactions.length} of ${widget.loanStatement.transactions.length} transactions',
style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
),
const Spacer(),
if (_transactionFilter.isNotEmpty)
TextButton(onPressed: () => _filterTransactions(''), child: const Text('Clear')),
],
),
),
Expanded(
child: _filteredTransactions.isEmpty
? Center(child: Text('No transactions match', style: TextStyle(color: Colors.grey.shade500)))
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _filteredTransactions.length,
itemBuilder: (context, index) {
final tx = _filteredTransactions[index];
final isPayment = tx.isPayment;
final isPenalty = tx.isPenalty;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: isPayment ? Colors.green.shade50 : (isPenalty ? Colors.red.shade50 : Colors.grey.shade100),
child: Icon(
isPayment ? Icons.arrow_downward : (isPenalty ? Icons.warning : Icons.event),
color: isPayment ? Colors.green : (isPenalty ? Colors.red : Colors.grey),
size: 20,
),
),
title: Text(tx.particulars, style: const TextStyle(fontSize: 13), maxLines: 2),
subtitle: Text('${tx.date} • ${tx.transactionType}', style: const TextStyle(fontSize: 11)),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (tx.primaryAmount > 0)
Text('₹${tx.primaryAmount.toStringAsFixed(0)}',
style: TextStyle(fontWeight: FontWeight.bold,
color: isPayment ? Colors.green : (isPenalty ? Colors.red : Colors.black))),
if (tx.runningBalance != null)
Text('Bal: ₹${tx.runningBalance!.toStringAsFixed(0)}',
style: TextStyle(fontSize: 10, color: Colors.grey.shade500)),
],
),
onTap: () {
Clipboard.setData(ClipboardData(text: '${tx.date} | ${tx.particulars} | ${tx.primaryAmount}'));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Copied'), duration: Duration(seconds: 1)));
},
),
);
},
),
),
],
);
}
Widget _buildFieldsTab() {
final fields = widget.loanStatement.fields;
if (fields.isEmpty) return const Center(child: Text('No fields found'));
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: fields.length,
itemBuilder: (context, index) {
final entry = fields.entries.elementAt(index);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
title: Text(entry.key, style: TextStyle(fontSize: 13, color: Colors.grey.shade700, fontWeight: FontWeight.w600)),
subtitle: SelectableText(entry.value, style: const TextStyle(fontSize: 14)),
trailing: IconButton(
icon: const Icon(Icons.copy, size: 18),
onPressed: () {
Clipboard.setData(ClipboardData(text: entry.value));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${entry.key} copied'), duration: const Duration(seconds: 1)));
},
),
),
);
},
);
}
Widget _buildChargesTab() {
final charges = widget.loanStatement.initialCharges;
final disbursements = widget.loanStatement.disbursements;
return ListView(
padding: const EdgeInsets.all(16),
children: [
if (disbursements.isNotEmpty) ...[
Text('Disbursements', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.green.shade700)),
const SizedBox(height: 8),
...disbursements.map((d) => Card(
margin: const EdgeInsets.only(bottom: 6),
child: ListTile(
leading: const CircleAvatar(backgroundColor: Colors.green, child: Icon(Icons.payments, color: Colors.white, size: 18)),
title: Text(d.date),
trailing: Text('₹${d.formattedAmount}', style: const TextStyle(fontWeight: FontWeight.bold)),
),
)),
const SizedBox(height: 16),
],
if (charges.isNotEmpty) ...[
Text('Initial Charges', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.orange.shade700)),
const SizedBox(height: 8),
...charges.map((c) => Card(
margin: const EdgeInsets.only(bottom: 6),
child: ListTile(
leading: const CircleAvatar(backgroundColor: Colors.orange, child: Icon(Icons.receipt, color: Colors.white, size: 18)),
title: Text(c.name, style: const TextStyle(fontSize: 13)),
trailing: Text('₹${c.formattedAmount}', style: const TextStyle(fontWeight: FontWeight.bold)),
),
)),
],
if (charges.isEmpty && disbursements.isEmpty)
const Center(child: Text('No charges or disbursements found')),
],
);
}
}