Suriya 829e5a8188 Take the terminal's role from the back office, not from which tab was clicked
The role split was right; only its source was wrong. Signing in matched what
was typed against two constants compiled into the app — admin@nearle.in and
cashier@nearle.in — so which shell a person got was a property of the *build*.
A shop could not add a third person, revoke either of the two it had, or stop
anyone with the APK reading both passwords out of it.

TerminalLogin survives unchanged in shape, because the shape was the good part:
one flag the shell reads, a session that decides it, and a cashier sign-out that
takes the catalogue with it while a supervisor's leaves it behind. Every
consumer — visibleModulesProvider, resolvedModuleProvider, the sidebar, the page
header, the sign-out dialog — is untouched. What changed is that the enum is now
only constructible from a session the back office signed, so there is no path
left where the terminal grants itself a permission the server did not send.

It reads `can_manage_staff` rather than the role name or id. app_roles holds six
rows for four distinct roles, a great many accounts carry a roleid that is not
in the table at all, and the name comes back blank for most of them. Matching on
either would mean shipping a copy of the role table in the app and keeping the
two in step for ever. One boolean, decided server-side, cannot drift. It
defaults to false, which matters on the restore path: a session saved by a build
that predates the field comes back as a cashier, never silently as an admin.

This also restores the sign-in layer itself — pos_auth_api, pos_session,
session_store, the staff import and the bearer token — which an earlier commit
removed wholesale from a stale checkout. Its parent was the commit that added
them, so the deletion was a bad merge rather than a decision; the terminal has
been running on the two constants since.

The login screen loses its role tabs and its credential prefill. You do not
choose what you are on the way in.

The opener is now matched on the back office user id rather than on the first
account with a matching role, so the first bill of a shift is attributed to
whoever actually signed in.

Tests: the smoke suite pinned only the supervisor shell, and it was passing for
the wrong reason — the fake session omitted can_manage_staff, and the sidebar it
asserted on was there because the role was hardcoded. Both halves are pinned now
and the fake is parameterised. widget_test.dart was the stock Flutter counter
template, restored by the same bad merge, testing a MyApp that has never existed
in this repo.

292 tests pass; analyzer reports no errors and no warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:06:00 +05:30
2026-07-29 11:41:53 +05:30
2026-08-06 19:29:23 +05:30
2026-07-29 11:41:53 +05:30
2026-07-29 11:41:53 +05:30
2026-07-29 11:41:53 +05:30
2026-07-29 13:06:39 +05:30
2026-07-29 11:41:53 +05:30
2026-08-06 19:29:23 +05:30

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                # 234 unit and widget tests
flutter analyze

Out of the box the terminal runs against a local stub: products are seeded, bills queue and drain, and nothing leaves the device. Connecting it to a real back office is a Settings change, not a rebuild — see below.

Connecting to a back office

Document For
Integration guide Wiring a terminal to NATS/MQTT and an HTTP catalogue, hop by hop, with commands to prove each one
Sync contract What the back office must implement: topics, payloads, acknowledgement rules, field handling

The short version: bills are written to SQLite first and uploaded in the background, so the till never waits on the network. A bill is only marked synced when the back office names its id — a broker acknowledging receipt is not the ledger accepting the sale.

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.
  • F2 focuses search, Esc releases 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 asMoney extension 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 / dialogTheme take CardThemeData / DialogThemeData on Flutter 3.29+. On older versions, drop the Data suffix in lib/core/theme/app_theme.dart.
  • Color.withValues(alpha:) requires Flutter 3.27. On older versions substitute withOpacity().

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 drawerReceiptService.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.
  • AuthenticationcashierSessionProvider holds a hardcoded session.
  • Sounds — the three bundled WAVs are synthesised placeholders. Replace with your own; SoundService swallows playback failures so a missing file never blocks billing.
Description
No description provided
Readme 1.9 MiB
Languages
Dart 95.3%
C++ 2.4%
CMake 1.8%
Swift 0.3%
HTML 0.1%
Other 0.1%