DEV Community

Preeti Singh
Preeti Singh

Posted on

I built a free offline barcode & QR scanner for Android — here's what I learned about offline-first Flutter architecture

A few months ago I shipped ScanPro — a free Android app for scanning and generating barcodes/QR codes, managing inventory, scanning documents to PDF, and batch scanning sessions. The whole thing is 100% offline: no account, no cloud, no data leaving the device.

Building it taught me a lot about what "offline-first" actually means in practice with Flutter and Riverpod. Here's what I'd tell myself before I started.

Why offline-first is harder than it sounds

Most tutorials show you how to fetch data from an API and display it. Offline-first flips that model: local SQLite is the source of truth, and network calls are optional enrichment (product lookups, in my case).

The tempting mistake is to add offline support as an afterthought — cache some responses, show a spinner, call it done. But that creates a split-brain architecture where you're never sure which state is authoritative.

The better approach: design as if the network doesn't exist, then add network calls as fire-and-forget side effects.

Architecture decisions that paid off

1. Feature-based folder structure

lib/
  features/
    scanner/
      data/       # repositories, SQLite queries
      domain/     # models, parsers
      presentation/ # screens, widgets
    inventory/
    history/
    generator/
Enter fullscreen mode Exit fullscreen mode

This kept each feature self-contained. When I added batch scanning, I only touched features/scanner/. When I added PDF tools, it was its own isolated feature.

2. Riverpod for everything

I used Riverpod with code generation. Each feature has its own providers that expose repositories. The key insight: repositories are the only place that touch the database. Screens never query SQLite directly.

// Clean: screen just reads a provider
final history = ref.watch(historyProvider);

// Never do this in a screen:
final db = await openDatabase(...);
final rows = await db.query('scans');
Enter fullscreen mode Exit fullscreen mode

3. SQLite schema versioning from day one

I started at schema v1 and I'm now at v6. Each version adds tables or columns without breaking existing data:

Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
  if (oldVersion < 2) {
    await db.execute('ALTER TABLE scans ADD COLUMN batch_id TEXT');
  }
  if (oldVersion < 3) {
    // inventory tables
    await db.execute('''CREATE TABLE inventory_items (...)''');
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Plan your schema upgrades before you ship v1. Migrations on production data are painful.

4. Consent-gated ads, not consent-blocked features

The app shows ads via Google Mobile Ads. The UMP consent flow runs on startup. The key: ads are completely separate from app functionality. I have a ConsentService singleton with a canShowAds boolean — if consent is denied, ads don't load. The scanner, history, inventory, and PDF tools work identically either way.

Don't mix ad consent with feature availability. Users will resent you.

The tricky bits

Batch scanning with shared history

One early design mistake: each scan in a batch was saved as a separate history item, creating a cluttered list. The fix was adding a batch_id UUID column and grouping scans at query time:

// Group batch scans into one HistoryItem
final batches = rows
  .where((r) => r['batch_id'] != null)
  .groupBy((r) => r['batch_id']);
Enter fullscreen mode Exit fullscreen mode

Now a batch of 50 scans shows as one expandable item in history.

PDF rendering on-device

For the "scan PDF barcodes" feature, I needed to render PDF pages as images to run MobileScannerController.analyzeImage() on them. The pdfrx package handles this cleanly:

final page = await doc.getPage(pageNumber);
final image = await page.render(
  width: page.width * 2,
  height: page.height * 2,
);
final pngBytes = await image.createImage()
  .then((img) => img.toByteData(format: ImageByteFormat.png));
Enter fullscreen mode Exit fullscreen mode

No server, no upload — just local rendering.

Crash from ProGuard stripping TypeToken

This one cost me a day. After shipping with R8/ProGuard enabled, flutter_local_notifications started crashing on cold start. The root cause: Gson's TypeToken generic type info was being stripped.

Fix in proguard-rules.pro:

-keep class com.google.gson.reflect.TypeToken { *; }
-keepattributes Signature
-keepattributes EnclosingMethod
Enter fullscreen mode Exit fullscreen mode

Always test your release build before shipping. The debug APK won't show you this.

What I'd do differently

  • Start with localization on day 1. Adding 13 languages retroactively meant touching every screen. Using AppLocalizations.of(context) from the start would have saved a week.
  • Plan your ad placement early. Retrofitting banners and native ads into existing layouts is messy. Stub out the ad slots as empty SizedBox widgets from the beginning.
  • Use EdgeInsetsDirectional everywhere. When I added RTL support (Arabic, Hebrew), screens with hardcoded EdgeInsets.only(left: ...) all broke. EdgeInsetsDirectional is the same amount of typing and handles RTL for free.

The app

ScanPro is free on the Play Store — grab it here if you want to see the result. Happy to answer questions about the architecture in the comments.

What offline-first patterns have worked for you on mobile? I'm especially curious how people handle conflict resolution when the same data can be modified on multiple devices.

Top comments (0)