Build an AI Document Scanner App

Scan documents with ML Kit OCR, Clean Architecture, and BLoC in Flutter.

Introduction

30 Second Summary

Paper documents pile up everywhere. Receipts, invoices, ID cards, and academic records all need to be captured, organized, and searchable without relying on cloud services.

In this project, you will build an AI-powered document scanner app that captures physical documents, extracts text using on-device machine learning, and stores everything in a searchable local database.

What You'll Build

A production-grade Flutter app that uses Google ML Kit for document scanning and OCR, auto-categorizes documents by type, and provides instant full-text search across all extracted text.

By the end of this project, you'll have:

  • A Clean Architecture project structure with separated Data, Domain, and Presentation layers.
  • A working document scanner with native edge detection, on-device OCR, and automatic categorization of receipts, invoices, ID cards, and academic documents.
  • A Drift database with FTS5 full-text search enabling instant search across all scanned document text.
  • Secret Mission: Custom image filters (grayscale, high-contrast B&W) processed in background isolates for jank-free 60fps performance.

Are there any prerequisites?

This project assumes solid Flutter fundamentals including widgets, navigation, and async/await. You should also be familiar with state management concepts and have a basic understanding of dependency injection.

You will need a physical Android device for testing the document scanner. The ML Kit document scanner package is Android-only and requires a real device with Google Play Services.

Set Up the Project and Clean Architecture Foundation

Building a production-grade document scanner requires a solid foundation. You need a project structure that separates concerns cleanly so the scanning logic, database operations, and UI code never bleed into each other.

In this step, you will create the Flutter project, install all the packages it needs, and lay out the Clean Architecture folder structure. You will also configure the Drift database with full-text search so documents are searchable from day one.

In this step, get ready to:
  • Create the Flutter project and add all package dependencies.
  • Set up the Clean Architecture folder structure with dependency injection.
  • Configure the Drift database with FTS5 full-text search.
Create the Flutter project and install dependencies

Every Flutter app starts with a scaffolded project. The flutter create command generates the initial file structure, and the --org flag sets your app's bundle identifier prefix.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Flutter project by running this command:
flutter create --org com.docscanner ai_doc_scanner

What does this command do?

The flutter create command scaffolds a full Flutter app project. The --org com.docscanner flag sets the reverse domain name used for the Android package name and iOS bundle ID.

The command creates the ai_doc_scanner folder containing lib/, android/, ios/, pubspec.yaml, and all other standard Flutter files.

  • You should see a long list of created files ending with "All done!" in your terminal.
  • Move into your new project folder by running:
cd ai_doc_scanner
  • Open the project in VS Code by running:
code .

VS Code didn't open?

If the code command is not recognized, open VS Code manually. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), type "Shell Command: Install 'code' command in PATH", and run it. Then try code . again.

Alternatively, open VS Code, click File then Open Folder, and select the ai_doc_scanner folder on your Desktop.

Help me open my project in VS Code.

Now you need to replace the default dependencies with the ones this project requires.

  • In VS Code, open pubspec.yaml from the Explorer sidebar on the left.
  • Select all the content in the file and replace it with the following:
name: ai_doc_scanner
description: AI Document Scanner with Clean Architecture
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: '>=3.0.0 <4.0.0'

dependencies:
  flutter:
    sdk: flutter
  drift: ^2.14.0
  drift_flutter: ^0.2.0
  flutter_bloc: ^8.1.3
  get_it: ^7.6.4
  google_mlkit_document_scanner: ^0.1.0
  google_mlkit_text_recognition: ^0.11.0
  path_provider: ^2.1.1
  share_plus: ^7.2.1
  image: ^4.1.3

dev_dependencies:
  flutter_test:
    sdk: flutter
  drift_dev: ^2.14.0
  build_runner: ^2.4.7
  flutter_lints: ^3.0.0

flutter:
  uses-material-design: true

What are all these packages?

  • drift and drift_flutter provide a type-safe SQLite database with code generation and FTS5 full-text search.
  • flutter_bloc manages app state reactively using the BLoC pattern.
  • get_it is a lightweight service locator for dependency injection.
  • google_mlkit_document_scanner provides native document scanning with edge detection and perspective correction.
  • google_mlkit_text_recognition extracts text from images using on-device OCR.
  • image enables pixel-level image manipulation for filters in the Secret Mission.
  • drift_dev and build_runner are dev-only tools that generate database code from your table definitions.
  • Save pubspec.yaml.
  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Install all the dependencies by running:
flutter pub get
  • You should see output ending with a message confirming all packages were resolved successfully.

Seeing dependency resolution errors?

Make sure you saved pubspec.yaml before running the command. Check that your YAML indentation is correct. Each dependency should be indented with two spaces under its parent key.

Help me fix my pubspec.yaml dependency errors.

Next, you need to configure Android SDK versions for ML Kit compatibility.

  • In the Explorer sidebar, navigate to android > app > build.gradle.kts (or build.gradle if your project uses Groovy syntax).
  • Find the compileSdk line inside the android block. It currently references a Flutter variable:
compileSdk = flutter.compileSdkVersion
  • Replace it with an explicit value:
compileSdk = 35
  • Find the minSdk line inside defaultConfig. It currently looks like:
minSdk = flutter.minSdkVersion
  • Replace it with:
minSdk = 21
  • Find the targetSdk line in the same defaultConfig block and replace it with:
targetSdk = 35
  • Save the file.

Why set these SDK values explicitly?

Google ML Kit packages require minSdkVersion 21 and compileSdkVersion 35. Flutter's default values come from its internal tooling and may differ across versions. Setting explicit numbers guarantees your project builds with the correct Android SDK targets regardless of your Flutter installation.

Create the Clean Architecture folder structure

Clean Architecture separates your code into layers that depend only inward. The Domain layer defines business logic, the Data layer implements storage, and the Presentation layer handles the UI.

  • In your VS Code integrated terminal, create all the required folders by running:
mkdir -p lib/core/constants lib/core/utils lib/core/di lib/core/database lib/core/services lib/features/scanner/data/datasources lib/features/scanner/data/models lib/features/scanner/data/repositories lib/features/scanner/domain/entities lib/features/scanner/domain/repositories lib/features/scanner/domain/usecases lib/features/scanner/presentation/bloc lib/features/scanner/presentation/pages lib/features/scanner/presentation/widgets

What is this folder structure?

  • core/ holds shared utilities: dependency injection, database setup, constants, and services.
  • features/scanner/domain/ contains entities, repository contracts, and use cases. This layer has zero dependencies on Flutter or external packages.
  • features/scanner/data/ implements the domain contracts with concrete database access and models.
  • features/scanner/presentation/ contains BLoC state management, pages, and widgets.
  • Confirm the folders exist by running:
ls lib/core lib/features/scanner
  • You should see constants, database, di, services, utils under core, and data, domain, presentation under scanner.

Now set up the dependency injection container that will wire all layers together.

  • Create a new file at lib/core/di/injection_container.dart and add the following code:
import 'package:get_it/get_it.dart';

final GetIt sl = GetIt.instance;

Future<void> setupDependencies() async {
  // Dependencies will be registered here in subsequent steps
}

What does this code do?

GetIt is a service locator. The global sl variable provides a single access point for all your app's dependencies. Calling sl<SomeClass>() anywhere in the app returns the registered instance of that class.

The setupDependencies() function will grow as you add repositories, data sources, and BLoCs in later steps.

  • Save injection_container.dart.
Configure the Drift database with FTS5 search

Drift generates type-safe Dart code from your table definitions. You will define the documents table and add an FTS5 virtual table for instant full-text search across all scanned OCR text.

  • Create a new file at lib/core/database/app_database.dart and add the following code:
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';

part 'app_database.g.dart';

