Skip to main content

flutter_ocr_native 0.3.8 copy "flutter_ocr_native: ^0.3.8" to clipboard
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.

example/lib/main.dart

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')),
      ],
    );
  }
}
2
likes
140
points
2k
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Flutter plugin for on-device OCR using ML Kit (Android), Vision (iOS/macOS), WinRT (Windows), Tesseract (Linux). Document parsing, validation, and masking.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

crypto, flutter, path_provider

More

Packages that depend on flutter_ocr_native

Packages that implement flutter_ocr_native