← All posts

7 min read

QR and Barcode Scanning in Flutter: A Practical Guide

QR and barcode scanning in Flutter with mobile_scanner: camera permissions, scan window UX, torch, validating scanned payloads safely and generating QR codes.

  • Flutter
  • QR Codes
  • Camera
Cover illustration for QR and Barcode Scanning in Flutter: A Practical Guide

Adding a scanner looks like a one-day task. Add a package, show the camera, read a string. And the first version really does take an afternoon.

What takes longer is everything around it: a permission dialog the user declined, a dark room, a scanner that fires five times for one code, and a QR code that turns out to contain something you didn’t expect. This post covers what I’ve learned about doing scanning properly in Flutter, as seven lessons.

1. Start with mobile_scanner

There are several scanning packages on pub.dev. For most apps, mobile_scanner is the one I’d pick. It’s actively maintained, supports Android, iOS, macOS and web, and is built on native barcode detection (Google’s ML Kit on Android) rather than decoding frames in Dart. That makes it fast and accurate without any extra setup.

The core API is small. A MobileScanner widget shows the camera preview, and its onDetect callback gives you a BarcodeCapture with a list of barcodes found in that frame:

final controller = MobileScannerController(
  formats: const [BarcodeFormat.qrCode],
  detectionSpeed: DetectionSpeed.noDuplicates,
);

MobileScanner(
  controller: controller,
  onDetect: (BarcodeCapture capture) {
    for (final barcode in capture.barcodes) {
      final value = barcode.rawValue;
      if (value != null) handleScan(value);
    }
  },
)

Two settings in that snippet matter more than they look:

  • formats: restrict detection to what you actually expect. If you’re scanning QR codes, don’t also scan EAN-13 and Code 128. Fewer formats means faster detection and fewer false positives.
  • detectionSpeed: noDuplicates stops the same code from triggering onDetect again and again while it stays in view. Without it, one scan can push the same screen three times.

Even with noDuplicates, I add my own guard: a _handled flag that I set on the first valid scan and reset when the user comes back to the scanner. Navigation is async, and frames keep arriving while it happens.

The controller has its own lifecycle. It holds the camera, so dispose it with the screen. Stop it when the app goes to the background and start it again on resume. The package’s README shows the recommended lifecycle pattern for your version. Follow that rather than copying a snippet from an old blog post, because this area has changed between major versions.

2. Treat camera permission as a screen, not a dialog

The permission request is the first real moment of the scanning feature, and it’s where many users drop off.

On iOS, add NSCameraUsageDescription to Info.plist with a sentence that actually explains why, such as “Scan the QR code on your ticket to check in”, not “This app needs camera access”. On Android, make sure the camera permission ends up in your merged manifest.

mobile_scanner requests the permission itself when it starts. If it’s denied, the widget’s error builder receives an exception, and you can show a proper screen instead of a black rectangle. That screen should:

  • Explain in one line what the camera is for.
  • Offer a button to open system settings if the permission was permanently denied.
  • Offer a fallback, which is almost always manual entry. Every code you scan should also be typeable. Cameras break, lenses get smudged, and some users just won’t grant camera access.

If you want to explain the camera before the system dialog appears (which I recommend), show a short screen first and only start the scanner when the user taps “Continue”. I go deeper into permission flows in designing onboarding flows that convert.

3. Design the scan window

A full-screen camera with no guidance makes people unsure where to point. A clear frame in the middle, with the rest dimmed, fixes that.

mobile_scanner supports a scanWindow: a Rect that limits detection to part of the preview. Draw your overlay over the same rectangle, so the area that looks active is the area that is active:

LayoutBuilder(
  builder: (context, constraints) {
    final size = constraints.biggest;
    final window = Rect.fromCenter(
      center: size.center(Offset.zero),
      width: 260,
      height: 260,
    );

    return Stack(
      fit: StackFit.expand,
      children: [
        MobileScanner(
          controller: controller,
          scanWindow: window,
          onDetect: _onDetect,
        ),
        CustomPaint(painter: ScanOverlayPainter(window)),
      ],
    );
  },
)

The overlay painter draws a translucent layer with a hole cut out for the window, plus corner marks. A scan window also stops the app from picking up a different code at the edge of the frame. That matters in places like warehouses, where codes sit next to each other.