class ScannedDocuments extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get title => text()();
  TextColumn get category => text()();
  TextColumn get ocrText => text()();
  TextColumn get imagePath => text()();
  DateTimeColumn get createdAt => dateTime()();
}

@DriftDatabase(tables: [ScannedDocuments])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());

  @override
  int get schemaVersion => 1;

  @override
  MigrationStrategy get migration => MigrationStrategy(
    onCreate: (m) async {
      await m.createAll();
      await customStatement(
        'CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(title, ocr_text, content=scanned_documents, content_rowid=id)'
      );
    },
  );
}

QueryExecutor _openConnection() {
  return driftDatabase(name: 'app_database');
}

What does this code do?

  • ScannedDocuments defines the SQLite table schema with columns for document metadata, OCR text, and the image file path.
  • @DriftDatabase tells Drift's code generator to create the _$AppDatabase superclass with all the query methods.
  • The MigrationStrategy runs on first launch. After creating the main table, it adds an FTS5 virtual table called documents_fts that indexes the title and ocr_text columns for fast keyword search.
  • driftDatabase(name: 'app_database') opens (or creates) the SQLite file using Drift's native platform support.
  • Save app_database.dart.
  • Run the Drift code generator by running this command in your terminal:
dart run build_runner build
  • You should see output indicating code generation succeeded, ending with a message that mentions generating app_database.g.dart.
  • Confirm the generated file exists by checking the Explorer sidebar. Expand lib/core/database/ and you should see app_database.g.dart alongside your original file.

Build runner failed?

If you see errors about missing imports, make sure you ran flutter pub get successfully first. The part 'app_database.g.dart' directive causes an error in your editor until code generation runs. That is expected.

If build_runner reports conflicts, try running with the delete flag:

dart run build_runner build --delete-conflicting-outputs

Help me fix my Drift code generation errors.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is pubspec.yaml:

name: ai_doc_scanner
description: AI Document Scanner with Clean Architecture
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: '>=3.0.0 <4.0.0'

dependencies:
  flutter:
    sdk: flutter
  drift: ^2.14.0
  drift_flutter: ^0.2.0
  flutter_bloc: ^8.1.3
  get_it: ^7.6.4
  google_mlkit_document_scanner: ^0.1.0
  google_mlkit_text_recognition: ^0.11.0
  path_provider: ^2.1.1
  share_plus: ^7.2.1
  image: ^4.1.3

dev_dependencies:
  flutter_test:
    sdk: flutter
  drift_dev: ^2.14.0
  build_runner: ^2.4.7
  flutter_lints: ^3.0.0

flutter:
  uses-material-design: true

Here is lib/core/di/injection_container.dart:

import 'package:get_it/get_it.dart';

final GetIt sl = GetIt.instance;

Future<void> setupDependencies() async {
  // Dependencies will be registered here in subsequent steps
}

Here is lib/core/database/app_database.dart:

import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';

part 'app_database.g.dart';

class ScannedDocuments extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get title => text()();
  TextColumn get category => text()();
  TextColumn get ocrText => text()();
  TextColumn get imagePath => text()();
  DateTimeColumn get createdAt => dateTime()();
}

@DriftDatabase(tables: [ScannedDocuments])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());

  @override
  int get schemaVersion => 1;

  @override
  MigrationStrategy get migration => MigrationStrategy(
    onCreate: (m) async {
      await m.createAll();
      await customStatement(
        'CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(title, ocr_text, content=scanned_documents, content_rowid=id)'
      );
    },
  );
}

QueryExecutor _openConnection() {
  return driftDatabase(name: 'app_database');
}

Your project foundation is solid. You have a Clean Architecture folder structure, dependency injection ready to go, and a database that indexes document text for instant search. Next up, you will build the Domain and Data layers that define how documents flow through the app.

Build the Domain and Data Layers

Your project structure and database are ready. Now you need to define what your app actually does with that data.

In Clean Architecture, the Domain layer holds your business rules (entities, repository contracts, use cases) and the Data layer handles persistence. Building these layers first means your app's core logic exists independently of any UI or framework code.

In this step, get ready to:
  • Define the Domain layer with entities, repository contracts, and use cases.
  • Implement the Data layer with database queries and a repository implementation.
  • Create a keyword-based document categorization service.
Define the Domain layer

The Domain layer is the heart of your app. It defines the data your app works with and the operations it can perform, without knowing anything about databases, APIs, or UI frameworks.

  • Create the Document entity class in features/scanner/domain/entities/document.dart by adding the following code:
class Document {
  final int? id;
  final String title;
  final String category;
  final String ocrText;
  final String imagePath;
  final DateTime createdAt;

  const Document({
    this.id,
    required this.title,
    required this.category,
    required this.ocrText,
    required this.imagePath,
    required this.createdAt,
  });
}

What does this code do?

The Document entity is a plain Dart class that represents a scanned document in your app. It holds the document's title, its auto-assigned category, the extracted OCR text, the path to the saved image, and a timestamp.

Notice id is nullable (int?). A new document doesn't have an ID yet because the database assigns one on insert.

  • Save document.dart.
  • You won't see a change yet. The next file uses this entity to define the repository contract.
  • Create the DocumentRepository abstract class in features/scanner/domain/repositories/document_repository.dart by adding this code:
import '../entities/document.dart';

abstract class DocumentRepository {
  Future<void> saveDocument(Document document);
  Future<List<Document>> getAllDocuments();
  Future<List<Document>> searchDocuments(String query);
  Future<void> deleteDocument(int id);
  Future<List<Document>> getDocumentsByCategory(String category);
}

What is an abstract repository?

An abstract class defines a contract. It says "any class that implements me must provide these methods" without specifying how they work. This is the core of Clean Architecture. Your Domain layer demands these five operations exist, but the Data layer decides how to fulfill them (with Drift, an API, or anything else).

  • Save document_repository.dart.
  • You won't see a change yet. The use cases below will reference this repository contract.

Use cases are single-purpose classes that encapsulate one business operation. Each use case calls the repository to do its job.

  • Create features/scanner/domain/usecases/get_all_documents.dart with the following code:
import '../entities/document.dart';
import '../repositories/document_repository.dart';

class GetAllDocuments {
  final DocumentRepository repository;
  GetAllDocuments(this.repository);

  Future<List<Document>> call() => repository.getAllDocuments();
}
  • Create features/scanner/domain/usecases/search_documents.dart with this code:
import '../entities/document.dart';
import '../repositories/document_repository.dart';

class SearchDocuments {
  final DocumentRepository repository;
  SearchDocuments(this.repository);

  Future<List<Document>> call(String query) => repository.searchDocuments(query);
}
  • Create features/scanner/domain/usecases/delete_document.dart with this code:
import '../repositories/document_repository.dart';

class DeleteDocument {
  final DocumentRepository repository;
  DeleteDocument(this.repository);

  Future<void> call(int id) => repository.deleteDocument(id);
}
  • Create a placeholder file at features/scanner/domain/usecases/scan_and_save_document.dart with this comment:
// Placeholder — wired in Step 3 after ScannerService is implemented

What does the use case pattern do?

Each use case is a class with a single call() method. This makes them callable like functions (getAllDocuments() instead of getAllDocuments.execute()). Each one wraps exactly one repository operation, keeping your business logic granular and testable.

The ScanAndSaveDocument use case is a placeholder for now because it depends on the scanner service you'll build in Step 3.

  • Save all four use case files.
  • Verify there are no import errors in VS Code by checking that the Problems tab at the bottom of VS Code shows no errors for these files.

Seeing import errors?

Make sure the document.dart entity file exists at the exact path features/scanner/domain/entities/document.dart. The use cases import this file via a relative path, so any typo in the folder name will break it.

Also confirm your document_repository.dart is in features/scanner/domain/repositories/. Both paths must match exactly.

Help me fix import errors in my domain layer files.

Implement the Data layer

