flutter_better_scanner 1.0.2
flutter_better_scanner: ^1.0.2 copied to clipboard
Drop-in document scanner: live edge detection, auto capture, crop, enhance, annotate and multi-page export. Bring your own UI or use the built-in screens.
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 #
- Overview
- Features
- Installation
- Dependencies
- Usage
- API documentation
- Configuration
- Detection speed
- Enhancements
- Platform support
- Permissions
- Troubleshooting
- 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 importingpackage:scanner_core/scanner_core.dart, see Migrating fromscanner_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 #
Android — android/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.
iOS — ios/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 #
iOS — ios/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. Keepuse_frameworks!in yourPodfile— 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_loadfor 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
LICENSEincludes the copyright attribution for Al Azad.