Flutter
Scan barcodes in Flutter apps with qrgen_flutter: a mobile_scanner-based widget and a WebView scanner that reads every QRGen symbology.
Note: This page is generated from
packages/flutter/README.md. Source code:packages/flutter.
Barcode, QR and ID scanning for Flutter, part of QRGen (Apache-2.0). Two widgets share one API:
| Widget | Engine | Best for |
|---|---|---|
QRGenScanner |
mobile_scanner 5.1+, 6 or 7 (ML Kit on Android; ML Kit on iOS up to 6, Apple Vision from 7) |
Production apps, best speed |
QRGenWebScanner |
Hosted QRGen embed page in webview_flutter |
Every QRGen symbology, no camera plugin setup |
Both use the QRGen symbology ids, result shape (QRGenBarcode), options and error codes
from the cross-platform specification, so results look the same as
in the JavaScript, iOS, Android, React Native and Python SDKs.
Install
dependencies:
qrgen_flutter: ^0.1.0
Requires Dart 3.3 and Flutter 3.19 or later (newer mobile_scanner and webview_flutter
releases are picked automatically on newer Flutter versions).
iOS
ios/Runner/Info.plist:
<key>NSCameraUsageDescription</key>
<string>This app uses the camera to scan barcodes and QR codes.</string>
mobile_scanner 6 needs iOS 15.5 (platform :ios, '15.5' in ios/Podfile); 5 and 7
work from iOS 12. The WebView scanner needs iOS 14.3+ for camera access in WKWebView.
Android
android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.CAMERA" />
mobile_scanner 7 needs minSdk 23, webview_flutter_android 4 needs minSdk 24.
QRGenScanner asks for the camera permission itself (through mobile_scanner).
For QRGenWebScanner the app must hold the permission before the page opens the camera,
see below.
To bundle the ML Kit model instead of downloading it through Google Play services, see the
mobile_scanner README (dev.steenbakker.mobile_scanner.useUnbundled).
QRGenScanner (mobile_scanner)
import 'package:qrgen_flutter/qrgen_flutter.dart';
class ScanPage extends StatelessWidget {
const ScanPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: QRGenScanner(
options: const QRGenScannerOptions(
symbologies: ['qr', 'ean13', 'code128'],
mode: QRGenScanMode.single,
),
onScan: (barcodes) => Navigator.pop(context, barcodes.first.data),
onError: (error) => debugPrint('${error.code.value}: ${error.message}'),
),
);
}
}
The widget:
- resolves SPEC ids, aliases and groups (
QRCode,ean-13,retail,2d, …) and maps them to theBarcodeFormats of the installedmobile_scannerversion, dropping the ones the platform cannot read (seeqrgenSupportedSymbologies()); - refines results so they match the other QRGen SDKs: UPC-A read as EAN-13 with a leading
0(Apple Vision) is reported as a 12 digitupca, EAN-13 starting with978/979asisbn, and a 14 digit ITF asitf14, whenever those ids were requested; - stops the camera while the app is in the background, while
isActiveisfalse, and while its route is covered by another full-screen route, and disposes itsmobile_scannercontroller; - draws a rounded corner-bracket viewfinder (or a scan line for linear-only sets), outlines
detected codes when
mobile_scannerreports corners, and shows a success toast pill, in the QRGen accentColor(0xFF2EC1CE); - gives haptic feedback (
HapticFeedback.mediumImpact) and a short system click on scan. PassonBeepto play a real beep with your audio package.
| Parameter | Default | Description |
|---|---|---|
options |
QRGenScannerOptions() |
SPEC options, see below |
onScan |
void Function(List<QRGenBarcode>): new codes after the duplicate filter |
|
onTrack |
void Function(List<QRGenTrackedBarcode>): batch mode, every tracked code whenever the set changes |
|
onError |
void Function(QRGenError) |
|
onReady |
The camera started | |
controller |
QRGenScannerController for start, stop, pause, resume, reset, setTorch, switchCamera |
|
isActive |
true |
Run the camera |
accentColor |
Color(0xFF2EC1CE) |
Viewfinder, outline and toast color |
showToast / showHighlights |
true |
Overlay parts |
onBeep |
system click | Scan sound |
errorBuilder |
built-in message | Widget Function(BuildContext, QRGenError) |
child |
Drawn on top (buttons, hints) |
Give the scanner a bounded size (Expanded, SizedBox, AspectRatio, or a Scaffold body).
Options
QRGenScannerOptions is shared by both widgets:
| Option | Type | Default | Meaning |
|---|---|---|---|
symbologies |
List<String> |
['all'] |
SPEC ids or groups |
mode |
QRGenScanMode |
continuous |
single stops the camera after the first scan (call controller.start() to scan again); batch tracks many codes |
duplicateFilter |
int (ms) |
1000 |
Ignore the same symbology + data within this window. 0 reports every frame, -1 once per session |
beep |
bool |
true |
Sound on scan |
vibrate |
bool |
true |
Haptic feedback on scan |
camera |
QRGenCameraFacing |
back |
back or front |
torch |
bool |
false |
Flashlight |
viewfinder |
QRGenViewfinder? |
frame (line for linear-only sets) |
frame, line or none |
scanArea |
QRGenScanArea? |
full frame | Normalized region of interest; codes whose center is outside are ignored |
maxResults |
int? |
1 (20 in batch) |
Codes per frame, closest to the center first |
QRGenWebScanner (webview_flutter)
Loads https://rockyljewell.github.io/QR-GEN/embed/ with the options in the query string,
registers the QRGenBridge JavaScript channel and parses the SPEC section 5 messages. It reads
every QRGen symbology because the page runs the QRGen WebAssembly engine.
final controller = QRGenWebScannerController();
QRGenWebScanner(
controller: controller,
options: const QRGenScannerOptions(symbologies: ['qr', 'pdf417']),
onReady: () => debugPrint('ready'),
onScan: (barcodes) => debugPrint(barcodes.first.data),
onError: (error) => debugPrint(error.message),
);
// Later:
await controller.setTorch(true);
await controller.pause();
- iOS: the controller is created with
WebKitWebViewControllerCreationParams(allowsInlineMediaPlayback: true, mediaTypesRequiringUserAction: {})and the page’s camera request is granted (iOS 15+), so the user only sees the app-level permission prompt. - Android:
setMediaPlaybackRequiresUserGesture(false)andsetOnPlatformPermissionRequestgrant the page’s camera request. - Controller commands (
start,stop,pause,resume,setTorch,send) callrunJavaScript("window.qrgen && window.qrgen.command({...});"). Commands sent before the page reportsreadyare queued, exceptstart/stop: those can reach a verified command bridge before camera-ready, soautostart=0and startup retries work.onReadyandcontroller.isReadystill reflect the page’s actual camera-ready message; pause/resume/torch keep their camera-ready queue. isActive: false, a covered route and the app going to the background sendstop; coming back sendsstart. Changingtorchsends a command, changing other options reloads the page.- Haptics run natively; the page gets
vibrate=0. The page plays the beep. embedUrlpoints to a self-hosted copy of the page (it must behttps).
Android camera permission for the WebView
Android WebViews can only grant camera access the app already holds. Request it first, for
example with permission_handler:
QRGenWebScanner(
requestCameraPermission: () async => (await Permission.camera.request()).isGranted,
onScan: (barcodes) {},
);
When requestCameraPermission returns false, the page is not loaded and
camera-permission-denied is reported. Without it, a missing permission is reported by the
page as camera-permission-denied.
Results
QRGenBarcode follows SPEC section 2 and round-trips through toJson() / fromJson():
| Field | Type | Notes |
|---|---|---|
data |
String |
Decoded text; GS1 DataBar in HRI form (01)... |
symbology |
QRGenSymbology |
.id is the SPEC id, for example ean13 |
symbologyName |
String |
EAN-13 |
rawBytes |
String |
Base64 of the raw bytes, '' when unavailable |
contentType |
QRGenContentType |
text, or binary for byte-only codes |
isGS1 |
bool |
|
location |
QRGenQuadrilateral |
Corners in pixels of frameSize (the upright camera frame) |
frameSize |
QRGenSize |
|
orientation, ecLevel, symbologyIdentifier |
0 / '' when unknown |
|
timestamp |
DateTime |
JSON: ms since epoch |
QRGenTrackedBarcode adds id (stable, by default derived from symbology and data, with collision
suffixes when needed), firstSeen, lastSeen and count. Treat IDs as opaque; active tracks
have unique IDs. Base IDs use the same scheme as React Native, while collision suffixes
depend on track insertion order. Codes are dropped after more than 500 ms unseen, before
matching the next frame; a returning code starts fresh timestamps and count.
Default duplicate windows and unseen-track timeouts use monotonic elapsed time;
public barcode and tracking timestamps remain epoch-valued DateTime values.
The pure helpers retain their optional explicit DateTime arguments and preserve
retained timestamp ages when switching between explicit and default time.
In native batch mode, onScan precedes onTrack. A reset, real widget disposal,
committed mode change or newer tracking update cancels an older pending track result.
No-op start/resume, cosmetic rebuilds and no-change blank input preserve the current
notification. Requesting a parent rebuild takes effect when that rebuild is committed.
Errors are QRGenError with a QRGenErrorCode (camera-permission-denied,
camera-not-found, camera-in-use, insecure-context, engine-load-failed, unsupported,
unknown).
Utilities
Pure Dart helpers (no Flutter needed): resolveSymbologies, QRGenSymbology.tryParse,
qrgenSymbologyGroups, mobileScannerFormatNames, fromMobileScannerFormat,
QRGenDuplicateFilter, QRGenBarcodeTracker, qrgenTrackingId, QRGenScanSession,
buildEmbedUri, parseBridgeMessage, QRGenBridgeCommand, QRGenFrameTransform (map
location to widget coordinates with the BoxFit.cover crop), QRGenScannerOverlay (reuse the
overlay with your own camera widget).
Optional spatial observations in the pure helpers
Custom camera integrations can separate identical decoded labels by their positions:
final session = QRGenScanSession(
mode: QRGenScanMode.batch,
spatialTracking: true,
trackMaxJump: 3,
trackMaxAssociationWork: 250000,
);
// Each barcode's location uses the same full upright frame dimensions.
final result = session.process(barcodes, null, const QRGenSize(640, 480));
final observations = result.tracked;
This opt-in belongs to QRGenScanSession and QRGenBarcodeTracker. The existing
QRGenScanner widget, shared QRGenScannerOptions, WebView and embed bridge do not
acquire these flags. Direct widget integration remains separate work; its camera
source ownership and orientation handling need their own validation. Existing
scanner rendering, styling and default payload grouping are unchanged.
The tracker equivalent is QRGenBarcodeTracker(spatial: true, maxJump: 3, maxAssociationWork: 250000) with update(barcodes, now?, frameSize?).
The session accepts the optional full size on process, including empty frames.
Each spatial record requires finite corners with nonzero extent and its own positive
integral frameSize, consistent across the frame. Coordinates remain full-frame
pixels when an application filters an ROI; do not relabel them as crop coordinates.
An empty frame with changed dimensions clears observations. For a same-size source
switch, call session.resetSpatial() before admitting frames from the new source;
it returns an empty-list change once and keeps payload duplicate history.
The application must reject delayed callbacks and frames from the old source.
Association groups by exact symbology and payload. It maximizes eligible one-to-one
matches, then minimizes total Euclidean center movement. Each current detection’s
movement gate is max(extent, 1) * maxJump, where extent is its larger bounding-box
dimension. Equal-position ties follow numeric observation creation order. Unmatched
observations remain until more than the tracking timeout has elapsed, so the result
can temporarily contain more observations than visible detections.
Before assignment, work is summed as n*n*(m+n) across payload groups with n
current detections and m > 0 surviving old observations. The default budget of
250,000 rejects an oversized complete frame without partial tracking or payload
reports. New-only groups cost zero. The budget is a positive safe integer and does
not guarantee frame time or memory use; an offline override can still be expensive.
Each spatial session frame also copies retained duplicate-history maps; once-per-session
history persists until reset, so long sessions need separate allocation and latency measurements.
Invalid geometry, source sizes, late caller reads, time comparisons and exhausted
identity/count ranges also reject without consuming histories or IDs.
Session configure(spatialTracking: ..., trackMaxJump: ..., trackMaxAssociationWork: ...) validates the entire proposal first. Equal effective
policies preserve observations; changed policies clear them and use a fresh public
ID namespace, while retaining duplicate history and stopped state. A mode change
also resumes. Ordinary reset, source reset and expiry never reuse a spatial ID within
the same helper lifetime. Treat IDs as opaque. Scan reports remain payload-based,
including for identical observations; window 0 still reports every detection.
Public timestamps remain DateTime values, while default expiry and duplicate ages
use monotonic elapsed time. Injected DateTime arguments retain their established
microsecond and clock-transition behavior.
Spatial tracks describe short-lived decoded observations. They do not establish persistent physical identity through occlusion, confirmed quantities, or Scandit parity. Those require physical camera and workflow validation.
Example
example/lib/main.dart: a scanner screen with a mode switch, a torch
toggle, a back end switch and a result list. Run flutter create . inside example/ to
generate the platform folders, then add the permissions above.
Status
- Core validation: 220 pure Dart tests pass with
dart testin a Flutter-free package (Dart 3.13.5), with strict analysis clean. They cover symbology resolution, the mobile_scanner mapping for versions 5/6, 7.0-7.2 and 7.3+, JSON round trips, the duplicate filter, the tracker, the scan session, geometry and bridge parsing, including optional spatial association, complete-frame transactions and an independent exhaustive small-scene oracle. - CI validation:
flutter analyzeandflutter testcheck the package and example. The resolved test environment uses Flutter 3.47.7, Dart 3.13.5, mobile_scanner 7.4.2 and webview_flutter 4.14.1. Widget tests driveQRGenScanneragainst a fakemobile_scannerplatform (formats, start and stop, single, batch, duplicate filter, torch,isActive, error mapping, reset, nested callback ownership and torch state across camera/configuration changes, including stopped and single-camera no-op controls) andQRGenWebScanneragainst a fakewebview_flutterplatform (URL,QRGenBridgechannel, message dispatch, command queueing, permission grants). CI also compiles the 87 spatial tracker/session/oracle cases for Node with the official Dart web compiler. The separate compiled-core probe runs 48 clock checks in an isolated Node context: four calibrations, 24 default-clock regressions and 20 compatibility/metadata controls. Seetool/clock_probe.dartandtool/run_clock_probe.mjs; JavaScript supplies clock boundaries, while the algorithms come from the compiled Dart sources. Earlier compatibility checks analyzedlib/against the lowest allowed dependencies (mobile_scanner 5.1.0, webview_flutter 4.4.0, webview_flutter_android 3.12.0, webview_flutter_wkwebview 3.9.0) and against mobile_scanner 6.0.10 and 7.0.0; those older dependency combinations have not been rerun for this spatial-helper change. The tracking ids are checked to be the same on the Dart VM, in dart2js output and in the React Native package. - Not tested yet: nothing has run on a real device or simulator. Camera behavior, highlight placement (especially with the front camera, which is not mirrored in the overlay) and the hosted embed page inside real WebViews still need checking on hardware. The widget tests use the mobile_scanner 7 platform interface, so they need mobile_scanner 7 to compile.
License
Apache-2.0, see LICENSE and NOTICE.
Created by Rockyljewell.