Bills were persisted correctly but read back wrong. The read path rebuilt a cart from its lines alone, dropping bill-level discounts and loyalty, so every figure derived from a stored bill was overstated: the upload payload, the day archive and the shift report. A discounted 529 bill read back as 620. Money and data integrity - order_dao: restore bill_discount and points_redeemed when rebuilding a cart; keep the reconstruction tier-less so the membership discount is not applied twice. Trust the recorded total and points via SaleTransaction.storedTotal. - checkout_sale + order_dao.commitSale: write the bill, its stock movement and the loyalty update in one transaction. Previously a failure part-way through left a persisted bill the cashier believed had failed, inviting a duplicate. - checkout_sale: re-check every line against live stock. A parked bill resumed after its stock was sold passed validation and oversold. - catalogue_dao: allocate the invoice sequence in one transaction; the previous read-modify-write could hand two sales the same number and fail UNIQUE. - local_store: replay unsynced sales after a catalogue import, so a mid-shift re-import cannot restore stock that has already been sold. - payment_controller: stamp the signed-in operator on the bill instead of the hardcoded seed session, and pass the terminal id through. - cart: reconcile per-slab GST against the bill total so the parts sum to the whole on a tax invoice. Sync and reporting - sync_repository: drain unsynced bills in a loop rather than silently capping at one page; stop on rejection so rejected rows cannot loop forever. - sync_log_dao (new): persist the sync history to the sync_log table, which the schema already defined but nothing used. It was in memory, so the only record that bills had been uploaded died at restart. - Scope shift reports by cashier. day_archive is re-keyed to (business_date, cashier_name) so a till stays settleable after its bills are uploaded and deleted. Schema v4 with a migration that carries v3 rows across. Input and UI - barcode_service: consume machine-paced keystrokes so a scan cannot also land in the focused field, and raise the bar to 60ms/char while a text field has focus so typing a mobile number is not read as a scan. Clock and focus check injected so the behaviour is testable. - primary_button: make the label flexible; label plus trailing total overflowed the Charge button by up to 131px. - app_router: redirect instead of null-casting when the receipt route is entered without its transaction. - customer_repository: reduce the search query to digits so a punctuated mobile number matches. Cleanup - Remove TransactionRepository.save, CustomerRepository.recordSale and OrderDao.insertOrder, all superseded by commitSale. - dart fix across the tree; 251 analyzer issues down to 3 info-level. Tests: 23 passing / 15 failing -> 90 passing. Fixed the two defects that broke the existing suite (containsAll type argument, reset() needing a catalogue) and deleted the leftover template test. Added coverage for the order round trip, the day archive after a real sync, stock safety, checkout atomicity, the v3->v4 migration, scanner-versus-human input, and an app-level smoke test that renders every module. Note: bills already uploaded with a discount went up overstated. This stops it happening again but does not correct historical server data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nearle POS
An enterprise Point of Sale terminal for supermarkets, grocery stores, pharmacies and retail shops. Built for Flutter Desktop (Windows/macOS/Linux) and Android tablets.
Brand colour #662582 · Inter typeface · 16px corner radius · touch-first targets.
Getting started
flutter pub get
flutter run -d windows # or macos, linux, or a connected tablet
flutter test # 60+ unit and widget tests
flutter analyze
Requires Flutter 3.27 / Dart 3.6 or newer. See Version compatibility if flutter analyze complains about theme types.
Architecture
Clean architecture in three layers. Dependencies point inward only: presentation → domain ← data. The domain layer imports nothing from Flutter, which is what makes the pricing engine testable in isolation.
lib/
├── main.dart Entry point: orientation lock, store + audio warmup
├── app/
│ ├── app.dart MaterialApp.router, global shortcuts, text-scale lock
│ └── providers.dart Dependency injection graph, cashier session, clock
│
├── core/
│ ├── constants/ Store details, GST rates, loyalty ratios, asset paths
│ ├── theme/ Colours, 4pt spacing scale, typography, ThemeData
│ ├── router/ GoRouter routes and page transitions
│ ├── services/ Barcode capture, scanner beeps, thermal receipt PDF
│ ├── utils/ Formatters, validators, BuildContext extensions
│ └── widgets/ GlassCard, PrimaryButton, NumericKeypad, StatusPill
│
├── domain/ Pure Dart — no Flutter imports
│ ├── entities/ Product, Customer, Cart, SaleTransaction
│ ├── repositories/ Abstract contracts
│ └── usecases/ CheckoutSale
│
├── data/
│ ├── datasources/ LocalStore (swappable), seed catalogue
│ └── repositories/ Concrete implementations
│
└── presentation/ One folder per feature: screens / widgets / providers
├── welcome/ customer/ pos/ payment/ receipt/
Why the domain layer is pure
Cart is an immutable value object that computes every figure on the bill as a getter — subtotal, per-slab GST, membership discount, loyalty redemption, round-off, points earned. No widget, repository or provider participates in the arithmetic. That means the money maths is covered by fast unit tests with no pumpWidget and no mocking, and swapping LocalStore for Hive or a REST backend changes nothing above the data layer.
The five screens
| Screen | What it does |
|---|---|
| Welcome | Three entry paths: New Customer, Existing Customer, Skip (walk-in). Vector illustration painted in code, so it stays crisp at 4K. |
| Customer Registration | Mobile + name required; email, gender, DOB optional. Saves and drops straight into billing. |
| Existing Customer | Large numeric keypad, auto-searches on the 10th digit. Shows name, tier, points and lifetime spend, or offers Register / Walk-in when nothing matches. |
| POS Dashboard | Three columns: navigation, product grid, always-visible bill. Category chips, live search, barcode billing. |
| Payment | Cash, Card, UPI, Wallet, Gift Card and arbitrary splits. Live change calculation with denomination shortcuts. |
| Receipt | Success summary beside a paper-style preview. Counts down and starts the next sale on its own. |
Fast cashier workflow
Barcode billing has no dialogs. BarcodeService listens to HardwareKeyboard globally rather than depending on a text field holding focus. It distinguishes a scanner from a human by keystroke timing — characters arriving faster than barcodeScanTimeout (120ms apart) are treated as a scan, so the cashier can still type into the same search box by hand. On a hit the item is added or its quantity incremented, a beep plays, and a floating toast confirms it. Nothing ever needs dismissing between items.
Other workflow details:
- Tapping a product card bills it immediately; the card shows a live quantity badge.
- Newest cart line renders first, mirroring what was just scanned.
- Every mutation goes through
CartController, so scanner input, taps and shortcuts share one code path — and one undo stack (F8). - Swipe a cart line to remove it.
F2focuses search,Escreleases it.- Bills can be parked and resumed.
- Stock is checked before adding, not after — an over-scan beeps and refuses rather than failing at checkout.
Pricing engine
Prices are GST-inclusive, per Indian retail convention, so tax is extracted rather than added.
- Per-product GST slabs. Vegetables at 0%, milk at 5%, biscuits at 18%, aerated drinks at 28%. The receipt breaks GST out per slab, split into CGST and SGST.
- Bill-level discounts are apportioned. When a membership discount or manual discount reduces the bill, the GST charged is scaled by the same factor rather than left at the pre-discount figure.
- Membership tiers are automatic. Bronze / Silver / Gold / Platinum derive from lifetime spend and carry 0 / 2 / 5 / 8 percent off, applied without cashier action.
- Loyalty. One point per ₹10 spent; each point is worth ₹0.25 on redemption. Redemption is capped at both the balance held and the bill value, and re-clamps automatically if the bill shrinks after points were applied.
- Round-off to the nearest rupee is shown as its own line.
- All money passes through an
asMoneyextension that rounds to two decimals, so floating-point drift never reaches a total.
Testing
flutter test
test/unit/cart_test.dart— the pricing engine: GST extraction, discount stacking, apportionment, loyalty caps, tier thresholds, round-off, and the guarantee that a payable never goes negative.test/unit/checkout_test.dart— end-to-end sale completion against real repositories: stock decrement, invoice sequencing, split tenders, loyalty movement, and every rejection path.LocalStore.reset()re-seeds between tests so fixtures never leak.test/unit/validators_test.dart— mobile/email/name validation and formatter output.test/widget/primary_button_test.dart— button and status pill rendering, tap and busy states.
Swapping the data layer
LocalStore is a process-local map that stands in for a database. To move to Hive, SQLite or an HTTP API, implement the three interfaces in domain/repositories/ and rebind them in app/providers.dart:
final productRepositoryProvider = Provider<ProductRepository>(
(ref) => HiveProductRepository(ref.watch(hiveBoxProvider)),
);
Nothing in domain/ or presentation/ changes.
Version compatibility
Two Flutter APIs used here moved recently:
ThemeData.cardTheme/dialogThemetakeCardThemeData/DialogThemeDataon Flutter 3.29+. On older versions, drop theDatasuffix inlib/core/theme/app_theme.dart.Color.withValues(alpha:)requires Flutter 3.27. On older versions substitutewithOpacity().
Status and known gaps
This project has not been compiled. It was written in an environment without a Dart SDK or network access, so flutter pub get, flutter analyze and flutter test have never run against it. Verification was static: the import graph resolves with no missing or orphaned files, and every symbol referenced in the tests exists in lib/. That cannot catch type errors, signature mismatches, or drift in the pinned package APIs. Expect to fix a handful of issues on first build — the theme types above are the most likely.
Deliberately out of scope, stubbed as clear extension points:
- Cash drawer —
ReceiptService.openCashDrawer()logs the ESC/POS kick sequence but does not send it. Wire in your printer's serial passthrough. - AI product search — the search bar ranks by exact barcode, SKU, prefix then fuzzy match. No model is called.
- Bottom navigation modules — Dashboard, Products, Inventory, Customers, Promos, Reports, Suppliers and Settings render with live badge counts but are not routed.
- Authentication —
cashierSessionProviderholds a hardcoded session. - Sounds — the three bundled WAVs are synthesised placeholders. Replace with your own;
SoundServiceswallows playback failures so a missing file never blocks billing.