selectable_ocr 1.0.0 copy "selectable_ocr: ^1.0.0" to clipboard
selectable_ocr: ^1.0.0 copied to clipboard

A powerful Flutter widget that lets users select and crop any area of the UI and automatically extracts text using on-device OCR or custom backends.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:selectable_ocr/selectable_ocr.dart';

void main() {
  runApp(const SelectableOcrExampleApp());
}

class FieldData {
  final String label;
  final String placeholder;
  final TextEditingController controller;
  bool isValidated;

  FieldData({
    required this.label,
    required this.placeholder,
    required this.controller,
    this.isValidated = false,
  });
}

class SelectableOcrExampleApp extends StatelessWidget {
  const SelectableOcrExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Selectable OCR',
      theme: ThemeData(
        brightness: Brightness.dark,
        scaffoldBackgroundColor: const Color(0xFF090D16),
        cardColor: const Color(0xFF131B2E),
        colorScheme: const ColorScheme.dark(
          primary: Color(0xFF3B82F6),
          secondary: Color(0xFF10B981),
          surface: Color(0xFF131B2E),
        ),
        fontFamily: 'Inter',
        appBarTheme: const AppBarTheme(
          backgroundColor: Color(0xFF0F172A),
          elevation: 0,
        ),
      ),
      home: const DocumentScannerScreen(),
    );
  }
}

class DocumentScannerScreen extends StatefulWidget {
  const DocumentScannerScreen({super.key});

  @override
  State<DocumentScannerScreen> createState() => _DocumentScannerScreenState();
}

class _DocumentScannerScreenState extends State<DocumentScannerScreen> {
  int _activeFieldIndex = 0;
  bool _isProcessing = false;
  String _statusMessage = 'Drag a box around the active field on the passport.';
  String _lastLatency = '';

  late final List<FieldData> _fields;

  @override
  void initState() {
    super.initState();
    _fields = [
      FieldData(
        label: 'Passport No',
        placeholder: 'e.g. E87239104',
        controller: TextEditingController(),
      ),
      FieldData(
        label: 'Surname',
        placeholder: 'e.g. ROBINSON',
        controller: TextEditingController(),
      ),
      FieldData(
        label: 'Given Names',
        placeholder: 'e.g. ALEXANDER DAVID',
        controller: TextEditingController(),
      ),
      FieldData(
        label: 'Nationality',
        placeholder: 'e.g. UNITED STATES OF AMERICA',
        controller: TextEditingController(),
      ),
      FieldData(
        label: 'Date of Birth',
        placeholder: 'e.g. 14 JUL 1992',
        controller: TextEditingController(),
      ),
      FieldData(
        label: 'Place of Birth',
        placeholder: 'e.g. CALIFORNIA, USA',
        controller: TextEditingController(),
      ),
      FieldData(
        label: 'Date of Expiry',
        placeholder: 'e.g. 09 AUG 2032',
        controller: TextEditingController(),
      ),
    ];
  }

  @override
  void dispose() {
    for (final f in _fields) {
      f.controller.dispose();
    }
    super.dispose();
  }

  void _handleOcrResult(OcrResult result) {
    final extracted = result.cleanText;
    final activeField = _fields[_activeFieldIndex];

    setState(() {
      activeField.controller.text = extracted;
      activeField.isValidated = extracted.isNotEmpty;
      _lastLatency = '${result.processingTime.inMilliseconds} ms';
      _statusMessage = 'Captured "$extracted" into ${activeField.label}';

      // Auto advance to the next empty field
      int nextIndex = (_activeFieldIndex + 1) % _fields.length;
      for (int i = 0; i < _fields.length; i++) {
        final checkIndex = (_activeFieldIndex + 1 + i) % _fields.length;
        if (!_fields[checkIndex].isValidated) {
          nextIndex = checkIndex;
          break;
        }
      }
      _activeFieldIndex = nextIndex;
    });
  }

