flutter_better_scanner

A document scanner for Flutter with live edge detection, auto capture, perspective correction, enhancement filters, annotation and multi-page export.

Everything is available at four levels: use the built-in screens as-is, build your own camera UI, build your own review UI, or call a single editor on a single image. No level is a dead end — you can mix them.

The CV core is hand-written C++ (no OpenCV), running in a worker isolate. Detection costs about 1 ms per frame.


Table of contents

  1. Overview
  2. Features
  3. Installation
  4. Dependencies
  5. Usage
  6. API documentation
  7. Configuration
  8. Detection speed
  9. Enhancements
  10. Platform support
  11. Permissions
  12. Troubleshooting
  13. License

Overview

flutter_better_scanner turns a phone camera into a document scanner and hands you the finished image files. It is one package with two halves:

  • Dart / Flutter — the camera engine, the page model, the built-in screens and the public API.
  • Native C++ over dart:ffi — edge detection, perspective warp, the enhancement filters and JPEG encoding. No OpenCV, no model files, nothing to download at runtime. It is compiled into your app from source (CMake on Android, CocoaPods on iOS).

Everything the scanner does to a page is a recipe — crop quad, rotation, flip, enhancement, brightness/contrast/sharpness, annotation layer — applied to an untouched original that stays on disk. Edits are therefore non-destructive and re-orderable, and the original is never overwritten.

All heavy pixel work happens on a long-lived worker isolate, so the UI thread stays free. Image decoding uses the platform codecs (hardware-fast, EXIF-aware) rather than pure Dart.

Merged package. This package contains the functionality of the former two-package layout (flutter_better_scanner + scanner_core). If you were importing package:scanner_core/scanner_core.dart, see Migrating from scanner_core.


Features

Capture

  • Live document edge detection with a smoothed outline drawn over the preview.
  • Auto capture — fires once the document is framed, steady, lit and in focus, with a progress ring and a configurable hold duration.
  • Manual shutter, or a toggle between the two.
  • Single-page and multi-page (batch) sessions, with an optional hard page cap.
  • Framing guides (free / A4 / Letter), rule-of-thirds grid.
  • Flash off / auto / torch, front and back camera, pinch zoom, tap to focus.
  • Gallery import through the system picker (single or multi-select).
  • Three live-detection quality levels, switchable mid-session.

Edit

  • Crop with draggable corner handles and a magnifier, seeded by automatic document detection.
  • Eight enhancement filters: original, auto, magic colour, black & white, grayscale, shadow removal, colour document, whiteboard.
  • Brightness, contrast and sharpness sliders on top of any filter.
  • Annotation layer: pen, highlighter, signature, watermark — stored as a transparent PNG and composited at render time.
  • Rotate, flip, duplicate, delete, reorder, rename the document.
  • Every edit is non-destructive and re-renders in tens of milliseconds.

Export

  • JPG (quality 1-100) or PNG.
  • Returns absolute file paths plus pixel dimensions per page.

Integration

  • Four levels: built-in UI, custom camera UI, custom review UI, single-image editors. Mix freely.
  • Full camera-permission state machine with an app-settings deep link, on both platforms, degrading gracefully if the platform channel is unavailable.
  • Live detection state published through a ValueListenable, so per-frame updates never rebuild the camera texture or your surrounding UI.
  • The native CV core is exported too, if you want to run it yourself.

Installation

Add the package to your app's pubspec.yaml:

dependencies:
  flutter_better_scanner: ^1.0.0

Or from a local path / git:

dependencies:
  flutter_better_scanner:
    path: ../flutter_better_scanner
    # or:
    # git:
    #   url: https://github.com/your-org/flutter_better_scanner.git

Then:

flutter pub get
import 'package:flutter_better_scanner/flutter_better_scanner.dart';

Requirements

Minimum
Dart SDK >=3.8.0 <4.0.0
Flutter >=3.32.0
Android minSdk 24, NDK + CMake 3.10+ (installed by Android Studio)
iOS 13.0

The Dart floor is set by two language features the sources use — null-aware collection elements (Dart 3.8) and wildcard parameters (Dart 3.7) — not by preference. Everything above the floor is left open, and the dependency ranges below span whole major versions, so the package keeps resolving on newer Flutter and Dart releases without a republish.

