Skip to content
Tech Stack22 October 2026 · 11 min read

Build a Flutter QR Inventory Scanner Without Duplicates

A QR scanner reports one code on many frames, and a network retry can repeat a stock write. Lock the scanner until confirmation and give each adjustment an ID first.

Build a Flutter QR Inventory Scanner Without Duplicates

What you'll learn

  • What you need before starting
  • Why one code produces a stream of events
  • Lock the scanner until the person confirms
Article contents

Duplicate stock entries from a QR scanner have two separate causes, and each needs its own fix. The camera reports the same code on many consecutive frames, so the screen has to stop listening after the first useful result. The network can deliver the same adjustment twice, so the database write needs an identity that makes a second attempt harmless. A debounce in the UI handles the first problem and does nothing for the second.

This tutorial builds both pieces for a Flutter inventory screen backed by Cloud Firestore. It also covers a third problem that tends to arrive with them: a scanner that accepts whatever text a QR code contains and treats it as a trusted record ID.

Every item, code and quantity in the examples is a synthetic demonstration. The Dart snippets are reference implementations checked against the published API pages for mobile_scanner 7.4.2 and cloud_firestore 7.0.0. They have not been run against a Firebase project, so treat the acceptance checks near the end as the tests you still need to run.

What you need before starting

You need a Flutter app with Firebase already configured, a Firestore items collection that stores a quantity field, and a signed-in user who is allowed to adjust stock. The scanner in this article is the mobile_scanner package.

Check your targets first. The package's support table lists Android, iOS, macOS and web, and marks Windows and Linux as unsupported (mobile_scanner on pub.dev). On iOS you add NSCameraUsageDescription to Info.plist, and on macOS you enable camera access under Signing & Capabilities. On the web, the browser camera API is only available in secure contexts, which means HTTPS or localhost (MDN, getUserMedia). A web build served over plain HTTP on a warehouse network will never open the camera.

For test codes, keep them synthetic. FlutterFlow's Barcode widget "converts a value into a scannable 1D or 2D barcode, such as a QR code", so a small internal page can print demo labels like INV1:DEMO-0001 without any real product data.

If you build in FlutterFlow, know what the built-in action covers. The Scan Barcode / QR Code action opens a camera interface, lets the user scan a code and stores its value in an output variable. Its documentation describes a single scan and does not mention continuous scanning, so the scan lock and the adjustment identity below belong in a custom widget and a custom action. The tradeoffs of that move are covered in when to drop from FlutterFlow custom code to pure Flutter.

Why one code produces a stream of events

A live camera scanner analyzes frames continuously. As long as a code stays in view, every analyzed frame can produce another detection, and each detection calls your onDetect callback with a BarcodeCapture whose barcodes property is "the list of scanned barcodes" (BarcodeCapture).

mobile_scanner lets you shape that stream through DetectionSpeed. The constructor defaults to DetectionSpeed.normal with detectionTimeoutMs set to 250, and the timeout is only kept in normal mode (MobileScannerController constructor).

ModeDocumented behaviorEffect on an inventory screen
normalScans "while respecting the configured scan timeout between individual scans"A code held in frame can be reported again after each 250 ms timeout
noDuplicatesScans a barcode once, "and never again until another barcode has been scanned"Blocks a deliberate second scan of the same item, such as counting two identical boxes
unrestrictedScans "without any restrictions"; the docs warn it "can cause memory issues on older devices"Maximum event volume, no protection at all

The quoted behavior comes from the DetectionSpeed reference.

None of these modes knows whether the person has finished with the current item. noDuplicates comes closest, but it makes the wrong decision for a stock count, where scanning the same label again after confirming the first unit is a legitimate action. That decision belongs to your screen.

Lock the scanner until the person confirms

Paper and wood model: a QR-style tile feeds a row of grey spheres; one cyan sphere has passed a gate into a tray beside a round confirm button, later spheres drop into a bin, and a wire arrow loops from the button back to the gate

Model the screen as a small state machine. While the phase is scanning, the first acceptable detection moves it to resolving, and every later detection is ignored until the person confirms or cancels. Rearming is an explicit action, never a timer.

import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';

enum ScanPhase { scanning, resolving, confirming, submitting }

class StockScanPage extends StatefulWidget {
  const StockScanPage({super.key});

  @override
  State<StockScanPage> createState() => _StockScanPageState();
}

class _StockScanPageState extends State<StockScanPage> {
  final controller = MobileScannerController(
    formats: const [BarcodeFormat.qrCode],
  );
  ScanPhase phase = ScanPhase.scanning;