Small details that make it feel finished:

  • A short haptic tap (HapticFeedback.mediumImpact()) on a successful scan.
  • A brief visual confirmation before navigating, so the user knows the scan worked.
  • Text under the window saying what to scan: “Point at the QR code on the receipt”.

4. Give users the torch

Scanning happens in dark restaurants, car parks and storage rooms. The controller can toggle the flashlight with controller.toggleTorch(), and it exposes the torch state so your button can show on and off correctly. Put the button where a thumb can reach it, not tucked into an app bar.

On devices without a flash, hide the button rather than showing one that does nothing. The controller’s state tells you whether the torch is available.

5. Never trust a QR code

This is the lesson I care about most. A QR code is untrusted user input. Anyone can print one and stick it over yours. Phishing with QR codes is common enough that it has its own name: “quishing”.

So never do this:

// Don't: opens whatever the code contains.
launchUrl(Uri.parse(barcode.rawValue!));

Instead, decide what valid payloads look like and reject everything else:

const _allowedHosts = {'example.com', 'www.example.com'};

ScanResult parseScan(String raw) {
  final uri = Uri.tryParse(raw.trim());
  if (uri == null) return const ScanResult.invalid();

  final isOurLink = uri.scheme == 'https' && _allowedHosts.contains(uri.host);
  if (!isOurLink) return const ScanResult.invalid();

  final ticketId = uri.queryParameters['ticket'];
  if (ticketId == null || !RegExp(r'^[A-Z0-9]{8,32}$').hasMatch(ticketId)) {
    return const ScanResult.invalid();
  }
  return ScanResult.ticket(ticketId);
}

The rules I follow:

  • Allowlist schemes and hosts. Match the host exactly. A contains('example.com') check also accepts example.com.attacker.net.
  • Validate the shape of IDs before they go anywhere near an API call.
  • Let the server decide. A scanned ticket ID is a claim. The backend checks whether it exists, belongs to this user and hasn’t been used.
  • Show external URLs before opening them, if you allow them at all. Display the full domain and make the user confirm.
  • Route your own links through your router. If your QR codes are app links, the same parsing that handles deep links with go_router should handle scanned links.

More on input validation in my mobile app security checklist.

6. Generating QR codes is the easy part

If your app also shows codes, such as tickets, referral links or pairing codes, qr_flutter renders them as a widget:

QrImageView(
  data: 'https://example.com/t?ticket=$ticketId',
  version: QrVersions.auto,
  size: 220,
  backgroundColor: Colors.white,
)

Keep the background white and the quiet zone (the empty margin around the code) intact, even in dark mode. Inverted or low-contrast codes fail on some scanners. Keep the payload short, too. A short URL with an ID produces a less dense code that scans faster from further away than a long JSON blob would. And if the user will show the code at a counter, raise the screen brightness while it’s visible.

7. Test on real devices, in real conditions

Simulators and emulators don’t help much here. The iOS Simulator has no camera at all. Scanning has to be tested on physical devices, ideally including an older, cheaper Android phone, because camera quality and autofocus vary a lot.

My test list:

  • Codes printed on paper, on a glossy surface and shown on another phone’s screen.
  • Dim light with and without the torch, plus glare.
  • Small codes and damaged codes.
  • Denying permission, then granting it from settings and returning to the app.
  • Backgrounding the app mid-scan and coming back.

For automated tests, keep the parsing and validation logic in plain Dart (like parseScan above) and unit test it with lots of bad input. mobile_scanner’s controller can also analyze an image file, which is handy for “scan from gallery” and for repeatable manual testing without printing anything.

Takeaways

  • mobile_scanner is a solid default. Restrict formats and use noDuplicates, plus your own guard against double navigation.
  • Treat permission as a designed screen, with an explanation, a settings link and manual entry as a fallback.
  • Match the detection scanWindow to the overlay the user sees, and add a reachable torch button.
  • Treat every scanned payload as untrusted. Allowlist schemes and hosts exactly, validate IDs and let the server decide.
  • Generate codes with qr_flutter on a white background with an intact quiet zone.
  • Test on physical devices in bad lighting, not just at your desk.

Scanning feels instant to the user only because the details around the camera were handled carefully.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).