Platform setup

Androidandroid/app/build.gradle.kts (or .gradle):

android {
    defaultConfig {
        minSdk = 24
    }
}

The package declares android.permission.CAMERA in its own manifest, so it is merged into your app automatically. Add it to your own manifest only if you want it visible there.

iOSios/Runner/Info.plist:

<key>NSCameraUsageDescription</key>
<string>Scan documents with the camera.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Import photos to scan.</string>

Then cd ios && pod install. Keep the use_frameworks! line that Flutter's default Podfile already contains — see Troubleshooting.

Optional, to spawn the worker isolate before the first scan instead of during it:

await BetterScanner.warmUp();

Dependencies

Everything below is resolved by pub — there is nothing to install by hand.

Package Constraint What it is used for
camera >=0.11.0 <0.13.0 Camera access and the frame stream. Headless — no UI is imposed by it.
image_picker >=1.0.0 <2.0.0 Gallery import through the system picker sheet.
image >=4.0.0 <5.0.0 PNG codec work only: annotation overlays and PNG export.
ffi >=2.0.0 <3.0.0 Allocation helpers for the dart:ffi bridge to the C++ core.
path >=1.8.0 <2.0.0 Path joining.
path_provider >=2.0.0 <3.0.0 Temporary directory for session and output files.
uuid >=4.0.0 <5.0.0 Stable page and session identifiers.

No native dependency is downloaded. The C++ core lives in src/ inside this package and is compiled with your app: CMake through the Android Gradle Plugin on Android, CocoaPods on iOS.

Transitively you also get the platform implementations of camera and image_picker (camera_android_camerax, camera_avfoundation, image_picker_android, image_picker_ios, …). Those are pulled in by pub automatically.


Usage

1. Built-in UI — one call

final result = await BetterScanner.openScanner(
  context,
  config: const ScannerConfig(
    captureMode: CaptureMode.multiple,
    shutterMode: ShutterMode.auto,
    autoCrop: true,
    autoEnhance: true,
    exportFormat: ExportFormat.jpg,
  ),
);

final paths = result?.imagePaths ?? [];

Scan exactly one page:

final path = await BetterScanner.scanSingle(context);

openScanner pushes the camera screen, runs the whole session (capture → review → edit → export) and returns a ScanResult. It returns a cancelled result when the user backs out, and null only if the route is popped externally.

2. Your own camera UI

ScannerController is the whole engine, headless. ScannerPreview is the camera texture plus the detection outline — the one widget worth reusing.

final scanner = ScannerController(config: myConfig);
await scanner.initialize();
...
scanner.dispose();
// In your build():
ScannerPreview(controller: scanner)
What you want Call
Flashlight scanner.cycleFlashMode() / setFlashMode(ScannerFlashMode.torch)
Front / back camera scanner.switchCamera() / setCamera(CameraFacing.front)
Framing guide scanner.setGuide(ScanGuide.a4)
Grid scanner.toggleGrid()
Auto / manual shutter scanner.setShutterMode(ShutterMode.auto)
Detection speed scanner.setDetectionQuality(DetectionQuality.medium)
Take a photo await scanner.capture()
Gallery import await scanner.pickFromGallery()
Zoom / focus scanner.setZoom(2.0), scanner.focusAt(offset)
Finish await scanner.finish()ScanResult

Live state arrives through a ValueListenable, so it updates per frame without rebuilding the preview:

ValueListenableBuilder<DetectionState>(
  valueListenable: scanner.detection,
  builder: (_, state, _) => Text(state.hint.label),   // "Hold steady", "Ready", …
)

DetectionState carries quad (the outline in preview space, normalized 0-1), hint, and progress (0-1 auto-capture ring fill).

3. Your own review UI

The same controller owns the page list, so a custom review screen is a ListenableBuilder over scanner.pages — every edit re-renders and notifies.