  void _onDetect(BarcodeCapture capture) {
    // The lock: anything detected outside the scanning phase is dropped.
    if (phase != ScanPhase.scanning) return;
    if (capture.barcodes.length != 1) {
      _showMessage('Scan one label at a time.');
      return;
    }
    setState(() => phase = ScanPhase.resolving);
    _resolve(capture.barcodes.single.rawValue);
  }

  void _rearm() => setState(() => phase = ScanPhase.scanning);

  // _resolve, _showMessage and the confirmation sheet follow below.

  @override
  Widget build(BuildContext context) {
    return MobileScanner(controller: controller, onDetect: _onDetect);
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }
}

The check for exactly one barcode handles two labels inside the same frame. Picking the first entry of the list would silently choose one of them, and the person would not know which item they just counted.

Passing your own controller changes who manages the camera. The widget's useAppLifecycleState option pauses and resumes the scanner automatically, but it is "only applicable if no controller is passed" (useAppLifecycleState). With a controller, follow the package README: observe the app lifecycle, call stop() when the app becomes inactive and start() when it resumes, after the camera permission has been granted (mobile_scanner README).

Treat the QR content as a lookup key

A QR code is untrusted input. Anyone can print one, and the Barcode class exposes fields such as rawValue and a url "bookmark that is embedded in the barcode" (Barcode). An inventory screen should never open that URL, never accept a quantity or price written into the code, and never use the raw text as a document path.

Define a strict payload format and accept nothing else:

final _itemCode = RegExp(r'^INV1:([A-Z0-9-]{4,32})$');

String? parseItemCode(String? raw) {
  if (raw == null) return null;
  return _itemCode.firstMatch(raw)?.group(1);
}

The INV1: prefix versions the format, and the character class keeps slashes out of the extracted SKU, so the value cannot reach another collection when you build a document reference from it. A code that fails the pattern produces a "Not an inventory label" message and a rearmed scanner, with no Firestore read at all.

A code that passes the pattern still identifies nothing until the server confirms it. Read items/{sku}. If the document does not exist, show "Unknown item" and offer a rescan. If the read throws, show a retryable error. Only an existing item, read with the signed-in user's permissions, moves the screen to confirming, where the person chooses the quantity.

Give each adjustment an ID before the first attempt

Two identical envelopes sealed with the same brass key token arrive on two strings, one of them frayed; one envelope sits in a single ledger slot while the second is held off by a stop, beside an unchanged stack of wooden blocks

A debounce decides how often the screen reacts. It cannot tell the database that two requests are the same request. If the connection drops after a write commits on the server and before the app hears back, the app sees a failure and retries, and a plain FieldValue.increment would count the stock twice.

Create the adjustment's identity when the person taps Confirm, before anything is sent. Firestore can create "a document reference with an auto-generated ID" that you use later (Firebase, Add data):

final adjustmentId =
    FirebaseFirestore.instance.collection('stock_adjustments').doc().id;

Store adjustmentId, the SKU, the delta and the user ID together as a pending draft in local storage the app already uses, so a restart does not lose the ID. PendingAdjustment below is a plain class holding those four values. Apply the draft in a transaction that checks whether that ID was already recorded:

Future<void> applyAdjustment(PendingAdjustment draft) async {
  final db = FirebaseFirestore.instance;
  final adjustmentRef = db.collection('stock_adjustments').doc(draft.id);
  final itemRef = db.collection('items').doc(draft.sku);

  await db.runTransaction((tx) async {
    final existing = await tx.get(adjustmentRef);
    if (existing.exists) return; // An earlier attempt already applied it.

    tx.set(adjustmentRef, {
      'sku': draft.sku,
      'delta': draft.delta,
      'createdBy': draft.uid,
      'createdAt': FieldValue.serverTimestamp(),
    });
    tx.update(itemRef, {'quantity': FieldValue.increment(draft.delta)});
  });
}

The read comes first because Firestore requires that "read operations must be executed before write operations" (Firebase, Transactions and batched writes). Two retries racing each other are also covered: a transaction "might run more than once if a concurrent edit affects a document that the transaction reads", so the losing attempt runs again and finds the adjustment already present. The Flutter SDK retries 5 times by default with a 30 second timeout (runTransaction). Keep the handler free of side effects, since the docs also say transaction functions "should not directly modify application state".

Back the transaction with a rule. Firestore rules split writes into create, which "applies to writes to nonexistent documents", and update, which applies to existing ones (Firebase, Structuring security rules). Allow create on stock_adjustments for authorized staff and deny update and delete, so a recorded adjustment cannot be rewritten afterwards.

Keep the offline draft separate from confirmed stock