The Data layer is where your abstract repository contract meets reality. Here you'll write the actual database queries that fulfill the Domain layer's promises.

  • Create the DocumentLocalDataSource class in features/scanner/data/datasources/document_local_datasource.dart by adding this code:
import 'package:drift/drift.dart';
import '../../../../core/database/app_database.dart';
import '../../domain/entities/document.dart';

class DocumentLocalDataSource {
  final AppDatabase db;
  DocumentLocalDataSource(this.db);

  Future<void> saveDocument(Document doc) async {
    await db.into(db.scannedDocuments).insert(
      ScannedDocumentsCompanion.insert(
        title: doc.title,
        category: doc.category,
        ocrText: doc.ocrText,
        imagePath: doc.imagePath,
        createdAt: doc.createdAt,
      ),
    );
  }

  Future<List<Document>> getAllDocuments() async {
    final rows = await db.select(db.scannedDocuments).get();
    return rows.map(_rowToDocument).toList();
  }

  Future<List<Document>> searchDocuments(String query) async {
    final rows = await db.customSelect(
      'SELECT s.* FROM scanned_documents s JOIN documents_fts fts ON s.id = fts.rowid WHERE documents_fts MATCH ?',
      variables: [Variable.withString(query)],
      readsFrom: {db.scannedDocuments},
    ).get();
    return rows.map((row) => Document(
      id: row.read<int>('id'),
      title: row.read<String>('title'),
      category: row.read<String>('category'),
      ocrText: row.read<String>('ocr_text'),
      imagePath: row.read<String>('image_path'),
      createdAt: DateTime.fromMillisecondsSinceEpoch(row.read<int>('created_at')),
    )).toList();
  }

  Future<void> deleteDocument(int id) async {
    await (db.delete(db.scannedDocuments)..where((t) => t.id.equals(id))).go();
  }

  Future<List<Document>> getDocumentsByCategory(String category) async {
    final rows = await (db.select(db.scannedDocuments)
      ..where((t) => t.category.equals(category))).get();
    return rows.map(_rowToDocument).toList();
  }

  Document _rowToDocument(ScannedDocument row) => Document(
    id: row.id,
    title: row.title,
    category: row.category,
    ocrText: row.ocrText,
    imagePath: row.imagePath,
    createdAt: row.createdAt,
  );
}

What does this code do?

This data source class is the only place in your app that talks directly to the Drift database. Each method maps to one repository operation.

  • saveDocument inserts a new row using Drift's type-safe Companion pattern.
  • searchDocuments uses a raw SQL query that joins the FTS5 virtual table with MATCH for full-text search across OCR content.
  • _rowToDocument is a helper that converts Drift's generated ScannedDocument row into your domain Document entity.
  • Save document_local_datasource.dart.
  • You won't see a change yet. The repository implementation below connects this data source to the Domain layer.
  • Create the DocumentRepositoryImpl class in features/scanner/data/repositories/document_repository_impl.dart by adding this code:
import '../../domain/entities/document.dart';
import '../../domain/repositories/document_repository.dart';
import '../datasources/document_local_datasource.dart';

class DocumentRepositoryImpl implements DocumentRepository {
  final DocumentLocalDataSource localDataSource;
  DocumentRepositoryImpl(this.localDataSource);

  @override
  Future<void> saveDocument(Document document) => localDataSource.saveDocument(document);

  @override
  Future<List<Document>> getAllDocuments() => localDataSource.getAllDocuments();

  @override
  Future<List<Document>> searchDocuments(String query) => localDataSource.searchDocuments(query);

  @override
  Future<void> deleteDocument(int id) => localDataSource.deleteDocument(id);

  @override
  Future<List<Document>> getDocumentsByCategory(String category) => localDataSource.getDocumentsByCategory(category);
}

Why is this class so thin?

The repository implementation simply delegates to the data source. This might seem redundant now, but it becomes valuable when you add caching, error handling, or multiple data sources (like a remote API). The repository coordinates between sources. The data source handles one specific source.

  • Save document_repository_impl.dart.
  • You won't see a change yet. The next chunk wires everything together in the dependency injection container.
  • Update core/di/injection_container.dart to register all the new dependencies by replacing its contents with:
import 'package:get_it/get_it.dart';
import '../database/app_database.dart';
import '../services/categorization_service.dart';
import '../../features/scanner/data/datasources/document_local_datasource.dart';
import '../../features/scanner/data/repositories/document_repository_impl.dart';
import '../../features/scanner/domain/repositories/document_repository.dart';
import '../../features/scanner/domain/usecases/get_all_documents.dart';
import '../../features/scanner/domain/usecases/search_documents.dart';
import '../../features/scanner/domain/usecases/delete_document.dart';

final GetIt sl = GetIt.instance;

Future<void> setupDependencies() async {
  // Database
  sl.registerLazySingleton<AppDatabase>(() => AppDatabase());

  // Services
  sl.registerLazySingleton<CategorizationService>(() => CategorizationService());

  // Data sources
  sl.registerLazySingleton<DocumentLocalDataSource>(
    () => DocumentLocalDataSource(sl<AppDatabase>()),
  );

  // Repositories
  sl.registerLazySingleton<DocumentRepository>(
    () => DocumentRepositoryImpl(sl<DocumentLocalDataSource>()),
  );

  // Use cases
  sl.registerLazySingleton(() => GetAllDocuments(sl<DocumentRepository>()));
  sl.registerLazySingleton(() => SearchDocuments(sl<DocumentRepository>()));
  sl.registerLazySingleton(() => DeleteDocument(sl<DocumentRepository>()));
}

What is dependency injection doing here?

GetIt is a service locator. Each registerLazySingleton call tells GetIt: "When someone asks for this type, create it once and reuse it forever." The dependencies chain together. The repository needs the data source, which needs the database.

The sl<AppDatabase>() calls retrieve already-registered instances. Order matters. The database is registered first so the data source can find it.

  • Save injection_container.dart.
  • Verify your Data layer compiles by running this command in the terminal:
flutter analyze

You should see no errors in the output. You may see a warning about the CategorizationService import since you haven't created that file yet. That's expected and you'll fix it in the next substep.

Seeing multiple errors?

If you see errors about ScannedDocumentsCompanion or ScannedDocument not being found, make sure the app_database.g.dart file from Step 1 still exists. These classes are generated by Drift's code generation.

If the generated file is missing, run dart run build_runner build again from your project root.

Help me fix Drift code generation errors in my data layer.

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files before moving on.

ⓧ I'd like to double check the full code

Here's the full injection_container.dart for comparison:

import 'package:get_it/get_it.dart';
import '../database/app_database.dart';
import '../services/categorization_service.dart';
import '../../features/scanner/data/datasources/document_local_datasource.dart';
import '../../features/scanner/data/repositories/document_repository_impl.dart';
import '../../features/scanner/domain/repositories/document_repository.dart';
import '../../features/scanner/domain/usecases/get_all_documents.dart';
import '../../features/scanner/domain/usecases/search_documents.dart';
import '../../features/scanner/domain/usecases/delete_document.dart';

final GetIt sl = GetIt.instance;

Future<void> setupDependencies() async {
  // Database
  sl.registerLazySingleton<AppDatabase>(() => AppDatabase());

  // Services
  sl.registerLazySingleton<CategorizationService>(() => CategorizationService());

  // Data sources
  sl.registerLazySingleton<DocumentLocalDataSource>(
    () => DocumentLocalDataSource(sl<AppDatabase>()),
  );

  // Repositories
  sl.registerLazySingleton<DocumentRepository>(
    () => DocumentRepositoryImpl(sl<DocumentLocalDataSource>()),
  );

  // Use cases
  sl.registerLazySingleton(() => GetAllDocuments(sl<DocumentRepository>()));
  sl.registerLazySingleton(() => SearchDocuments(sl<DocumentRepository>()));
  sl.registerLazySingleton(() => DeleteDocument(sl<DocumentRepository>()));
}