// Read
scanner.pages;                  // List<ScanPage>, stable ids for list keys
scanner.proxyPath(page);        // ~1400px render, right for lists
scanner.pagePath(page);         // full-resolution render
scanner.originalPath(page);     // the untouched capture
page.enhancement, page.rotationQuarterTurns, page.isFlipped,
page.brightness, page.contrast, page.cropCorners, page.aspectRatio;
// Edit — headless
await scanner.rotatePage(page);
await scanner.flipPage(page);
await scanner.setPageEnhancement(page, ScanEnhancement.magicColor);
await scanner.setPageAdjustments(page, brightness: 12, contrast: 1.2);
await scanner.setPageCrop(page, corners);
await scanner.resetPageCrop(page);
await scanner.setPageAnnotation(page, pngBytes);
await scanner.clearPageAnnotation(page);
await scanner.duplicatePage(page);
await scanner.deletePage(page);
scanner.reorderPages(oldIndex, newIndex);
scanner.rename('Invoice 42');
// Or borrow a built-in editor from your own screen
await scanner.openCropEditor(context, page);
await scanner.openEnhanceEditor(context, page);
await scanner.openAnnotateEditor(context, page);

// Build your own filter strip
final thumbs = await scanner.renderEnhancementThumbnails(page);
final jpeg = await scanner.renderPagePreview(page,
    enhancement: ScanEnhancement.blackWhite);   // preview without committing

Reviewing with the built-in screen from a custom camera UI:

final result = await BetterScanner.openPreview(context, controller: scanner);

For a page list with no camera behind it at all — images you already have — start the controller without opening the camera:

final scanner = ScannerController(config: myConfig);
await scanner.initializeWithoutCamera();
for (final path in myPaths) {
  await scanner.addImage(path);
}

4. A-la-carte — one editor, on any image

For images that never came from this scanner. Each call takes a path and returns the path of a new file; the input is never modified. Returns null when the user cancels.

final cropped  = await ScannerEditor.crop(context, imagePath: path);
final enhanced = await ScannerEditor.enhance(context, imagePath: path);
final signed   = await ScannerEditor.annotate(context, imagePath: path);

Headless — no screen at all:

final turned   = await ScannerEditor.rotate(imagePath: path);
final mirrored = await ScannerEditor.flip(imagePath: path);
final tidy     = await ScannerEditor.autoCrop(imagePath: path);  // null if nothing found
final bw       = await ScannerEditor.applyEnhancement(
    imagePath: path, enhancement: ScanEnhancement.blackWhite);
final thumbs   = await ScannerEditor.enhancementThumbnails(imagePath: path);
final (w, h)   = await ScannerEditor.imageSize(path);

Or hand the package a pile of images and let it run the whole review screen:

final result = await ScannerEditor.editImages(context, imagePaths: myPaths);

5. The native CV core, directly

Only if you want it. This is the library that used to be the separate scanner_core package, re-exported unchanged:

import 'package:flutter_better_scanner/scanner_core.dart';

final rgba = RgbaImage(pixels, width, height);          // raw RGBA bytes
final quad = ScannerCv.detectDocument(rgba);            // Quad? in 0-1 space
final flat = quad == null
    ? rgba
    : ScannerCv.warp(rgba, quad.scale(rgba.width - 1.0, rgba.height - 1.0));
final out  = ScannerCv.applyFilter(flat, ScanFilter.magicColor);
final jpeg = ScannerCv.encodeJpeg(out, 92);

Every function here is synchronous and CPU-bound — run it on a worker isolate for anything bigger than a camera frame. The package's own screens already do.


API documentation

BetterScanner

One-call entry points.

Member Returns Description
openScanner(context, {config}) Future<ScanResult?> Push the camera screen and run a full session.
openPreview(context, {controller}) Future<ScanResult?> Push the built-in review screen for an existing controller.
scanSingle(context, {config}) Future<String?> Scan exactly one page, return its path.
warmUp() Future<void> Spawn the CV worker isolate ahead of time.
shutdown() Future<void> Release the shared worker isolate.

ScannerController

ChangeNotifier. The whole engine, headless. Call dispose() when done.

Lifecycle

Member Description
ScannerController({config}) Construct. Nothing starts yet.
initialize() Spawn the worker, create the session, open the camera.
initializeWithoutCamera() Same, but leave the camera closed (status suspended).
suspend() / resume() Release / re-open the camera.
dispose() Tear everything down.
updateConfig(config) Swap the configuration at runtime; the page list is kept.

State