  int get _validatedCount => _fields.where((f) => f.isValidated).length;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      resizeToAvoidBottomInset: false,
      appBar: AppBar(
        title: Row(
          children: [
            Container(
              padding: const EdgeInsets.all(6),
              decoration: BoxDecoration(
                color: const Color(0xFF3B82F6).withAlpha(40),
                borderRadius: BorderRadius.circular(8),
              ),
              child: const Icon(
                Icons.crop_free,
                color: Color(0xFF3B82F6),
                size: 20,
              ),
            ),
            const SizedBox(width: 10),
            const Expanded(
              child: Text(
                'Selectable OCR',
                overflow: TextOverflow.ellipsis,
                style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
              ),
            ),
          ],
        ),
        actions: [
          if (_lastLatency.isNotEmpty)
            Center(
              child: Container(
                margin: const EdgeInsets.only(right: 8),
                padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                decoration: BoxDecoration(
                  color: const Color(0xFF10B981).withAlpha(30),
                  borderRadius: BorderRadius.circular(20),
                  border: Border.all(color: const Color(0xFF10B981), width: 1),
                ),
                child: Row(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    const Icon(Icons.bolt, size: 13, color: Color(0xFF10B981)),
                    const SizedBox(width: 3),
                    Text(
                      _lastLatency,
                      style: const TextStyle(
                        fontSize: 11,
                        color: Color(0xFF10B981),
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ],
                ),
              ),
            ),
          IconButton(
            tooltip: 'Reset Fields',
            icon: const Icon(Icons.refresh, size: 20),
            onPressed: () {
              setState(() {
                for (final f in _fields) {
                  f.controller.clear();
                  f.isValidated = false;
                }
                _activeFieldIndex = 0;
                _statusMessage = 'All fields reset. Select a field to begin.';
                _lastLatency = '';
              });
            },
          ),
        ],
      ),
      body: SafeArea(
        child: LayoutBuilder(
          builder: (context, constraints) {
            final isDesktop = constraints.maxWidth > 850;
            if (isDesktop) {
              return _buildDesktopLayout();
            } else {
              return _buildMobileLayout();
            }
          },
        ),
      ),
    );
  }

  // --- Mobile Layout: Optimized for portrait touch screen with keyboard safety ---
  Widget _buildMobileLayout() {
    return Column(
      children: [
        // Top Active Field Guide Banner
        _buildActiveFieldHeader(),

        // Document Viewer (Fixed height proportion to prevent layout shift)
        Expanded(
          flex: 5,
          child: Container(
            margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
            decoration: BoxDecoration(
              color: const Color(0xFF131B2E),
              borderRadius: BorderRadius.circular(16),
              border: Border.all(color: const Color(0xFF1E293B)),
              boxShadow: const [
                BoxShadow(
                  color: Colors.black45,
                  blurRadius: 10,
                  offset: Offset(0, 4),
                ),
              ],
            ),
            clipBehavior: Clip.antiAlias,
            child: _buildSelectableDocument(),
          ),
        ),

        // Bottom Extracted Fields List (Scrollable & keyboard-safe)
        Expanded(
          flex: 4,
          child: Container(
            margin: const EdgeInsets.fromLTRB(12, 4, 12, 8),
            padding: const EdgeInsets.fromLTRB(14, 12, 14, 6),
            decoration: BoxDecoration(
              color: const Color(0xFF131B2E),
              borderRadius: BorderRadius.circular(16),
              border: Border.all(color: const Color(0xFF1E293B)),
            ),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    const Text(
                      'Extracted Fields',
                      style: TextStyle(
                        fontSize: 14,
                        fontWeight: FontWeight.bold,
                        color: Colors.white,
                      ),
                    ),
                    Container(
                      padding: const EdgeInsets.symmetric(
                        horizontal: 8,
                        vertical: 2,
                      ),
                      decoration: BoxDecoration(
                        color: const Color(0xFF3B82F6).withAlpha(40),
                        borderRadius: BorderRadius.circular(10),
                      ),
                      child: Text(
                        '$_validatedCount / ${_fields.length} Done',
                        style: const TextStyle(
                          fontSize: 11,
                          color: Color(0xFF60A5FA),
                          fontWeight: FontWeight.w600,
                        ),
                      ),
                    ),
                  ],
                ),
                const SizedBox(height: 8),
                Expanded(
                  child: ListView.separated(
                    padding: EdgeInsets.zero,
                    itemCount: _fields.length,
                    separatorBuilder: (context, index) =>
                        const SizedBox(height: 8),
                    itemBuilder: (context, index) => _buildFieldRow(index),
                  ),
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }

  // --- Desktop / Tablet Layout: Dual-pane side-by-side ---
  Widget _buildDesktopLayout() {
    return Padding(
      padding: const EdgeInsets.all(20.0),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Expanded(
            flex: 6,
            child: Column(
              children: [
                _buildActiveFieldHeader(),
                const SizedBox(height: 12),
                Expanded(
                  child: Container(
                    decoration: BoxDecoration(
                      color: const Color(0xFF131B2E),
                      borderRadius: BorderRadius.circular(20),
                      border: Border.all(color: const Color(0xFF1E293B)),
                      boxShadow: const [
                        BoxShadow(
                          color: Colors.black45,
                          blurRadius: 16,
                          offset: Offset(0, 4),
                        ),
                      ],
                    ),
                    clipBehavior: Clip.antiAlias,
                    child: _buildSelectableDocument(),
                  ),
                ),
              ],
            ),
          ),
          const SizedBox(width: 20),
          SizedBox(
            width: 380,
            child: Container(
              padding: const EdgeInsets.all(20),
              decoration: BoxDecoration(
                color: const Color(0xFF131B2E),
                borderRadius: BorderRadius.circular(20),
                border: Border.all(color: const Color(0xFF1E293B)),
              ),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      const Text(
                        'Extracted Data',
                        style: TextStyle(
                          fontSize: 16,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                      Text(
                        '$_validatedCount of ${_fields.length} Extracted',
                        style: const TextStyle(
                          fontSize: 12,
                          color: Color(0xFF60A5FA),
                          fontWeight: FontWeight.w600,
                        ),
                      ),
                    ],
                  ),
                  const SizedBox(height: 8),
                  Text(
                    _statusMessage,
                    style: const TextStyle(
                      fontSize: 12,
                      color: Color(0xFF94A3B8),
                    ),
                  ),
                  const Divider(color: Color(0xFF1E293B), height: 24),
                  Expanded(
                    child: ListView.separated(
                      itemCount: _fields.length,
                      separatorBuilder: (context, index) =>
                          const SizedBox(height: 10),
                      itemBuilder: (context, index) => _buildFieldRow(index),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildActiveFieldHeader() {
    final activeField = _fields[_activeFieldIndex];
    return Container(
      margin: const EdgeInsets.fromLTRB(12, 8, 12, 2),
      padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
      decoration: BoxDecoration(
        color: const Color(0xFF1E293B),
        borderRadius: BorderRadius.circular(12),
        border: Border.all(
          color: const Color(0xFF3B82F6).withAlpha(120),
          width: 1.5,
        ),
      ),
      child: Row(
        children: [
          Container(
            padding: const EdgeInsets.all(6),
            decoration: BoxDecoration(
              color: const Color(0xFF3B82F6).withAlpha(50),
              shape: BoxShape.circle,
            ),
            child: const Icon(
              Icons.touch_app,
              color: Color(0xFF60A5FA),
              size: 16,
            ),
          ),
          const SizedBox(width: 10),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              mainAxisSize: MainAxisSize.min,
              children: [
                Row(
                  children: [
                    const Text(
                      'ACTIVE TARGET: ',
                      style: TextStyle(
                        fontSize: 10,
                        fontWeight: FontWeight.bold,
                        color: Color(0xFF94A3B8),
                        letterSpacing: 1,
                      ),
                    ),
                    Text(
                      activeField.label.toUpperCase(),
                      style: const TextStyle(
                        fontSize: 12,
                        fontWeight: FontWeight.w900,
                        color: Color(0xFF60A5FA),
                      ),
                    ),
                  ],
                ),
                Text(
                  'Draw a box over "${activeField.label}" on the passport image',
                  style: const TextStyle(
                    fontSize: 11,
                    color: Colors.white70,
                  ),
                  overflow: TextOverflow.ellipsis,
                ),
              ],
            ),
          ),
          if (_isProcessing)
            const SizedBox(
              width: 16,
              height: 16,
              child: CircularProgressIndicator(
                strokeWidth: 2,
                color: Color(0xFF3B82F6),
              ),
            ),
        ],
      ),
    );
  }

  Widget _buildSelectableDocument() {
    return Center(
      child: SelectableOcr(
        style: const SelectionStyle(
          strokeColor: Color(0xFF3B82F6),
          fillColor: Color(0x383B82F6),
          strokeWidth: 2.5,
          cornerRadius: 4.0,
          loadingText: 'Extracting on-device...',
          loadingBadgeColor: Color(0xFF3B82F6),
          loadingBadgeBackground: Color(0xE60F172A),
        ),
        onLoading: (loading) => setState(() => _isProcessing = loading),
        onOcrResult: _handleOcrResult,
        onError: (err) {
          setState(() {
            _statusMessage = 'Error: $err';
          });
        },
        child: Image.asset(
          'assets/images/sample_passport.jpg',
          fit: BoxFit.contain,
          errorBuilder: (context, error, stackTrace) {
            return Center(
              child: Text(
                'Failed to load asset image: $error',
                style: const TextStyle(color: Colors.redAccent),
              ),
            );
          },
        ),
      ),
    );
  }

  Widget _buildFieldRow(int index) {
    final field = _fields[index];
    final isActive = _activeFieldIndex == index;

    return InkWell(
      onTap: () {
        setState(() {
          _activeFieldIndex = index;
          _statusMessage = 'Selected "${field.label}". Now draw a box over it.';
        });
      },
      borderRadius: BorderRadius.circular(10),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 200),
        padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
        decoration: BoxDecoration(
          color: isActive
              ? const Color(0xFF3B82F6).withAlpha(30)
              : const Color(0xFF0F172A),
          borderRadius: BorderRadius.circular(10),
          border: Border.all(
            color: isActive
                ? const Color(0xFF3B82F6)
                : (field.isValidated
                    ? const Color(0xFF10B981).withAlpha(100)
                    : const Color(0xFF1E293B)),
            width: isActive ? 1.5 : 1.0,
          ),
        ),
        child: Row(
          children: [
            // Validation indicator or index badge
            Container(
              width: 24,
              height: 24,
              decoration: BoxDecoration(
                color: field.isValidated
                    ? const Color(0xFF10B981).withAlpha(40)
                    : (isActive
                        ? const Color(0xFF3B82F6).withAlpha(40)
                        : const Color(0xFF1E293B)),
                shape: BoxShape.circle,
              ),
              child: Center(
                child: field.isValidated
                    ? const Icon(
                        Icons.check,
                        size: 14,
                        color: Color(0xFF10B981),
                      )
                    : Text(
                        '${index + 1}',
                        style: TextStyle(
                          fontSize: 11,
                          fontWeight: FontWeight.bold,
                          color: isActive
                              ? const Color(0xFF60A5FA)
                              : Colors.white54,
                        ),
                      ),
              ),
            ),
            const SizedBox(width: 10),

            // Field Label and Value
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text(
                    field.label,
                    style: TextStyle(
                      fontSize: 11,
                      fontWeight:
                          isActive ? FontWeight.bold : FontWeight.w500,
                      color: isActive
                          ? const Color(0xFF60A5FA)
                          : const Color(0xFF94A3B8),
                    ),
                  ),
                  const SizedBox(height: 2),
                  Text(
                    field.controller.text.isEmpty
                        ? field.placeholder
                        : field.controller.text,
                    style: TextStyle(
                      fontSize: 13,
                      fontFamily: field.controller.text.isNotEmpty
                          ? 'monospace'
                          : null,
                      fontWeight: field.controller.text.isNotEmpty
                          ? FontWeight.bold
                          : FontWeight.normal,
                      color: field.controller.text.isNotEmpty
                          ? Colors.white
                          : const Color(0xFF475569),
                    ),
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                  ),
                ],
              ),
            ),

            if (isActive)
              const Icon(
                Icons.arrow_left,
                color: Color(0xFF3B82F6),
                size: 20,
              ),
          ],
        ),
      ),
    );
  }
}
4
likes
160
points
--
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A powerful Flutter widget that lets users select and crop any area of the UI and automatically extracts text using on-device OCR or custom backends.

Homepage
Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter, google_mlkit_text_recognition, http, path_provider

More

Packages that depend on selectable_ocr