And here's the full document_local_datasource.dart:

import 'package:drift/drift.dart';
import '../../../../core/database/app_database.dart';
import '../../domain/entities/document.dart';

class DocumentLocalDataSource {
  final AppDatabase db;
  DocumentLocalDataSource(this.db);

  Future<void> saveDocument(Document doc) async {
    await db.into(db.scannedDocuments).insert(
      ScannedDocumentsCompanion.insert(
        title: doc.title,
        category: doc.category,
        ocrText: doc.ocrText,
        imagePath: doc.imagePath,
        createdAt: doc.createdAt,
      ),
    );
  }

  Future<List<Document>> getAllDocuments() async {
    final rows = await db.select(db.scannedDocuments).get();
    return rows.map(_rowToDocument).toList();
  }

  Future<List<Document>> searchDocuments(String query) async {
    final rows = await db.customSelect(
      'SELECT s.* FROM scanned_documents s JOIN documents_fts fts ON s.id = fts.rowid WHERE documents_fts MATCH ?',
      variables: [Variable.withString(query)],
      readsFrom: {db.scannedDocuments},
    ).get();
    return rows.map((row) => Document(
      id: row.read<int>('id'),
      title: row.read<String>('title'),
      category: row.read<String>('category'),
      ocrText: row.read<String>('ocr_text'),
      imagePath: row.read<String>('image_path'),
      createdAt: DateTime.fromMillisecondsSinceEpoch(row.read<int>('created_at')),
    )).toList();
  }

  Future<void> deleteDocument(int id) async {
    await (db.delete(db.scannedDocuments)..where((t) => t.id.equals(id))).go();
  }

  Future<List<Document>> getDocumentsByCategory(String category) async {
    final rows = await (db.select(db.scannedDocuments)
      ..where((t) => t.category.equals(category))).get();
    return rows.map(_rowToDocument).toList();
  }

  Document _rowToDocument(ScannedDocument row) => Document(
    id: row.id,
    title: row.title,
    category: row.category,
    ocrText: row.ocrText,
    imagePath: row.imagePath,
    createdAt: row.createdAt,
  );
}
Implement the categorization service

When your app scans a document and extracts text, it needs to automatically classify what kind of document it is. You'll build a keyword-matching engine that detects patterns in the OCR text.

  • Create core/services/categorization_service.dart with the following code:
class CategorizationService {
  String categorize(String text) {
    final lower = text.toLowerCase();
    if (_containsAny(lower, ['total', 'subtotal', 'tax', 'payment'])) {
      return 'Receipt';
    } else if (_containsAny(lower, ['invoice', 'due date', 'bill to'])) {
      return 'Invoice';
    } else if (_containsAny(lower, ['name', 'date of birth', 'nationality', 'passport'])) {
      return 'ID Card';
    } else if (_containsAny(lower, ['university', 'grade', 'semester', 'student'])) {
      return 'Academic';
    }
    return 'General';
  }

  bool _containsAny(String text, List<String> keywords) {
    return keywords.any((k) => text.contains(k));
  }
}

What does this code do?

The categorize() method takes OCR-extracted text and checks it against keyword groups. If the text contains "total", "subtotal", "tax", or "payment", it's probably a receipt. If none of the keyword groups match, the document gets a "General" fallback category.

The _containsAny helper returns true if the text contains at least one keyword from the list. Converting to lowercase first ensures the matching is case-insensitive.

  • Save categorization_service.dart.
  • Run the analyzer again to verify everything compiles cleanly:
flutter analyze

You should see no errors in the output. The CategorizationService import in injection_container.dart now resolves correctly.

Still seeing errors?

Check that your categorization_service.dart file is in core/services/, not inside features/. The import path in injection_container.dart expects it at ../services/categorization_service.dart relative to the core/di/ folder.

Help me fix the remaining analyzer errors in my project.

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files before moving on.

ⓧ I'd like to double check the full code

Here's the full categorization_service.dart:

class CategorizationService {
  String categorize(String text) {
    final lower = text.toLowerCase();
    if (_containsAny(lower, ['total', 'subtotal', 'tax', 'payment'])) {
      return 'Receipt';
    } else if (_containsAny(lower, ['invoice', 'due date', 'bill to'])) {
      return 'Invoice';
    } else if (_containsAny(lower, ['name', 'date of birth', 'nationality', 'passport'])) {
      return 'ID Card';
    } else if (_containsAny(lower, ['university', 'grade', 'semester', 'student'])) {
      return 'Academic';
    }
    return 'General';
  }

  bool _containsAny(String text, List<String> keywords) {
    return keywords.any((k) => text.contains(k));
  }
}

And here's the full document_repository_impl.dart:

import '../../domain/entities/document.dart';
import '../../domain/repositories/document_repository.dart';
import '../datasources/document_local_datasource.dart';

class DocumentRepositoryImpl implements DocumentRepository {
  final DocumentLocalDataSource localDataSource;
  DocumentRepositoryImpl(this.localDataSource);

  @override
  Future<void> saveDocument(Document document) => localDataSource.saveDocument(document);

  @override
  Future<List<Document>> getAllDocuments() => localDataSource.getAllDocuments();

  @override
  Future<List<Document>> searchDocuments(String query) => localDataSource.searchDocuments(query);

  @override
  Future<void> deleteDocument(int id) => localDataSource.deleteDocument(id);

  @override
  Future<List<Document>> getDocumentsByCategory(String category) => localDataSource.getDocumentsByCategory(category);
}

Your Domain and Data layers are locked in. Next up, you'll integrate Google ML Kit to actually scan documents and feed real OCR text through this pipeline.

Integrate ML Kit Document Scanner and OCR

Your Domain and Data layers are wired up and ready to persist documents. Now you need a way to actually capture those documents from the real world.

In this step, you will integrate Google ML Kit's document scanner to capture physical documents with native edge detection, then extract their text using on-device OCR. You will tie the full pipeline together in a use case that scans, reads, categorizes, and persists documents in one call.

In this step, get ready to:
  • Configure Android permissions and create the ScannerService wrapping ML Kit.
  • Implement the scan-to-OCR pipeline with background text extraction.
  • Create the ScanDocumentUseCase that orchestrates scanning, OCR, categorization, and persistence.
Configure Android permissions and create the ScannerService

The ML Kit Document Scanner uses Google Play Services under the hood to handle camera access and edge detection. You still need to declare the camera permission in your manifest so Android grants your app access on devices running API 23+.

  • Open android/app/src/main/AndroidManifest.xml in VS Code.
  • Add the camera permission inside the <manifest> tag, above the <application> tag:
<uses-permission android:name="android.permission.CAMERA" />

What does this permission do?

Declaring android.permission.CAMERA tells Android your app needs camera access. On Android 6.0+ this triggers a runtime permission dialog the first time the scanner launches.

The ML Kit document scanner handles the actual camera UI via Google Play Services. Your app never manages the camera directly.

  • Save AndroidManifest.xml.

Now you will create the ScannerService class in the Data layer. This service wraps ML Kit's document scanner and text recognizer into two clean methods your use case can call.

  • Create a new file at lib/features/scanner/data/datasources/scanner_service.dart and paste the following code:
import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';

class ScannerService {
  // Launch the native document scanner UI and return captured image paths
  Future<List<String>?> scan() async {
    final options = DocumentScannerOptions(
      pageLimit: 5,
      mode: ScannerMode.full,
      isGalleryImport: true,
      documentFormat: DocumentFormat.jpeg,
    );
    final scanner = DocumentScanner(options: options);
    try {
      final result = await scanner.scanDocument();
      return result.images;
    } catch (e) {
      return null;
    } finally {
      scanner.close();
    }
  }