Member Type Description
status ScannerStatus uninitialized, ready, suspended, permissionDenied, permissionPermanentlyDenied, unavailable.
isReady bool Live and streaming.
isBusy bool A capture or render is in flight.
isFull bool The page cap has been reached.
errorMessage String? Why the camera could not be opened.
detection ValueListenable<DetectionState> Per-frame outline, hint and progress.
pages / pageCount List<ScanPage> / int The captured pages.
title String Document title.
cameraController CameraController? The underlying plugin controller, for advanced previews.
previewAspectRatio double Width / height in the current orientation.
facing, flashMode, guide, gridVisible, shutterMode Current control state.
zoom, minZoom, maxZoom double Zoom range.
canSwitchCamera bool More than one camera exists.
detectionQuality DetectionQuality Current live-detection level.
autoCaptureEnabled bool Auto shutter is on and edge detection is on.

Controls

cycleFlashMode() · setFlashMode(mode) · switchCamera() · setCamera(facing) · setGuide(guide) · setGridVisible(bool) · toggleGrid() · setShutterMode(mode) · toggleShutterMode() · setDetectionQuality(level) · setZoom(value) · focusAt(normalizedOffset)

Capture

Member Returns Description
capture() Future<ScanPage?> Take a photo and add it as a page.
captureRaw() Future<String?> Take a photo, return the raw path, add nothing.
addImage(path, {quad}) Future<ScanPage?> Ingest an image already on disk.
pickFromGallery() Future<List<ScanPage>> System picker; empty list on cancel.

Pages

deletePage · duplicatePage · reorderPages · rotatePage · flipPage · setPageEnhancement · setPageAdjustments · setPageCrop · resetPageCrop · setPageAnnotation · clearPageAnnotation · rename

Path getters: pagePath(page) (full render) · proxyPath(page) (~1400 px, for lists) · originalPath(page) (untouched capture) · annotationPath(page).

Rendering helpers

Member Returns Description
renderPagePreview(page, {enhancement, brightness, contrast, sharpness, maxDimension}) Future<Uint8List> JPEG of a candidate render, committing nothing.
renderEnhancementThumbnails(page, {size}) Future<List<Uint8List>> One thumbnail per ScanEnhancement, in enum order, from a single round trip.

Built-in editors on demand

openCropEditor(context, page) · openEnhanceEditor(context, page) · openAnnotateEditor(context, page) — each returns Future<bool> (true when the page changed).

Permission

permission · needsPermission · needsAppSettings · requestPermission() · openAppSettings() · refreshPermission() — see Permissions.

Finishing

finish()Future<ScanResult>. Exports every page and returns the paths.

ScannerEditor

Single-image editors, for images that did not come from a scan. Every method returns the path of a new file.

Member UI? Returns
crop(context, {imagePath, autoDetect, config}) yes Future<String?>
enhance(context, {imagePath, config}) yes Future<String?>
annotate(context, {imagePath, config}) yes Future<String?>
editImages(context, {imagePaths, config}) yes Future<ScanResult?>
rotate({imagePath, quarterTurns, config}) no Future<String?>
flip({imagePath, config}) no Future<String?>
applyEnhancement({imagePath, enhancement, brightness, contrast, sharpness, config}) no Future<String?>
autoCrop({imagePath, config}) no Future<String?>null when nothing is found
enhancementThumbnails({imagePath, size, config}) no Future<List<Uint8List>>
imageSize(imagePath) no Future<(int width, int height)>

Widgets

Widget Constructor
ScannerPreview ({controller, showOutline, showGuide, showGrid, enableTapToFocus, enableZoomGesture, loadingBuilder, errorBuilder, permissionBuilder})
QuadOverlay ({state, theme, showRing})
GuideOverlay ({guide, color})
GridOverlay ({color})
ScannerScreen ({config}) — the built-in camera screen
PreviewScreen ({controller, allowAddPages}) — the built-in review screen

loadingBuilder, errorBuilder and permissionBuilder let you replace the three non-camera states of ScannerPreview without giving up the engine.

Result types

ScanResult

Member Type
status ScanStatus (success / cancelled)
pages List<ScannedPage>
title String?
imagePaths List<String>
files List<File>
single ScannedPage? — first page, or null
isEmpty / isNotEmpty / isCancelled bool

ScannedPage

id · path · width · height · file

ScanPage

