<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Preeti Singh</title>
    <description>The latest articles on DEV Community by Preeti Singh (@thecocoder).</description>
    <link>https://dev.to/thecocoder</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3818252%2F503fce61-5a0b-45da-8447-0e78d25bc9b6.png</url>
      <title>DEV Community: Preeti Singh</title>
      <link>https://dev.to/thecocoder</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/thecocoder"/>
    <language>en</language>
    <item>
      <title>I built a free offline barcode &amp; QR scanner for Android — here's what I learned about offline-first Flutter architecture</title>
      <dc:creator>Preeti Singh</dc:creator>
      <pubDate>Wed, 11 Mar 2026 10:09:14 +0000</pubDate>
      <link>https://dev.to/thecocoder/i-built-a-free-offline-barcode-qr-scanner-for-android-heres-what-i-learned-about-offline-first-1kn3</link>
      <guid>https://dev.to/thecocoder/i-built-a-free-offline-barcode-qr-scanner-for-android-heres-what-i-learned-about-offline-first-1kn3</guid>
      <description>&lt;p&gt;A few months ago I shipped &lt;a href="https://play.google.com/store/apps/details?id=com.thecocoder.barcodepro" rel="noopener noreferrer"&gt;ScanPro&lt;/a&gt; — 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why offline-first is harder than it sounds
&lt;/h2&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The better approach: &lt;strong&gt;design as if the network doesn't exist, then add network calls as fire-and-forget side effects&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture decisions that paid off
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Feature-based folder structure
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lib/
  features/
    scanner/
      data/       # repositories, SQLite queries
      domain/     # models, parsers
      presentation/ # screens, widgets
    inventory/
    history/
    generator/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h3&gt;
  
  
  2. Riverpod for everything
&lt;/h3&gt;

&lt;p&gt;I used &lt;a href="https://riverpod.dev/" rel="noopener noreferrer"&gt;Riverpod&lt;/a&gt; with code generation. Each feature has its own providers that expose repositories. The key insight: &lt;strong&gt;repositories are the only place that touch the database&lt;/strong&gt;. Screens never query SQLite directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Clean: screen just reads a provider&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;historyProvider&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Never do this in a screen:&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;openDatabase&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'scans'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. SQLite schema versioning from day one
&lt;/h3&gt;

&lt;p&gt;I started at schema v1 and I'm now at v6. Each version adds tables or columns without breaking existing data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_onUpgrade&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Database&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;oldVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;newVersion&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oldVersion&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'ALTER TABLE scans ADD COLUMN batch_id TEXT'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oldVersion&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// inventory tables&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'''CREATE TABLE inventory_items (...)'''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plan your schema upgrades before you ship v1. Migrations on production data are painful.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Consent-gated ads, not consent-blocked features
&lt;/h3&gt;

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

&lt;p&gt;Don't mix ad consent with feature availability. Users will resent you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tricky bits
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Batch scanning with shared history
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;batch_id&lt;/code&gt; UUID column and grouping scans at query time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Group batch scans into one HistoryItem&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;batches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;
  &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'batch_id'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;groupBy&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'batch_id'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a batch of 50 scans shows as one expandable item in history.&lt;/p&gt;

&lt;h3&gt;
  
  
  PDF rendering on-device
&lt;/h3&gt;

&lt;p&gt;For the "scan PDF barcodes" feature, I needed to render PDF pages as images to run &lt;code&gt;MobileScannerController.analyzeImage()&lt;/code&gt; on them. The &lt;code&gt;pdfrx&lt;/code&gt; package handles this cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageNumber&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;width:&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;height:&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;pngBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createImage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toByteData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;format:&lt;/span&gt; &lt;span class="n"&gt;ImageByteFormat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;png&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No server, no upload — just local rendering.&lt;/p&gt;

&lt;h3&gt;
  
  
  Crash from ProGuard stripping TypeToken
&lt;/h3&gt;

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

&lt;p&gt;Fix in &lt;code&gt;proguard-rules.pro&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-keep class com.google.gson.reflect.TypeToken { *; }
-keepattributes Signature
-keepattributes EnclosingMethod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always test your release build before shipping. The debug APK won't show you this.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  The app
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;ScanPro&lt;/strong&gt; is free on the Play Store — &lt;a href="https://play.google.com/store/apps/details?id=com.thecocoder.barcodepro" rel="noopener noreferrer"&gt;grab it here&lt;/a&gt; if you want to see the result. Happy to answer questions about the architecture in the comments.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>android</category>
      <category>showdev</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