  // Extract text from a scanned image using on-device OCR
  Future<String> extractText(String imagePath) async {
    final textRecognizer = TextRecognizer();
    try {
      final inputImage = InputImage.fromFilePath(imagePath);
      final recognized = await textRecognizer.processImage(inputImage);
      return recognized.blocks.map((b) => b.text).join('\n');
    } finally {
      textRecognizer.close();
    }
  }
}

What does this code do?

The scan() method creates a DocumentScannerOptions with up to 5 pages, full scanning mode (edge detection + perspective correction + auto-capture), gallery import enabled, and JPEG output. It then launches the native scanner UI and returns the list of captured image file paths.

The extractText() method creates a TextRecognizer instance, feeds it an image, and joins all recognized text blocks into a single string separated by newlines. The recognizer is always closed in the finally block to free native resources.

  • Save scanner_service.dart.
  • Confirm no red squiggles appear in the file. If imports show errors, run flutter pub get to ensure the ML Kit packages are resolved.

Seeing import errors for ML Kit packages?

Make sure google_mlkit_document_scanner and google_mlkit_text_recognition are listed in your pubspec.yaml dependencies. Run flutter pub get again if you added them recently.

Also verify your android/app/build.gradle has minSdkVersion 21 and compileSdkVersion 35. These packages require both.

Help me fix ML Kit import errors in my Flutter project.

Define typed failure classes and the ScanDocumentUseCase

In Clean Architecture, use cases should communicate failures through typed classes rather than raw exceptions. This gives the presentation layer precise information about what went wrong.

  • Create a new file at lib/core/utils/failures.dart and paste the following:
abstract class Failure {
  final String message;
  const Failure(this.message);
}

// Returned when the user dismisses the scanner without capturing
class ScannerCancelledFailure extends Failure {
  const ScannerCancelledFailure() : super('Scanner was cancelled by the user.');
}

// Returned when text recognition fails on a scanned image
class OcrFailure extends Failure {
  const OcrFailure(String message) : super(message);
}

// Returned when saving to the database fails
class StorageFailure extends Failure {
  const StorageFailure(String message) : super(message);
}

What does this code do?

The abstract Failure class holds a human-readable message string. Three concrete subtypes represent the three things that can go wrong in the scanning pipeline: the user cancels, OCR fails on an image, or the database save fails.

Your BLoC (in the next step) will match on these types to show the right error UI to the user.

  • Save failures.dart.

Now create the use case that orchestrates the full scan-to-save pipeline. It calls the scanner, runs OCR on each image, categorizes the text, and persists the document.

  • Create a new file at lib/features/scanner/domain/usecases/scan_document_use_case.dart and paste the following:
import 'dart:isolate';
import '../entities/document.dart';
import '../repositories/document_repository.dart';
import '../../../../core/services/categorization_service.dart';
import '../../data/datasources/scanner_service.dart';

class ScanDocumentUseCase {
  final ScannerService scannerService;
  final DocumentRepository repository;
  final CategorizationService categorizationService;

  ScanDocumentUseCase({
    required this.scannerService,
    required this.repository,
    required this.categorizationService,
  });

  Future<void> call() async {
    // Launch the native scanner and get captured image paths
    final imagePaths = await scannerService.scan();
    if (imagePaths == null || imagePaths.isEmpty) return;

    // Process each scanned page
    for (final imagePath in imagePaths) {
      // Run OCR in a background isolate to keep UI responsive
      final ocrText = await Isolate.run(() => scannerService.extractText(imagePath));

      // Auto-categorize based on extracted keywords
      final category = categorizationService.categorize(ocrText);

      // Build and persist the document entity
      final document = Document(
        title: 'Document ${DateTime.now().millisecondsSinceEpoch}',
        category: category,
        ocrText: ocrText,
        imagePath: imagePath,
        createdAt: DateTime.now(),
      );
      await repository.saveDocument(document);
    }
  }
}

What does this code do?

The call() method is the single entry point. It triggers the scanner, then loops through each returned image path. For each image it extracts text via Isolate.run() to offload processing from the main thread, categorizes the text, constructs a Document entity, and saves it through the repository.

If the user cancels (imagePaths is null or empty), the method returns immediately without throwing.

Why use Isolate.run() for OCR?

The TextRecognizer performs heavy native processing when recognizing text from an image. Wrapping the call in Isolate.run() ensures the post-processing (combining text blocks, string manipulation) does not block the main Dart thread while the recognizer works.

This pattern keeps the UI responsive at 60fps even when processing multi-page documents with dense text.

  • Save scan_document_use_case.dart.

Red squiggles on Isolate.run?

Make sure your Dart SDK constraint in pubspec.yaml is >=3.0.0. The Isolate.run() API was introduced in Dart 3.0.

Help me fix Isolate.run errors in my Dart project.

Wire the scanner into the dependency injection container

With the ScannerService and ScanDocumentUseCase created, you need to register them in GetIt so the rest of the app can access them through Dependency Injection.

  • Open lib/core/di/injection_container.dart.
  • Replace the entire file contents with the following updated version:
import 'package:get_it/get_it.dart';
import '../database/app_database.dart';
import '../services/categorization_service.dart';
import '../../features/scanner/data/datasources/document_local_datasource.dart';
import '../../features/scanner/data/datasources/scanner_service.dart';
import '../../features/scanner/data/repositories/document_repository_impl.dart';
import '../../features/scanner/domain/repositories/document_repository.dart';
import '../../features/scanner/domain/usecases/get_all_documents.dart';
import '../../features/scanner/domain/usecases/search_documents.dart';
import '../../features/scanner/domain/usecases/delete_document.dart';
import '../../features/scanner/domain/usecases/scan_document_use_case.dart';

final GetIt sl = GetIt.instance;

Future<void> setupDependencies() async {
  sl.registerLazySingleton<AppDatabase>(() => AppDatabase());
  sl.registerLazySingleton<CategorizationService>(() => CategorizationService());
  sl.registerLazySingleton<DocumentLocalDataSource>(
    () => DocumentLocalDataSource(sl<AppDatabase>()),
  );
  sl.registerLazySingleton<DocumentRepository>(
    () => DocumentRepositoryImpl(sl<DocumentLocalDataSource>()),
  );
  sl.registerLazySingleton<ScannerService>(() => ScannerService());
  sl.registerLazySingleton(() => GetAllDocuments(sl<DocumentRepository>()));
  sl.registerLazySingleton(() => SearchDocuments(sl<DocumentRepository>()));
  sl.registerLazySingleton(() => DeleteDocument(sl<DocumentRepository>()));
  sl.registerLazySingleton(() => ScanDocumentUseCase(
    scannerService: sl<ScannerService>(),
    repository: sl<DocumentRepository>(),
    categorizationService: sl<CategorizationService>(),
  ));
}

What changed here?

Two new registrations were added. ScannerService is registered as a lazy singleton so only one instance exists. ScanDocumentUseCase is registered with its three dependencies injected from the container.

The import for scanner_service.dart and scan_document_use_case.dart were added at the top. Everything else remains the same.

  • Save injection_container.dart.
  • In your terminal, run the following to confirm everything compiles:
flutter analyze
  • You should see no errors. Warnings about unused imports or variables are fine for now.

Seeing analysis errors?

Double check that the import paths in injection_container.dart match your actual file locations. The path ../../features/scanner/data/datasources/scanner_service.dart assumes the DI file is at lib/core/di/injection_container.dart.

Help me fix import path errors in my injection container.

Now test the full pipeline on a real device. The ML Kit document scanner requires Google Play Services and cannot run on an emulator.

  • Connect your Android physical device via USB.
  • Run the app on your device by running:
flutter run

Why a physical device?

The google_mlkit_document_scanner package relies on Google Play Services' ML Kit module, which is only available on physical Android devices. The emulator does not include this module.