A page inside a live session. Stable id (usable as a list key), plus enhancement, rotationQuarterTurns, isFlipped, brightness, contrast, sharpness, cropCorners (a Quad), aspectRatio, detectedDocument, width, height.

DetectionState

quad (Quad?, preview space, normalized) · hint (ScanHint) · progress (double, 0-1) · hasDocument (bool).

ScanHint values: searching, moveCloser, holdSteady, badLighting, focusing, ready, cooldown. Each has a short English .label via the ScanHintLabel extension — map the enum yourself to localize.

Native core (package:flutter_better_scanner/scanner_core.dart)

Symbol Description
ScannerCv.version() Core version integer.
ScannerCv.detectQuad(gray, w, h, stride) Document quad in a grayscale frame.
ScannerCv.detectDocument(rgba) Document quad in a full-resolution still, with corner refinement.
ScannerCv.analyzeFrame(bytes, w, h, stride, {isBgra, cannyPasses, useHoughFallback, refineCorners}) Quad + luma + focus in one call and one buffer copy.
ScannerCv.rgbaToGray(rgba, w, h, stride) RGBA/BGRA → grayscale.
ScannerCv.warp(src, pixelQuad) Perspective-warp a quad to a straight rectangle.
ScannerCv.applyFilter(src, filter) Apply a ScanFilter.
ScannerCv.adjust(src, {brightness, contrast, sharpness}) Brightness −100..100, contrast 0.4..2.5, sharpness 0..2.
ScannerCv.frameStats(gray, w, h, stride) Mean luma + focus (variance of Laplacian).
ScannerCv.resize(src, dw, dh) Bilinear resize.
ScannerCv.limitSize(src, maxDim) Resize so the long side is at most maxDim.
ScannerCv.rotateFlip(src, quarterTurns, flipH) Rotate clockwise, then optionally mirror.
ScannerCv.compositeOver(dst, overlay) Alpha-composite in place.
ScannerCv.encodeJpeg(src, quality) Baseline JPEG (4:2:0).

Types: RgbaImage(bytes, width, height) · Quad(tl, tr, br, bl) · FrameStats(meanLuma, focus) · FrameAnalysis(quad, meanLuma, focus) · ScanFilter (+ .label).

Quad helpers: corners, fullFrame(), scale(sx, sy), withCorner(i, p), lerp(a, b, t), maxDistance(other), area, isConvex, warpSize(), toList()/fromList(), toJson()/fromJson().

Quad is also exported from the main library, since controller.setPageCrop and page.cropCorners use it.


Configuration

const ScannerConfig(
  // Capture
  captureMode: CaptureMode.multiple,     // or .single — hides batch controls
  shutterMode: ShutterMode.manual,       // or .auto
  maxPages: 10,                          // null = unlimited
  confirmEachCapture: false,             // show the crop editor after each shot

  // Processing — each independently switchable
  autoDetectEdges: true,                 // live outline + auto shutter
  detectionQuality: DetectionQuality.high,   // .low / .medium / .high
  autoCrop: true,                        // crop new pages to the detection
  autoEnhance: true,
  enhancement: ScanEnhancement.auto,

  // Camera chrome
  guide: ScanGuide.a4,                   // .free / .a4 / .letter
  flashMode: ScannerFlashMode.off,
  camera: CameraFacing.back,
  showGrid: false,
  allowGalleryImport: true,
  allowZoom: true,
  // ...and an allowX flag for every control, to hide it from the built-in UI:
  // allowShutterModeToggle, allowGuideChange, allowGridToggle,
  // allowFlashControl, allowCameraSwitch

  // After capture
  afterCapture: AfterCapture.openPreview,   // or .returnPaths
  previewTools: PreviewTools.all,           // toggle crop/enhance/annotate/…

  // Output
  exportFormat: ExportFormat.jpg,        // or .png
  exportQuality: 92,                     // 1-100, ignored for PNG
  workingResolution: 2600,               // long side captures are normalized to
  documentTitle: 'Invoice',              // null = timestamp

  theme: ScannerTheme(accent: Color(0xFF3B82F6)),
  autoCaptureTuning: AutoCaptureTuning(
    holdDuration: Duration(milliseconds: 900),
    cooldown: Duration(milliseconds: 1500),
    minAreaFraction: 0.18,
    minFocus: 7,
    minLuma: 45,
    maxLuma: 245,
  ),
);

