The device reality outside Jakarta
Field staff carry Rp 1,5 to 2,5 juta Android handsets: Redmi A-series, Samsung Galaxy A0x, Infinix. Three or four gigabytes of RAM, Android 12 or 13, a 720p screen, and an OEM launcher that treats every background process as a battery thief. They work in warehouses with no signal, on roads that drop to 3G, and in buildings where the only connectivity is whatever the customer’s guest Wi-Fi permits.
Flutter helps here for one specific reason: it renders its own widgets instead of delegating to platform controls, so a Galaxy A05 behaves like the device you tested on. You are not debugging why a dropdown paints differently under MIUI. Rendering consistency is not the headline benefit anyone advertises, but it is the one that saves the most time when your QA fleet is eight different cheap phones.
Offline-first is the architecture, not a feature
Offline-first is not a mode you toggle. It is the shape of the whole application. The local database — SQLite through Drift or sqflite — is the source of truth the UI reads from, always. The network is a background process that reconciles local state with the server. Build it the other way round, with the network primary and a cache bolted on, and you will spend the project fighting spinners in a warehouse basement.
- An outbox table for pending mutations with status, attempt count, and last error, visible to the user
- Delta pull using a server cursor rather than a full sync — 4G in Cirebon is not your office Wi-Fi
- Photos queued separately and uploaded on good signal, using resumable multipart uploads
- A visible sync indicator with pending counts, because trust comes from watching the queue drain
- Idempotency keys on every submit so a retry cannot create two Delivery Notes in ERPNext
On the ERPNext side you need an endpoint built for this, not the generic REST list API. We usually expose a custom method that accepts a cursor and returns documents changed since that point, capped at a page size, with the next cursor in the response. Filtering on modified timestamp alone is not enough — clock skew and long transactions silently drop records, and you find out from a driver rather than from a test.
Sync also needs a clear failure story. When a record cannot be pushed after five attempts, show it, name the reason in plain language, and let the user retry or hand it to a supervisor. A silent outbox that quietly grows all week is exactly how field staff learn to stop trusting the app and go back to sending photos.
Conflict resolution nobody talks about
Here is the part every tutorial skips. Two people edit the same record offline: what happens? Last-write-wins is fine for a note field and catastrophic for stock. The answer is not one strategy but a rule per data type, agreed with the business before a single line of sync code gets written, because the right answer is a business decision wearing technical clothes.
- Proof of delivery and attendance are append-only events, so there is no conflict to resolve at all
- Stock movements sync as deltas, never absolute quantities, so two offline edits both apply correctly
- Master data, pricing, and credit limits are always server-wins, with a clear message to the user
- Draft work in progress is client-wins until submitted, after which it becomes immutable
- Anything genuinely ambiguous goes into a supervisor queue instead of being resolved silently
APK size, cold start, and battery
A release build split per ABI, with R8 enabled and unused assets stripped, lands around 8 to 12 MB for a baseline Flutter app. A real field app with maps, camera, and offline tiles is more like 25 to 40 MB. Ship an app bundle so the Play Store delivers only the relevant architecture — a universal APK doubles the download for no benefit, and size matters when staff install over mobile data.
Battery is dominated by one thing: background location. Continuous high-accuracy GPS costs roughly 6 to 9 percent an hour and will get your app blamed for everything else too. Batch location to every 60 to 120 seconds while a job is active, stop entirely when it closes, and use a foreground service with a visible notification. Then walk users through the OEM battery whitelist, because MIUI and One UI will kill your background isolate.
Cold start matters more than raw frame rate on these devices. Defer heavy initialisation, keep the first screen cheap, and never load the full item catalogue at launch. On a Redmi A2 the difference between a two-second and a seven-second start is the difference between an app people open willingly and one they open only when told to.
Where Flutter is the wrong choice
Flutter is not the answer to everything. If the app depends on a vendor SDK that ships native only — certain Bluetooth peripherals, some payment terminals, particular biometric readers — the plugin work can cost more than writing the thing natively. If the product is content-heavy and needs to be indexed, build for the web. And if your team already has two strong native engineers and no Dart experience, respect that.
For the common case, though — an offline-capable operational app on cheap Android, one codebase, delivered by a small team, integrated with ERPNext — Flutter remains the pragmatic answer. This is not fashion. It is that the alternative is maintaining two codebases for an application whose users would never notice the difference.