You will need a physical Android device for all scanning-related testing throughout this project.

  • Once the app launches, trigger the scan by calling ScanDocumentUseCase from your main app or a temporary test button. The native scanner UI should appear with edge detection guides.
  • Scan a document (a receipt, an ID card, or any printed text). After scanning, the app processes OCR and saves the document to your local database.
  • Verify by adding a temporary print statement or checking the database. You should see the extracted OCR text and the auto-assigned category matching the document content.

Scanner not launching on your device?

Make sure your device has Google Play Services installed and updated. The ML Kit document scanner module downloads on first use, so the first launch may take a few extra seconds.

If the scanner crashes immediately, check that minSdkVersion is set to 21 in android/app/build.gradle. The ML Kit document scanner requires at minimum API 21.

Help me debug the ML Kit document scanner not launching.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three new files plus the updated injection container.

ⓧ I'd like to double check the full code

Here is the complete code for each file created or modified in this step.

import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';

class ScannerService {
  Future<List<String>?> scan() async {
    final options = DocumentScannerOptions(
      pageLimit: 5,
      mode: ScannerMode.full,
      isGalleryImport: true,
      documentFormat: DocumentFormat.jpeg,
    );
    final scanner = DocumentScanner(options: options);
    try {
      final result = await scanner.scanDocument();
      return result.images;
    } catch (e) {
      return null;
    } finally {
      scanner.close();
    }
  }

  Future<String> extractText(String imagePath) async {
    final textRecognizer = TextRecognizer();
    try {
      final inputImage = InputImage.fromFilePath(imagePath);
      final recognized = await textRecognizer.processImage(inputImage);
      return recognized.blocks.map((b) => b.text).join('\n');
    } finally {
      textRecognizer.close();
    }
  }
}
abstract class Failure {
  final String message;
  const Failure(this.message);
}

class ScannerCancelledFailure extends Failure {
  const ScannerCancelledFailure() : super('Scanner was cancelled by the user.');
}

class OcrFailure extends Failure {
  const OcrFailure(String message) : super(message);
}

class StorageFailure extends Failure {
  const StorageFailure(String message) : super(message);
}
import 'dart:isolate';
import '../entities/document.dart';
import '../repositories/document_repository.dart';
import '../../../../core/services/categorization_service.dart';
import '../../data/datasources/scanner_service.dart';

class ScanDocumentUseCase {
  final ScannerService scannerService;
  final DocumentRepository repository;
  final CategorizationService categorizationService;

  ScanDocumentUseCase({
    required this.scannerService,
    required this.repository,
    required this.categorizationService,
  });

  Future<void> call() async {
    final imagePaths = await scannerService.scan();
    if (imagePaths == null || imagePaths.isEmpty) return;

    for (final imagePath in imagePaths) {
      final ocrText = await Isolate.run(() => scannerService.extractText(imagePath));
      final category = categorizationService.categorize(ocrText);
      final document = Document(
        title: 'Document ${DateTime.now().millisecondsSinceEpoch}',
        category: category,
        ocrText: ocrText,
        imagePath: imagePath,
        createdAt: DateTime.now(),
      );
      await repository.saveDocument(document);
    }
  }
}
import 'package:get_it/get_it.dart';
import '../database/app_database.dart';
import '../services/categorization_service.dart';
import '../../features/scanner/data/datasources/document_local_datasource.dart';
import '../../features/scanner/data/datasources/scanner_service.dart';
import '../../features/scanner/data/repositories/document_repository_impl.dart';
import '../../features/scanner/domain/repositories/document_repository.dart';
import '../../features/scanner/domain/usecases/get_all_documents.dart';
import '../../features/scanner/domain/usecases/search_documents.dart';
import '../../features/scanner/domain/usecases/delete_document.dart';
import '../../features/scanner/domain/usecases/scan_document_use_case.dart';

final GetIt sl = GetIt.instance;

Future<void> setupDependencies() async {
  sl.registerLazySingleton<AppDatabase>(() => AppDatabase());
  sl.registerLazySingleton<CategorizationService>(() => CategorizationService());
  sl.registerLazySingleton<DocumentLocalDataSource>(
    () => DocumentLocalDataSource(sl<AppDatabase>()),
  );
  sl.registerLazySingleton<DocumentRepository>(
    () => DocumentRepositoryImpl(sl<DocumentLocalDataSource>()),
  );
  sl.registerLazySingleton<ScannerService>(() => ScannerService());
  sl.registerLazySingleton(() => GetAllDocuments(sl<DocumentRepository>()));
  sl.registerLazySingleton(() => SearchDocuments(sl<DocumentRepository>()));
  sl.registerLazySingleton(() => DeleteDocument(sl<DocumentRepository>()));
  sl.registerLazySingleton(() => ScanDocumentUseCase(
    scannerService: sl<ScannerService>(),
    repository: sl<DocumentRepository>(),
    categorizationService: sl<CategorizationService>(),
  ));
}

Your scanning pipeline is fully operational. Next up, you will build the BLoC state management and dashboard UI to give users a polished interface for viewing, searching, and managing their scanned documents.

Build the BLoC State Management and Dashboard UI

Your app can scan documents, extract text, and store everything in a local database. The missing piece is a way for the user to actually interact with all of this.

In this step, you will build the Presentation layer. This includes a BLoC (Business Logic Component) that reacts to user events and emits UI states, a dashboard page with search and filtering, and a detail page for viewing scanned documents.

In this step, get ready to:
  • Create the DocumentListBloc with events, states, and event handlers.
  • Build the Dashboard home screen with a search bar, category filter chips, and a document grid.
  • Build the Document Detail screen with OCR text display and share functionality.
Create the DocumentListBloc

The BLoC pattern separates UI logic from the widgets themselves. Your BLoC will accept user events (load documents, search, filter, delete, scan) and emit states that tell the UI what to render.

  • Create the file features/scanner/presentation/bloc/document_list_bloc.dart and add the following events and states:
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/entities/document.dart';
import '../../domain/usecases/get_all_documents.dart';
import '../../domain/usecases/search_documents.dart';
import '../../domain/usecases/delete_document.dart';
import '../../domain/usecases/scan_document_use_case.dart';

// Events
abstract class DocumentListEvent {}
class LoadDocuments extends DocumentListEvent {}
class SearchDocumentsEvent extends DocumentListEvent {
  final String query;
  SearchDocumentsEvent(this.query);
}
class FilterByCategory extends DocumentListEvent {
  final String? category;
  FilterByCategory(this.category);
}
class DeleteDocumentEvent extends DocumentListEvent {
  final int id;
  DeleteDocumentEvent(this.id);
}
class ScanDocumentEvent extends DocumentListEvent {}

// States
abstract class DocumentListState {}
class DocumentListInitial extends DocumentListState {}
class DocumentListLoading extends DocumentListState {}
class DocumentListLoaded extends DocumentListState {
  final List<Document> documents;
  DocumentListLoaded(this.documents);
}
class DocumentListError extends DocumentListState {
  final String message;
  DocumentListError(this.message);
}

What does this code do?

Events represent user actions. LoadDocuments fetches everything. SearchDocumentsEvent carries the search query. FilterByCategory holds an optional category string (null means "show all").

States represent what the UI should display. The BLoC transitions between DocumentListLoading (show a spinner), DocumentListLoaded (show the document list), and DocumentListError (show an error message).

  • In the same file, add the DocumentListBloc class below the states:
class DocumentListBloc extends Bloc<DocumentListEvent, DocumentListState> {
  final GetAllDocuments getAllDocuments;
  final SearchDocuments searchDocuments;
  final DeleteDocument deleteDocument;
  final ScanDocumentUseCase scanDocumentUseCase;