"Transactions will fail when the client is offline" (Firebase, Transactions and batched writes). For this screen that is useful. An offline confirmation cannot pretend to be applied, so the screen keeps the draft and labels it honestly. The offline Firestore write article explains why queued writes need careful labels, and the transaction here avoids that queue entirely.

The screen ends up with four outcomes that each need their own message:

OutcomeWhat the app knowsWhat the person sees
Unknown itemThe payload is valid and no items/{sku} document exists"Unknown item", rescan offered, nothing recorded
Duplicate scanA detection arrived while the lock was heldNothing; the event is dropped
Offline draftThe draft and its ID are stored locally, the transaction failed"Not applied yet", with a retry action
Confirmed adjustmentThe transaction committed, or found the ID already recorded"Stock updated", then the scanner rearms

Retry always reuses the stored ID. Generating a fresh ID on retry would turn the protection off at the exact moment it matters. Disable the retry button while a transaction is running, too, so a double tap produces one attempt instead of two racing ones.

Run the five acceptance checks

Run these on a physical device for each platform you ship, with synthetic labels and a test Firebase project:

  1. Hold one INV1:DEMO-0001 label in frame for ten seconds. One confirmation sheet should appear, and no adjustment document should exist until you tap Confirm. After confirming, stock_adjustments should contain exactly one new document.
  2. Move two different labels past the camera quickly. The second one should be ignored while the first is resolving. Then hold both in one frame and confirm you get the "one label at a time" message.
  3. Scan malformed codes: a URL, lowercase inv1:demo-0001, a code with a slash in the SKU and plain text. Each should show "Not an inventory label", open nothing and trigger no Firestore read.
  4. Turn on airplane mode and confirm an adjustment. The transaction should fail, the draft should remain with its ID, and the screen should say "Not applied yet".
  5. Reconnect and tap retry, then retry once more after it succeeds. The item's quantity should change once and stock_adjustments should still hold one document for that ID.

If check 5 changes the quantity twice, look for a code path that creates a new ID on retry. That is the most likely cause.

Where this design stops

The transaction runs on the client, so it relies on security rules for its guarantees. A rule that allows staff to update items lets a modified client change quantity without writing an adjustment at all. If stock must only move through recorded adjustments, apply them on a trusted backend, such as a callable function that performs the same ID check, and deny direct client writes to quantity. The Flutter and Firebase stack decisions for a SaaS mobile app cover where that boundary usually sits, and the same at-least-once thinking appears in webhook idempotency and retries.

A local draft is only as durable as the storage behind it. If the app is uninstalled or its data cleared before a draft is applied, that adjustment is lost, so show the count of unapplied drafts somewhere the person will see it before ending a shift.

The quickest place to start is your current scan handler. If it writes to Firestore inside onDetect, or creates its write ID inside the retry path, move those two lines first.

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

Why does a Flutter QR scanner fire onDetect several times for one code?

Because a live scanner analyzes camera frames continuously, and every frame that still contains the code can produce another detection. In mobile_scanner the default DetectionSpeed.normal mode only waits for a 250 ms timeout between individual scans, so a label held in front of the camera keeps arriving. DetectionSpeed.noDuplicates suppresses repeats of the same value, but it also blocks a deliberate second scan of the same item until a different code is seen. For an inventory screen, ignore detections while a scan is being resolved or confirmed, and rearm the scanner explicitly after the person confirms or cancels.

Is a debounce enough to stop duplicate inventory adjustments?

No, because a debounce only controls how often the screen reacts, while duplicates can also come from retried network requests. If the connection drops after the server commits a write and before the app receives the response, the app sees a failure and tries again. Give each adjustment an ID when the person confirms, store it with the draft, and apply it in a Firestore transaction that first checks whether a document with that ID already exists. A retry that reuses the ID then finds the record and changes nothing.

What should an inventory app do with the content of a scanned QR code?

Treat it as untrusted text that may only identify an item, and resolve everything else on the server. Accept one strict format, such as a versioned prefix followed by a SKU made of uppercase letters, digits and hyphens, and reject anything else without reading the database. Never open an embedded URL, never trust a quantity or price written into the code, and never use the raw text as a document path. Look the SKU up with the signed-in user's permissions and show an unknown-item state when no record exists.

Can FlutterFlow's built-in Scan Barcode action handle continuous inventory scanning?

Its documentation describes one scan per action, so continuous scanning with a lock needs custom code. The Scan Barcode / QR Code action opens a camera interface, lets the user scan a code and stores the value in an output variable. The page does not mention continuous scanning. A screen that keeps the camera open, ignores repeated detections and applies retry-safe adjustments belongs in a custom widget built on a scanner package plus a custom action for the Firestore transaction. FlutterFlow's Barcode widget is useful for generating synthetic test labels.