ScannerConfig, PreviewTools, ScannerTheme and AutoCaptureTuning are all immutable with copyWith.


Detection speed

detectionQuality trades live-tracking accuracy for CPU. All three levels run the same algorithm; they differ in working resolution, analysis rate, and which optional stages run.

Level Working image Rate Cost/frame Corner error Stages
low 192 px ~8 fps 0.25 ms 0.013 fast path only
medium 240 px ~15 fps 0.38 ms 0.011 all
high (default) 288 px ~25 fps 0.55 ms 0.007 all

low drops the Hough-line fallback (the expensive stage that recovers documents whose outline is broken - a finger over an edge, a washed-out side) and sub-pixel corner refinement, and uses a single Canny pass. Corner error is a fraction of the frame width, so 0.013 is 1.3%.

Timings are from the desktop reference scene; phones are roughly an order of magnitude slower, which is the point of the setting.

// Fixed at construction
const ScannerConfig(detectionQuality: DetectionQuality.medium);

// Or changed mid-session — takes effect on the next frame,
// the camera is not restarted
scanner.setDetectionQuality(DetectionQuality.low);
scanner.detectionQuality;          // current level

// The numbers behind a level, if you want to show them
DetectionQuality.low.workingWidth;      // 192
DetectionQuality.low.framesPerSecond;   // 8
DetectionQuality.low.useHoughFallback;  // false

Auto-crop is not affected. The detection that runs once on a captured still always uses full quality - it happens once per page, and its accuracy is what the user actually sees in the result.


Enhancements

original, auto, magicColor, blackWhite, grayscale, shadowRemoval, colorDocument, whiteboard.

All are non-destructive: the original capture stays on disk and every page is a recipe (crop quad + rotation + flip + enhancement + adjustments + annotation layer) re-rendered on demand.


Platform support

Platform Status Notes
Android ✅ Supported API 24+. C++ core built by CMake through the Android Gradle Plugin; libscanner_core.so is bundled into your APK/AAB. 16 KB page size supported.
iOS ✅ Supported iOS 13+. C++ core compiled by CocoaPods into the plugin framework.
Web dart:ffi and dart:io are unavailable.
macOS / Windows / Linux The Dart FFI loader already has entries for these, and the C++ core is portable, but no build files ship for them and the camera plugin has no desktop implementation.

Both supported platforms are declared through a single plugin entry that is both a method-channel plugin (camera permission) and an FFI plugin (the CV core).


Permissions

What you must declare

iOSios/Runner/Info.plist:

<key>NSCameraUsageDescription</key>
<string>Scan documents with the camera.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Import photos to scan.</string>

NSPhotoLibraryUsageDescription is only needed if you use gallery import (allowGalleryImport, controller.pickFromGallery).

Android — nothing required. The package's own manifest declares:

<uses-permission android:name="android.permission.CAMERA" />

which is merged into your app. Add it to android/app/src/main/AndroidManifest.xml yourself only if you want it visible there. Gallery import goes through the system picker, so it needs no storage permission on any supported API level.

What is handled for you

The scanner asks on first use, and when the answer is no it explains what to do instead of sitting on a spinner.

State What the built-in UI shows
Not asked yet System dialog, automatically
Denied, can ask again Explanation + Allow camera (shows the dialog again)
Permanently denied Explanation + Open Settings + I have allowed it
Restricted (device policy) Explanation, no action - the user cannot grant it

Returning from the settings page re-checks automatically: grant it and the preview starts, leave it off and the same explanation stays put.

Driving it yourself

scanner.needsPermission;      // blocked on a permission decision
scanner.needsAppSettings;     // true => the dialog is gone, Settings is the only route
scanner.permission;           // granted / notDetermined / denied /
                              // permanentlyDenied / restricted / unknown

await scanner.requestPermission();   // show the dialog, start the camera if granted
await scanner.openAppSettings();     // deep link to this app's settings page
await scanner.refreshPermission();   // re-read and start if it is now granted

Or without a controller at all:

final state = await ScannerPermissions.check();
if (state.canRequest) await ScannerPermissions.request();
if (state.needsSettings) await ScannerPermissions.openAppSettings();

Or keep the logic and replace only the screen:

ScannerPreview(
  controller: scanner,
  permissionBuilder: (context, controller) => MyOwnPrompt(
    locked: controller.needsAppSettings,
    onAllow: controller.requestPermission,
    onOpenSettings: controller.openAppSettings,
  ),
)

ScannerStatus gains permissionDenied and permissionPermanentlyDenied, so a custom camera screen can branch on scanner.status directly.

If the permission channel is ever unavailable, every call degrades to ScannerPermission.unknown rather than throwing, and the scanner falls back to reading the camera plugin's own error codes - so the flow degrades rather than breaking.


Troubleshooting

UnsupportedError: Could not load the flutter_better_scanner native core

The C++ core did not get linked into your app.

  • iOS: run cd ios && pod install. Keep use_frameworks! in your Podfile — it is in Flutter's default template, and it is what makes the plugin build as a dynamic framework the FFI loader can open. If you removed it, the plugin links statically and the linker can dead-strip the unreferenced C++ object file. Restore the line, or add -force_load for the plugin's static library.
  • Android: install the NDK and CMake (Android Studio → SDK Manager → SDK Tools). Then flutter clean && flutter run.

iOS build fails in the flutter_better_scanner pod

Delete the derived state and reinstall:

cd ios && rm -rf Pods Podfile.lock && pod install --repo-update

Android build: CMake ... not found or NDK not configured

Install both from the SDK Manager. The build needs CMake 3.10 or later; the version pinned in the plugin's build.gradle is deliberately not raised, since raising it breaks clients on older toolchains.

The preview is black, or spins forever

Almost always the camera permission. Check scanner.status and scanner.permission - the built-in UI already surfaces both, but a custom preview has to. ScannerStatus.unavailable with a non-null scanner.errorMessage means the camera plugin itself failed.

Auto capture never fires

It gates on four things at once: the document must fill at least minAreaFraction of the frame, be steady within stabilityThreshold, be in focus above minFocus, and the scene luma must be between minLuma and maxLuma. Watch scanner.detection.value.hint - it names whichever gate is failing. Loosen them through AutoCaptureTuning. Also make sure both shutterMode: ShutterMode.auto and autoDetectEdges: true are set; scanner.autoCaptureEnabled reports the combination.

Edge detection misses the document

Contrast between paper and background is what the detector needs. On a white desk, or with a document that runs off the frame, it will find nothing - the page then falls back to a small inset of the full frame and page.detectedDocument is false. Raising detectionQuality to high re-enables the Hough-line fallback, which recovers most broken outlines.

Capture is slow on low-end devices

Lower workingResolution (default 2600) -it is the long side every capture is normalized to before editing, and edit cost scales with it. Lowering detectionQuality reduces the live cost but not the per-capture cost.

Out-of-memory on very large images

Same knob: workingResolution. The worker caches two full-resolution originals (~27 MB each at 2600 px) and three page bases (~8 MB each). Call BetterScanner.shutdown() to release the worker isolate when scanning is over.

Files disappear after a while

Sessions and exports live under the OS temporary directory, which the system may reclaim. Copy the paths from ScanResult into your own storage if you need them to persist.

MissingPluginException on the permission channel

Do a full restart (not a hot reload) after adding the package. If it persists, the scanner still works - permission state degrades to unknown and the camera plugin's error codes take over.


Migrating from scanner_core

The former scanner_core package is now part of this one.

 dependencies:
   flutter_better_scanner: ^1.0.0
-  scanner_core:
-    path: ../scanner_core
-import 'package:scanner_core/scanner_core.dart';
+import 'package:flutter_better_scanner/scanner_core.dart';

Nothing else changes. ScannerCv, Quad, RgbaImage, ScanFilter, FrameStats and FrameAnalysis keep the same names, signatures and behaviour, and Quad is still re-exported from package:flutter_better_scanner/flutter_better_scanner.dart.


Example

A runnable demo lives in example/. It needs its platform folders generated first:

cd example
flutter create . --platforms=android,ios
flutter run

License

MIT. See LICENSE.

The bundled LICENSE includes the copyright attribution for Al Azad.

Libraries

flutter_better_scanner
A drop-in document scanner for Flutter.
scanner_core
Direct access to the native CV core that powers flutter_better_scanner.