  DocumentListBloc({
    required this.getAllDocuments,
    required this.searchDocuments,
    required this.deleteDocument,
    required this.scanDocumentUseCase,
  }) : super(DocumentListInitial()) {
    on<LoadDocuments>((event, emit) async {
      emit(DocumentListLoading());
      try {
        final docs = await getAllDocuments();
        emit(DocumentListLoaded(docs));
      } catch (e) {
        emit(DocumentListError(e.toString()));
      }
    });
    on<SearchDocumentsEvent>((event, emit) async {
      emit(DocumentListLoading());
      try {
        final docs = event.query.isEmpty
            ? await getAllDocuments()
            : await searchDocuments(event.query);
        emit(DocumentListLoaded(docs));
      } catch (e) {
        emit(DocumentListError(e.toString()));
      }
    });
    on<FilterByCategory>((event, emit) async {
      emit(DocumentListLoading());
      try {
        final docs = await getAllDocuments();
        final filtered = event.category == null
            ? docs
            : docs.where((d) => d.category == event.category).toList();
        emit(DocumentListLoaded(filtered));
      } catch (e) {
        emit(DocumentListError(e.toString()));
      }
    });
    on<DeleteDocumentEvent>((event, emit) async {
      await deleteDocument(event.id);
      add(LoadDocuments());
    });
    on<ScanDocumentEvent>((event, emit) async {
      await scanDocumentUseCase();
      add(LoadDocuments());
    });
  }
}

What does this code do?

  • Each on<Event> handler calls the corresponding use case from the Domain layer and emits a new state with the result.
  • SearchDocumentsEvent falls back to loading all documents when the query is empty, giving you real-time search behavior as the user types.
  • FilterByCategory loads all documents first, then filters in-memory. For small local databases this is efficient and avoids extra DB queries.
  • After a delete or scan, the BLoC dispatches LoadDocuments() internally to refresh the list.
  • Save document_list_bloc.dart.

Seeing import errors?

Make sure the file path matches features/scanner/presentation/bloc/document_list_bloc.dart exactly. The imports reference use cases from the Domain layer that you created in the previous step.

help me fix import errors in my BLoC file

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/entities/document.dart';
import '../../domain/usecases/get_all_documents.dart';
import '../../domain/usecases/search_documents.dart';
import '../../domain/usecases/delete_document.dart';
import '../../domain/usecases/scan_document_use_case.dart';

// Events
abstract class DocumentListEvent {}
class LoadDocuments extends DocumentListEvent {}
class SearchDocumentsEvent extends DocumentListEvent {
  final String query;
  SearchDocumentsEvent(this.query);
}
class FilterByCategory extends DocumentListEvent {
  final String? category;
  FilterByCategory(this.category);
}
class DeleteDocumentEvent extends DocumentListEvent {
  final int id;
  DeleteDocumentEvent(this.id);
}
class ScanDocumentEvent extends DocumentListEvent {}

// States
abstract class DocumentListState {}
class DocumentListInitial extends DocumentListState {}
class DocumentListLoading extends DocumentListState {}
class DocumentListLoaded extends DocumentListState {
  final List<Document> documents;
  DocumentListLoaded(this.documents);
}
class DocumentListError extends DocumentListState {
  final String message;
  DocumentListError(this.message);
}

class DocumentListBloc extends Bloc<DocumentListEvent, DocumentListState> {
  final GetAllDocuments getAllDocuments;
  final SearchDocuments searchDocuments;
  final DeleteDocument deleteDocument;
  final ScanDocumentUseCase scanDocumentUseCase;

  DocumentListBloc({
    required this.getAllDocuments,
    required this.searchDocuments,
    required this.deleteDocument,
    required this.scanDocumentUseCase,
  }) : super(DocumentListInitial()) {
    on<LoadDocuments>((event, emit) async {
      emit(DocumentListLoading());
      try {
        final docs = await getAllDocuments();
        emit(DocumentListLoaded(docs));
      } catch (e) {
        emit(DocumentListError(e.toString()));
      }
    });
    on<SearchDocumentsEvent>((event, emit) async {
      emit(DocumentListLoading());
      try {
        final docs = event.query.isEmpty
            ? await getAllDocuments()
            : await searchDocuments(event.query);
        emit(DocumentListLoaded(docs));
      } catch (e) {
        emit(DocumentListError(e.toString()));
      }
    });
    on<FilterByCategory>((event, emit) async {
      emit(DocumentListLoading());
      try {
        final docs = await getAllDocuments();
        final filtered = event.category == null
            ? docs
            : docs.where((d) => d.category == event.category).toList();
        emit(DocumentListLoaded(filtered));
      } catch (e) {
        emit(DocumentListError(e.toString()));
      }
    });
    on<DeleteDocumentEvent>((event, emit) async {
      await deleteDocument(event.id);
      add(LoadDocuments());
    });
    on<ScanDocumentEvent>((event, emit) async {
      await scanDocumentUseCase();
      add(LoadDocuments());
    });
  }
}
Build the Dashboard home screen

The Dashboard is the entry point of your app. It provides the BLoC to the widget tree and rebuilds the UI whenever the BLoC emits a new state.

  • First, register the BLoC in GetIt. Open core/di/injection_container.dart and add this import at the top of the file:
import '../../features/scanner/presentation/bloc/document_list_bloc.dart';
  • At the end of the setupDependencies() function, add this registration:
  sl.registerFactory(() => DocumentListBloc(
    getAllDocuments: sl<GetAllDocuments>(),
    searchDocuments: sl<SearchDocuments>(),
    deleteDocument: sl<DeleteDocument>(),
    scanDocumentUseCase: sl<ScanDocumentUseCase>(),
  ));

Why registerFactory instead of registerLazySingleton?

A BLoC holds state. If you register it as a singleton, navigating away and back would reuse stale state. registerFactory creates a fresh BLoC instance each time it is requested, which is the recommended pattern.

  • Save injection_container.dart.
  • Now create the dashboard page. Create the file features/scanner/presentation/pages/dashboard_page.dart and paste the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection_container.dart';
import '../bloc/document_list_bloc.dart';
import 'document_detail_page.dart';

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

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      // Provide the BLoC and immediately load documents
      create: (_) => sl<DocumentListBloc>()..add(LoadDocuments()),
      child: const _DashboardView(),
    );
  }
}

class _DashboardView extends StatefulWidget {
  const _DashboardView();
  @override
  State<_DashboardView> createState() => _DashboardViewState();
}

class _DashboardViewState extends State<_DashboardView> {
  final _searchController = TextEditingController();
  String? _selectedCategory;
  final _categories = ['Receipt', 'Invoice', 'ID Card', 'Academic'];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('AI Doc Scanner')),
      body: Column(
        children: [
          // Search bar dispatches SearchDocumentsEvent on every keystroke
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: TextField(
              controller: _searchController,
              decoration: const InputDecoration(hintText: 'Search documents...', prefixIcon: Icon(Icons.search)),
              onChanged: (q) => context.read<DocumentListBloc>().add(SearchDocumentsEvent(q)),
            ),
          ),
          // Category filter chips
          SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            child: Row(
              children: [
                FilterChip(label: const Text('All'), selected: _selectedCategory == null,
                  onSelected: (_) { setState(() => _selectedCategory = null); context.read<DocumentListBloc>().add(FilterByCategory(null)); }),
                ..._categories.map((c) => FilterChip(
                  label: Text(c), selected: _selectedCategory == c,
                  onSelected: (_) { setState(() => _selectedCategory = c); context.read<DocumentListBloc>().add(FilterByCategory(c)); },
                )),
              ],
            ),
          ),
          // Document grid responds to BLoC state changes
          Expanded(
            child: BlocBuilder<DocumentListBloc, DocumentListState>(
              builder: (context, state) {
                if (state is DocumentListLoading) return const Center(child: CircularProgressIndicator());
                if (state is DocumentListError) return Center(child: Text(state.message));
                if (state is DocumentListLoaded) {
                  return GridView.builder(
                    gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2),
                    itemCount: state.documents.length,
                    itemBuilder: (context, i) {
                      final doc = state.documents[i];
                      return GestureDetector(
                        onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => DocumentDetailPage(document: doc))),
                        child: Card(
                          child: Column(
                            children: [
                              Expanded(child: Image.file(File(doc.imagePath), fit: BoxFit.cover)),
                              Text(doc.title, overflow: TextOverflow.ellipsis),
                              Chip(label: Text(doc.category)),
                              Text(doc.createdAt.toLocal().toString().substring(0, 10)),
                            ],
                          ),
                        ),
                      );
                    },
                  );
                }
                return const SizedBox();
              },
            ),
          ),
        ],
      ),
      // FAB triggers a new document scan
      floatingActionButton: FloatingActionButton(
        onPressed: () => context.read<DocumentListBloc>().add(ScanDocumentEvent()),
        child: const Icon(Icons.document_scanner),
      ),
    );
  }
}

What does this code do?

  • BlocProvider wraps the page and immediately dispatches LoadDocuments() so documents appear on launch.
  • The TextField dispatches a SearchDocumentsEvent on every keystroke. Because the BLoC falls back to loading all documents when the query is empty, clearing the field restores the full list.
  • FilterChip widgets dispatch FilterByCategory events. Tapping "All" passes null to show everything.
  • BlocBuilder rebuilds the grid whenever the BLoC emits a new state. Each card shows a thumbnail, title, category badge, and date.

Add the dart:io import if prompted

The File class used in Image.file(File(...)) requires dart:io. If VS Code prompts you to import it, accept the suggestion. Add import 'dart:io'; at the top of the file.

  • Save dashboard_page.dart.

Seeing an error about document_detail_page.dart?

The dashboard imports document_detail_page.dart which does not exist yet. You will create it in the next substep. The error resolves once that file is in place.

help me fix the missing import for document_detail_page

Build the Document Detail screen and wire up main.dart

The detail page shows the full scanned image, the extracted OCR text (selectable for copying), and a share button that opens the native Android share sheet.

  • Create features/scanner/presentation/pages/document_detail_page.dart and add the following code:
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:share_plus/share_plus.dart';
import '../../domain/entities/document.dart';

class DocumentDetailPage extends StatelessWidget {
  final Document document;
  const DocumentDetailPage({super.key, required this.document});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(document.title),
        actions: [
          // Share button sends the scanned image via the native share sheet
          IconButton(
            icon: const Icon(Icons.share),
            onPressed: () async {
              await SharePlus.instance.share(
                ShareParams(files: [XFile(document.imagePath)]),
              );
            },
          ),
        ],
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // Full scanned image
            Image.file(File(document.imagePath)),
            const SizedBox(height: 16),
            // Category chip and date
            Chip(label: Text(document.category)),
            Text('Scanned: ${document.createdAt.toLocal()}'),
            const SizedBox(height: 16),
            // Copyable OCR text
            const Text('Extracted Text:', style: TextStyle(fontWeight: FontWeight.bold)),
            SelectableText(document.ocrText),
          ],
        ),
      ),
    );
  }
}

What does this code do?

  • SharePlus.instance.share(ShareParams(files: [XFile(...)])) opens the native Android share sheet with the scanned image file attached. This is the modern share_plus API.
  • SelectableText renders the OCR text in a way that lets the user long-press to select and copy portions of it.
  • Image.file(File(document.imagePath)) loads the locally stored scan image directly from disk.
  • Save document_detail_page.dart.
  • Finally, wire everything together in lib/main.dart. Replace the existing contents of main.dart with the following:
import 'package:flutter/material.dart';
import 'core/di/injection_container.dart';
import 'features/scanner/presentation/pages/dashboard_page.dart';

void main() async {
  // Ensure Flutter bindings are initialized before DI setup
  WidgetsFlutterBinding.ensureInitialized();
  await setupDependencies();
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'AI Doc Scanner',
      home: const DashboardPage(),
    );
  }
}

What does this code do?

  • WidgetsFlutterBinding.ensureInitialized() is required because setupDependencies() is async and runs before runApp. Without it, Flutter cannot use platform channels before the framework is ready.
  • setupDependencies() registers the database, services, repositories, use cases, and BLoC in GetIt.
  • DashboardPage is set as the home of the app, so it is the first screen the user sees.
  • Save main.dart.
  • Run the app on your physical device by running:
flutter run
  • You should see the Dashboard with an empty grid (no documents yet), a search bar at the top, category filter chips, and a floating action button with a scanner icon.
  • Tap the floating action button to scan a document. After scanning, the document should appear in the grid with a thumbnail, title, category badge, and date.
  • Type a word from the scanned text into the search bar. The grid should filter to show only matching documents.
  • Tap a document card to open the detail view. You should see the full image, category chip, date, and the extracted OCR text.
  • Tap the share icon in the app bar. The native Android share sheet should appear with the scanned image attached.

App crashes on launch?

  • Check that injection_container.dart imports the BLoC file and registers DocumentListBloc with registerFactory.
  • Make sure main.dart calls WidgetsFlutterBinding.ensureInitialized() before setupDependencies().
  • Verify that both dashboard_page.dart and document_detail_page.dart exist in the features/scanner/presentation/pages/ folder.

help me debug my app crash on launch

Your app now has a fully reactive UI. The BLoC pattern keeps your widgets free of business logic, and the search bar gives instant access to any document by its OCR content. Next up, you will add custom image filters as a Secret Mission challenge.

Secret mission

Add Background Image Filters

Your document scanner captures crisp images, but what if you could enhance them? In this secret mission, you'll build custom image filters (grayscale and high-contrast black & white) that run in a background isolate. The UI stays silky smooth at 60fps while heavy pixel manipulation happens off the main thread.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with no cloud resources or API keys, so there are no ongoing costs.

Resources you used:

  • The ai_doc_scanner project folder on your Desktop.
  • Local SQLite database file stored in the app's documents directory on your Android device or emulator.
  • Scanned document image files stored in the app's documents directory on your Android device or emulator.
  • The AI Doc Scanner app installed on your Android device or emulator.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep experimenting with your document scanner.

  • Your project folder stays on your Desktop and you can reopen it anytime in VS Code.
  • The app and all scanned documents remain on your Android device until you manually uninstall.
  • The SQLite database and image files persist in the app's documents directory with no storage fees.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your project files and app data stay intact for when you return.

  • Stop the Flutter debug session by pressing the stop button in VS Code or pressing Ctrl+C in the terminal.
  • Close the Android emulator if running to free up RAM.
  • Close VS Code. Your project will be exactly where you left it when you reopen the folder.

Delete - I don't want to use this again

Remove all project resources and start fresh. This cannot be undone.

  • Uninstall the AI Doc Scanner app from your Android device or emulator. This removes the SQLite database and all scanned image files stored in the app's documents directory.
  • Delete the project folder from your Desktop by running:

macOS

cd ~/Desktop
rm -rf ai_doc_scanner

Windows

cd ~\Desktop
rmdir /s /q ai_doc_scanner
  • Confirm the folder is gone by running:
ls ~/Desktop

You should no longer see ai_doc_scanner in the output.

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-grade AI document scanner app from scratch using Clean Architecture, on-device machine learning, and reactive state management.

You've learned how to:

  • Architect a Flutter app with strict Clean Architecture (Data, Domain, and Presentation layers), Drift database with FTS5 full-text search, and GetIt dependency injection wiring everything together.
  • Integrate Google ML Kit Document Scanner for native edge detection and auto-capture, extract text with on-device OCR, and auto-categorize documents using a keyword-based classification engine.
  • Build a reactive BLoC-powered dashboard with real-time search, category filtering, document detail views, and native sharing via share_plus.
  • Secret Mission: Added custom image filters (grayscale, high-contrast B&W) processed in background isolates for jank-free 60fps UI performance.

Ready to quiz yourself?