Compare commits

27 Commits

Author SHA1 Message Date
4a2474ee6c added login 2026-08-07 17:07:49 +05:30
ad44402232 check 2026-08-07 15:31:20 +05:30
09e5e29df2 first commit 2026-08-07 15:29:10 +05:30
0988d39d8b check 2026-08-07 14:34:25 +05:30
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
eebd10da6d pos changes 2026-08-06 19:29:23 +05:30
Suriya
cb065a0f69 Put back the Linux runner and .clangd formatting
Swept into the previous commit by a git add -A while a formatter had run over
them in the background. Unrelated to that commit's subject, and the C++ change
flipped pointer style away from what the Flutter template ships, so it would
come back on every regenerate and churn again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:11:10 +05:30
Suriya
7b6cd598f0 Apply the trailing-comma lint the analyzer asks for
Formatting only, from dart fix. No behaviour change — the release APK and the
full suite both pass either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:10:48 +05:30
Suriya
4f9a5c3d6b Take staff from the back office, and let the seeded PINs die when it has any
Suriya/4821, Divya/5093, Rahul/6274 were compiled into the app — the same three
logins on every install, readable by anyone with the APK, and unreplaceable.

Sign-in now imports the outlet's real staff and deactivates everything it
didn't import, so the built-in PINs stop working the moment a shop has anyone
recorded. That deactivation is the point: merging would have left the hardcoded
logins alive alongside the real ones for ever.

The seeds stay, and that is not a hedge. Only 116 of 596 accounts on the
platform have a PIN set, and outlet 1135 — the one this build ships pointed at
— has none at all. Deleting them would hand 33 of 34 tenants a till nobody can
sign in to. So: back office first, local database once synced, seeds only when
there is nothing else.

Rows are keyed on the back office user id, so a re-sync updates one account
rather than creating a second. A leaver removed upstream loses the till on the
next sign-in. Accounts are deactivated rather than deleted, because bills carry
the cashier's name and shifts settle against it.

An import that writes nobody is treated exactly like an empty answer — a back
office full of `pin = 0` rows must not deactivate the seeds and strand the
counter. That is a real shape in the data, not a hypothetical.

An imported PIN is not flagged for change; the shop already chose it. The flag
belongs to the seeds, which everyone shares.

Role names are mapped by name and fall back to cashier. `app_roles` holds six
rows for four roles — Admin and Manager appear twice each — and most accounts
carry a roleid absent from the table entirely, so an unrecognised role must not
quietly become an admin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:02:04 +05:30
Suriya
b5b2047bcd Sign the terminal in against the back office instead of against two constants
Sign-in compared `admin@nearle.in` / `nearle123` — a compile-time const — after
a 600ms delay standing in for a network call that was never made. Two things
followed, and the second was the serious one.

Every install of a build shared one password, and changing it meant a rebuild.
Worse: because nothing was checked with the back office, the *outlet* could not
come from the sign-in. It came from a store id typed into Settings, so the till
asserted which shop it belonged to and the server took its word. One field on
one screen moved a terminal into another tenant's books.

Now a person signs in with their own back-office account and the outlet arrives
as a consequence — sealed in a signed token, checked server-side on every
request, and not editable from this device. `DemoCredentials` is gone, along
with the prefilled fields and the "Demo account" hint that printed the password
on the login screen.

The pieces:

- `PosSession` — what the back office answers with. The token is opaque on
  purpose: the till must not parse it or reason about what it appears to say.
- `SessionStore` — the whole session to the platform keystore, not SQLite. The
  token is a bearer credential and SQLite here is a file behind a shop counter.
  An expired session reads back as absent, so no caller has to remember to
  check.
- `SyncConfig.bearerToken` — one accessor rather than the same `??` at each
  call site, because the request that forgot it would be the one silently
  sending no credentials. The session beats a static API key: the key says the
  request came from our fleet, the session says which outlet it came from, and
  only the second can stop a till reaching another tenant's books.
- Restore runs in `syncBootstrapProvider` *before* the engine starts. A drain
  that began first would upload the day's bills unauthenticated. A till trades
  all day; a reboot mid-shift must not put a login screen in front of a queue.
- An outlet picker, shown only when the account genuinely reaches several. Not
  dismissable — defaulting silently to the first outlet is how a day's takings
  end up filed against the wrong shop.

Store name, address, GSTIN and phone now come down with the session and are
written on sign-in. They were compile-time constants, and on a GST invoice
those fields are a legal requirement rather than decoration.

The smoke test signs in through a fake client and inside `runAsync`: sign-in
reaches SQLite now, and real disk I/O cannot complete on a widget test's fake
clock — pumping alone leaves it suspended for ever.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:46:59 +05:30
Suriya
908058038a Send timestamps with the offset they were read in
Live bill INV-2608-T5EDD-00116 carried billedat 2026-08-05T12:49:28Z beside
receivedat 2026-08-05T07:19:28Z — the sale appearing to have been rung five and
a half hours after the back office received it. Exactly the IST offset, on every
bill.

Nothing was lying. DateTime.toIso8601String() on a local time emits no zone
marker at all, and Go's time.Parse fills that silence with UTC, so a Coimbatore
wall clock was recorded as though it had been read in London.

The daily figures survived by luck: businessdate is derived from the wall clock
either way, and the wall clock was always the till's own, so a day's takings
landed on the right day even while the instant was wrong. Anything comparing
billedat against real time did not.

Formatters.isoWithOffset attaches the offset, which fixes both readings at once
— the instant parses correctly and the local date still formats correctly. The
minutes come from the real offset rather than being assumed zero, because India
is +05:30 and a whole-hour implementation would be wrong in a way that looks
almost right.

Applied to all six timestamps the till sends. date_of_birth is left alone: a
birthday is a date, not an instant, and giving it a zone would be meaningless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:17:57 +05:30
Suriya
353c6c1075 Report health on every transport, not only the broker
A shop configured for the HTTP route uploaded 17 bills correctly today and
never once appeared on the fleet board. Nothing logged it, because from the
terminal's point of view nothing had failed: the reporter was typed against
MqttOrderTransport and started behind an `is MqttOrderTransport` check, so on
HTTP it was silently never constructed. A monitoring feature that quietly does
not exist on one of two supported routes is worse than no feature, because the
blank square reads as "no terminals" rather than "not wired up".

publishHealth moves onto the OrderTransport interface. The broker publishes to
the health topic as before; HTTP posts the same payload to POST /pos/health;
the simulated route does nothing, which is the honest answer for a till with no
back office configured. The reporter is now started for every route.

There was no test for the reporter at all, which is why this shipped. There are
five now, including one that fails on the old code.

Also removes test/widget_test.dart — the stock `flutter create` counter test,
referencing a MyApp that never existed here. It has never compiled and was the
only red in the suite.

263 tests pass, analyzer clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:28:22 +05:30
6c0266c9c7 added product import and billing integration 2026-08-05 18:15:21 +05:30
Suriya
33b4337933 Publish under nearle/pos, add a health heartbeat, send the GST slab split
Three changes, all driven by what the back office turned out to need.

The broker is shared with the rider fleet on nearle/riders/…, so topics
move under nearle/pos/{locationid}/{terminal}/… — one ACL rule per
system, and it is obvious from a topic which one owns it. Store ID now
carries the back office's numeric location id; the tenant is resolved
from it server-side and never taken from the wire.

A till publishes a heartbeat every 30 seconds on its own topic. The Last
Will already answers "is it dead", which is not enough to run a hundred
shops on: the failure that costs money is a terminal that is connected,
selling, and quietly holding two hundred bills it has never uploaded. So
the beat carries queue depth, the age of the oldest thing waiting,
today's trading, and printer reachability. Not retained — the back
office holds it under a TTL, and a retained beat would leave an
unplugged till looking alive until something overwrote it.

Bills now carry tax_breakdown, the GST slab split the cart already
computes. A tax return is filed per slab, and recomputing the split
server-side would mean redoing the discount apportionment and getting
exactly the same answer — or else the filed figure stops matching the
paper the shopper was handed.

Docs rewritten against the real deployment: Eclipse Mosquitto 2.1.2, no
NATS anywhere reachable, no TLS, and a broker whose queue and autosave
defaults mean it must not be treated as durable storage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:47:24 +05:30
Suriya
09edce5dc6 Silence clangd on the generated Linux runner
`linux/runner/` is Flutter's stock GTK host program. On macOS the GTK
development headers are absent, so clangd cannot resolve <gtk/gtk.h>,
G_DECLARE_FINAL_TYPE never expands, and a six-line main.cc reports seven
errors for symbols that macro produces.

They are editor diagnostics, not build errors — the file compiles
unchanged on Linux, and nothing in the Flutter toolchain reads clangd.
Suppressing them beats editing generated code the Linux build needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:39:56 +05:30
Suriya
fe428931ec Upload shopper registrations, and charge GST to the lines that earned it
Two defects that share a shape: a figure landing on the wrong record.

Bill-level discounts were apportioned across every line by a single
factor, so "20% off Beverages" pulled tax out of the atta line as well.
The bill total was right either way, which is what made it easy to ship
— only the slab split on a filed return was wrong. Targeted campaigns
now reduce the lines they name, and bill-wide reductions still spread
pro rata, so the arithmetic is unchanged wherever it was already right.

Shoppers registered at a till only ever reached the back office as three
fields riding along on a bill. Somebody who signed up and bought nothing
existed on one terminal and nowhere else, and two tills registering the
same mobile each minted their own row. Customers are now an outbox of
their own on pos/{store}/{terminal}/customer, and the id is a UUIDv5
over the normalised mobile number — so a hundred terminals agree on who
a shopper is without talking to each other.

Registrations go up before bills, and a failure there cannot strand a
day's takings. No loyalty figures are sent: they belong to the bill
stream, which is idempotent and knows about every counter.

Two things found while building it. Numbers were keyed on raw digits, so
a cashier typing +91 forked a shopper as effectively as a random id
would. And the sale path wrote the customer with ConflictAlgorithm
.replace, which is a DELETE and an INSERT — every column absent from the
row reverts to its schema default, so the new sync flag would have been
cleared by the shopper's next purchase.

Schema v8. Existing customers are queued rather than assumed sent: the
terminal cannot tell an imported row from a locally registered one, and
only one of those mistakes loses somebody.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:34:27 +05:30
Suriya
467d5eee75 Add an integration guide for wiring a terminal to a back office
sync-contract.md specifies what the back office must implement. It does not
say how to get a terminal talking to one, which is the question anyone
actually deploying this hits first.

Five hops, in the order they should be proved:
1. NATS with the MQTT gateway on — config snippet, narrow per-topic
   permissions, and a file-backed JetStream stream, because a memory stream
   loses a shop's bills on restart after the terminal has been told they
   landed.
2. Pointing the terminal at it from Settings.
3. Watching a bill publish.
4. Consuming, committing, and acknowledging — with the table schema, an
   idempotent insert, and the rule that the ack comes from the consumer after
   the commit rather than from an ingest handler that merely queued the work.
5. The catalogue pull, and the mid-day push that triggers it.

Each hop has a command that proves it works, because a failure at hop 4 looks
identical to a failure at hop 2 from the terminal's side — it just keeps
queueing.

Plus a troubleshooting table mapping symptoms to causes (queue refilling with
the same bills means the ack arrived after the 20s timeout; two terminals
fighting for the connection means they share a client id), and a pre-rollout
checklist: back up the database before the one-way v7 migration, build
per-ABI to cut 69MB to ~23MB, change the seed PINs, turn TLS on.

Every topic, timeout, query parameter and pill label in the guide was checked
against the code rather than written from memory.

README now points at both documents and states the default: the terminal runs
against a local stub until it is configured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 16:06:06 +05:30
Suriya
e5fc777202 Merge branch 'fix/billing-data-integrity'
Eight commits taking the terminal from a demo to something a fleet can run.

- Bills drain to the back office automatically over MQTT or HTTP, with
  application-level acknowledgement, backoff and a 7-day recovery window.
- Every terminal has its own identity, so 100 devices no longer share one set
  of MQTT topics, one client id and one invoice series.
- Staff PINs moved out of the shipped binary into PBKDF2 hashes.
- Cash drawer, store details, staff management and promos all built for real
  rather than mocked.
- The catalogue now pulls from a real endpoint with paged delta sync.

234 tests passing, analyzer clean.
2026-08-01 15:42:55 +05:30
Suriya
fbfc02d140 Pull the catalogue from a real endpoint, with delta sync
Replaces the last simulation on the inbound side. RemoteCatalogueSource
returned SeedData after a fake progress bar; there was no wire format, no
endpoint, and no way to receive an update short of reinstalling.

Wire format (data/remote/catalogue_wire.dart)
- Tolerant where it should be: a catalogue of 4,000 products must not fail to
  import over one absent emoji, so optional fields take defaults and an
  unrecognised category files under Grocery — the item still scans, prices and
  bills.
- Strict where it matters: no id, name, barcode or price and the import fails.
  A silently dropped product is a shelf item that scans to nothing, discovered
  with a queue waiting.
- GST accepts 18 or 0.18 and reads both the same. Back offices disagree about
  which they mean, and getting it wrong silently changes the tax on every line.

HTTP source with paging and deltas
- GET {base}/catalogue?since={revision}&page={n}. Paged because a supermarket
  catalogue is tens of thousands of rows: one response times out on a shop's
  line and stalls the UI decoding it. Capped at 200 pages so a bad deployment
  cannot become an infinite request loop against a shop's connection.
- `since` carries the revision already held, so a normal morning fetches a
  handful of price changes rather than the whole book. A server that cannot do
  deltas ignores it and answers is_delta:false — the terminal reads the flag
  rather than assuming, so both work.
- A bad credential is non-retryable and says so, leaving the working catalogue
  in place so billing continues.

Applying deltas without losing local state
- A full snapshot withdraws what it omits; a delta must not. Read as a
  snapshot, the first morning price change would empty the shelf.
- Retired products are marked inactive, not deleted — order lines already
  recorded point at them, and a hard delete would orphan a bill's history.
- Locally registered shoppers survive a pull, as before.
- The unsynced-stock replay is now scoped to the products the pull actually
  overwrote. It exists because a server count predates local sales; running it
  over a delta that never carried that product would subtract those units a
  second time and quietly empty a shelf that is full. Both halves of that rule
  are tested.

MQTT stays the nudge, not the transport: a catalogue push on
pos/{store}/catalogue makes every terminal pull immediately, but the rows come
over HTTP, because a broker is the wrong shape for tens of thousands of them.

Tests: 210 -> 234. docs/sync-contract.md now covers both directions, including
a field-by-field table of what happens when something is missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:37:42 +05:30
Suriya
46d354ced1 Build promos for real: engine, storage, editor, and application at the till
The Promo module was a mockup. Three hardcoded rows, a toggle that changed
nothing, and no promo code anywhere in lib/domain or lib/data. A cashier
looking at it would reasonably conclude promotions were running.

Engine (domain/services/promo_engine.dart)
- Five campaign types: percent or flat off the bill, percent off a category
  or a product, and buy-X-get-Y.
- Conditions: date range (inclusive of the closing day), days of the week,
  minimum bill value, and a cap on what a percentage can take off — without
  one an unusually large trolley gives away more than the campaign was costed
  for.
- Stacking is conservative by default. All stackable campaigns apply together;
  of the exclusive ones only the single best does, chosen by what it is worth
  to the shopper with priority breaking ties. Two percentages compounding
  produce a discount nobody signed off, and the shop finds out at the end of
  the month.
- The total is capped at the subtotal, so no combination of campaign, tier and
  manual discount can turn a sale into a payout.
- buy-X-get-Y counts whole groups only, and prices the free unit at what is
  actually being charged — a line already carrying a manual discount must not
  refund more than it took.

Kept out of Cart deliberately: Cart owns arithmetic that must never be wrong,
this owns policy a shop changes weekly.

Storage (schema v6, plus promos_json on orders at v7)
- Campaigns persist locally, because a shop mid-promotion with a dead line
  still has to honour the price on the shelf edge.
- A bill records the campaign name and the amount given, not a link to the
  row. A campaign edited or deleted later cannot change what a past sale
  shows, and a reprinted receipt still names what the shopper was given.
- On read-back the promo amounts are subtracted from the manual discount,
  because bill_discount already contains them. Restoring both at full value
  would discount the bill twice — the same shape as the bug that used to
  overstate synced totals.

At the till
- Every cart mutation re-evaluates, so a promo cannot survive the line that
  earned it being removed.
- A resumed parked bill is re-evaluated rather than restored: a campaign that
  has since ended must not be honoured because the bill was parked while it
  was running.
- Campaigns are named individually on the billing panel and the printed
  receipt, so a shopper who came in for an advertised offer can see it applied.

Editor
- Full CRUD, admin-only, with validation for the cases that would save happily
  and then silently never fire — a targeted campaign with no target, a
  percentage over 100, an end date before the start.

Tests: 199 -> 210. Covers each campaign type, the eligibility conditions, the
stacking rules, the impossible-to-go-negative guarantee, GST recomputation
against the reduced total, round-tripping, and the double-count guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:36:50 +05:30
Suriya
fdd90f28d9 Open the cash drawer for real, and persist the back-office route
Cash drawer
- openCashDrawer was a debugPrint. The drawer never opened.
- It cannot go through the PDF pipeline: a PDF is rendered by the platform
  driver, which will not pass raw ESC/POS bytes to the device. So it goes over
  a socket instead — nearly every network thermal printer listens on 9100 and
  forwards whatever arrives straight to the print head, which makes the whole
  protocol five bytes.
- Printer IP and port are configurable in Settings with a Test button that
  saves and fires immediately, because a drawer that does not open is
  indistinguishable from one that is not wired up.
- Every failure explains itself: unreachable, refused, or simply not
  configured — which is the honest state for a USB printer, since there is no
  raw path to one from Flutter.
- Now fires only on a cash tender. A card-only sale that pops the drawer is a
  shrinkage risk, and it is the first thing a shop notices.

Back-office route
- Host, port, TLS and transport persist to the database; username, password
  and API key go to the platform keystore (Keychain / Credential Manager /
  Android Keystore). Writing credentials into SQLite would put them in the
  same file as the bills, on a machine behind a shop counter.
- Loaded at startup. Previously the dialog wrote settings that were silently
  ignored on the next launch, which reads exactly like they never saved — and
  credentials retyped every morning end up on a sticky note instead.
- A saved route never overwrites the terminal's store or terminal id. Those
  belong to the device, and re-pointing a till at a different broker must not
  change who it is, or its bills and presence records stop lining up.

Tests: 168 -> 176. The drawer test stands up a real socket server and asserts
the exact bytes arrive. The config test asserts no credential appears anywhere
in the meta table while the non-secret settings do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:18:56 +05:30
Suriya
3513281a11 Build staff management, store details editing, and a forced PIN change
Wires the last two dead buttons in Settings and closes the loop on the
credential work: hashed PINs are only worth having if a shop can actually
change them.

Users & roles (Manage)
- Add, rename, re-role and remove staff. Admin-only at the door, because
  anyone who can edit staff can make themselves an admin.
- PIN and confirmation are both required and must match. There is no email to
  reset a PIN with, so a typo nobody can verify locks the account out until an
  admin intervenes.
- Editing someone leaves their PIN alone unless a new one is typed. An admin
  setting another person's PIN counts as a reset and re-arms must-change.
- Removal is a deactivation with a confirmation that explains why: bills
  already rung keep the cashier's name, so shift reports stay correct.
- Anyone still on a shipped PIN is flagged in the list and in Settings.

Store details (Edit)
- Name, address, GSTIN and phone now editable and persisted. GSTIN is format
  and state-code validated; it prints on every invoice as a legal requirement,
  so a typo is a compliance problem across hundreds of bills.
- Admin-only: changing the GSTIN changes what every future invoice claims
  about who collected the tax.

Forced PIN change
- Shown once after sign-in while must-change is set, and not dismissable. The
  seeded PINs are in the source of the build, so a terminal still running one
  is effectively unprotected.

Fixed while testing: the role dropdown laid its items out at natural width and
"Manager — Sales, inventory and reports" overflowed the dialog by 222px. Now
isExpanded with the description spelled out below, where it is readable.

Tests: 160 -> 168. Covers both role guards, the mismatched and too-short PIN
paths, the default-PIN flag, and GSTIN and seller-name validation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:10:49 +05:30
Suriya
e17937e8f1 Move staff PINs out of the shipped binary into hashed database rows
Three StaffUser constants carried plaintext PINs (1234/2345/3456) in
auth_controller.dart. Every build shipped every till's credentials, readable
by anyone who unzipped the APK. Across 100 deployed devices that is one
credential, not a hundred.

- Schema v5 adds a staff table. Only a PBKDF2-HMAC-SHA256 hash and a per-user
  random salt are stored; the PIN itself exists nowhere, including there.
  12,000 iterations, tuned so one sign-in is imperceptible while working
  through all 10,000 four-digit PINs against a stolen database takes ~15
  minutes per account instead of milliseconds.
- Verification is constant-time. String == returns at the first differing
  byte, and that timing leaks how much of a guess was right.
- StaffUser no longer has a pin field at all, so the credential cannot drift
  back into memory, into widgets, or into a const declaration.
- Weak PINs are refused: under four digits, non-numeric, repeated digits, and
  sequences. Two staff cannot share a PIN — the till identifies a cashier by
  PIN alone, so a shared one would attribute bills to whichever row was
  checked first.
- The last admin cannot be demoted or deactivated. A till with no admin cannot
  be administered, including to appoint one, and recovering means editing the
  database by hand.
- Staff are deactivated, never deleted, so bills already rung keep naming a
  real person.

Seed accounts are now 4821/5093/6274 rather than 1234/2345/3456 — the weak-PIN
rule refuses the old ones, and a default the rule itself would reject is not a
defensible default. All three are flagged must-change-pin so they get a shop
trading on day one without becoming permanent.

Store details are now editable data, not compile-time constants. Name,
address, GSTIN and phone persist to the database and are read back rather than
falling through to the build's constants, which would silently undo a failed
save. GSTIN is format-validated including the state code — it prints on every
invoice as a legal requirement, so a typo is a compliance problem across
hundreds of bills before anyone notices.

Tests: 141 -> 160. Includes a test that reads every column of every staff row
and asserts no seed PIN appears anywhere in the database.

Migration test now asserts v5 and that an upgraded terminal comes up with the
staff table present but empty — seeding is the store's job on first open, so
an existing shop is never handed accounts it did not create.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:00:12 +05:30
Suriya
174fdddb8a Wire the dead Add customer button to the existing capture sheet
The Customers module's primary action did nothing. The sheet it needed was
already built and in use at checkout, so this reuses it — a customer added
from the book now goes through the same validation, including the
duplicate-mobile guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:43:05 +05:30
Suriya
33467e6963 Add a back-office setup dialog, and clear the last lints
Settings > Connectivity > Configure now points a terminal at a back office.
Until this existed a store was wired up by editing syncConfigProvider and
rebuilding, which made every terminal in a fleet its own build.

- Transport picker (offline demo / HTTP / MQTT) with only the relevant fields
  shown, validated: an MQTT route with no host is refused rather than silently
  saved, because a terminal pointed at nothing looks exactly like one that is
  merely offline.
- Terminal name and store id are editable and persist to the database. The
  device id and terminal code are shown but not editable, with a copy button —
  they are what a support call needs, and re-coding a till must not orphan the
  bills already written under the old code.
- TLS defaults on, with a note that bills carry customer names and numbers.
- About card now shows the real terminal, device id and store instead of the
  literal TERM-01, and the dead "Check for updates" button is now the entry
  point to this dialog.

Lints cleared, analyzer now reports zero issues:
- SoundService wrapped a plain bool in a getter and setter that did nothing.
- Two post-await guards used context.mounted inside a State, which the
  analyzer cannot relate to the State's own lifetime. Both are now `mounted`.

Tests: 140 -> 141. The new widget test drives the dialog end to end and asserts
that saving an MQTT route with no host keeps the dialog open with the error
visible. Suite run three times clean.

Known gap, documented in docs/sync-contract.md: broker credentials live in
memory and must be re-entered after a restart. Persisting them means
encrypting at rest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:47:11 +05:30
Suriya
30d6d1080f Give every terminal its own identity, and report fleet presence
Answers "which of my 100 tills are alive and healthy", and fixes three things
that were fine on one device and broken on a hundred.

Terminal identity (lib/data/local/terminal_identity.dart)
- Every device mints a UUID on first run, stored in its own database, plus a
  short code (T4A9) derived from it. Renaming keeps the device id, so history
  keeps pointing at the same physical till.
- Replaces the literal 'TERM-01', which was hardcoded in five places. The whole
  fleet reported as one terminal: shift reports merged, MQTT topics collided,
  and a second connection with the same client id evicts the first from the
  broker — so two tills would have knocked each other offline in a loop.

Invoice numbers now carry the terminal code
- INV-2608-T4A9-00042. The sequence counter lives in each till's own database
  and starts at 1, so without this every terminal in the fleet minted
  INV-2608-00001 for its first sale of the month. The order UUID kept the data
  distinct; the number a customer quotes on a receipt was not.

SQLite pragmas
- WAL, so the product grid refreshing does not block the sale being written,
  and the file is never left mid-rewrite by a power cut.
- busy_timeout 5s, so a contended lock waits instead of throwing "database is
  locked" — which at checkout is a failed sale with a customer standing there.
- synchronous NORMAL, the right trade under WAL for a till.

Fleet presence (lib/data/sync/presence_reporter.dart)
- Retained status record on connect and once a minute: device id, code, name,
  app version, pending bill count, last upload, catalogue revision, sync halt
  state. Retained so a dashboard connecting at noon gets all 100 terminals
  immediately rather than a blank board.
- The Last Will already said "reachable". A till can be connected and still be
  holding 200 unsent bills or running last month's prices; only pending_bills
  and catalogue_revision say so.

NATS
- The MQTT gateway maps / to . so the existing transport works unchanged.
  SyncConfig.asNatsSubject() exposes the translation, and the contract doc
  gives the JetStream subjects (pos.*.*.order, pos.*.*.status) plus the two
  server-side requirements: a file-backed stream, and the ack published by the
  consumer after commit rather than by the ingest handler.

Tests: 129 -> 140. New coverage for identity minting and stability, per-device
invoice uniqueness, topic and client-id separation, NATS subject mapping, and
the two pragmas. Suite run three times clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:17:23 +05:30
Suriya
0a49323858 Drain bills to the back office automatically, over MQTT or HTTP
Turns the orders table into a queue that empties itself. Bills were only
uploaded when a cashier pressed Sync at end of day; a till that was never
pressed held a day's takings indefinitely.

Drain engine (lib/data/sync/sync_engine.dart)
- Triggers on sale committed, network regained, 5-minute poll, head-office
  request, and the manual button.
- Single flight: a busy till firing a trigger per sale would otherwise have
  several passes reading the same pending rows and send every bill twice.
  A trigger arriving mid-drain is queued and replayed, so nothing is dropped.
- Exponential backoff with +/-20% jitter to a 5-minute ceiling. The jitter
  matters: a store's terminals all fail at the same instant when the line
  drops, and would retry in lockstep without it.
- Halts rather than loops on a failure retrying cannot fix (bad credential,
  refused batch). Pressing Sync clears the halt.

Transports (lib/data/remote/)
- OrderTransport interface; MQTT, HTTP and simulated implementations. The
  repository does not know which is in use.
- MQTT: QoS 1 uplink, application-level ACK correlated by batch_id on a return
  topic, retained Last Will for terminal-offline detection, downlink for
  catalogue pushes and remote sync requests.
- A broker PUBACK is never treated as acceptance. It means the broker holds
  the bytes, not that the ledger took the sale. Only ids the back office names
  are marked synced; silence leaves a bill pending.
- HTTP carries a stable idempotency key across retries of the same bills.

Retention
- Accepted bills are kept 7 days instead of deleted, so a batch the back
  office later loses can be re-sent in full. Purged after that; archived
  totals stay forever.
- forBusinessDate now reads pending rows only. A retained bill exists in both
  the orders table and day_archive, and summing both would overstate the day.

Fixes found while building this
- SyncEngine._refreshPending wrote state.copyWith(pending: await ...). Dart
  evaluates the receiver before the awaited argument, so a connectivity drop
  during the wait was silently overwritten by the stale snapshot. Caught by
  the first run of the new engine tests.
- PrinterSettingsController wrote state after four awaits with no mounted
  check, throwing "used after dispose" when Settings was left mid-load. This
  was pre-existing and reached the cashier as a red screen.

Also
- Header pill now reports real sync state: LIVE / n QUEUED / SYNCING /
  SYNC HALTED, with an explanation of where the bills are.
- Settings shows the route, last upload, next retry and retention window.
- docs/sync-contract.md states what the back office must implement, including
  the idempotency requirement that at-least-once delivery makes mandatory.

Tests: 90 -> 129 passing. New coverage for backoff shape and jitter band,
single flight, halting, ACK correlation and partial acceptance, at-least-once
duplicate handling, retention and purge, and no double-counting after a sync.
Suite run six times clean.

Not addressed: bills already synced by an older build went up overstated and
still need server-side reconciliation. Broker credentials have no Settings
editor yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:57:29 +05:30
118 changed files with 17355 additions and 2569 deletions

27
.clangd Normal file
View File

@@ -0,0 +1,27 @@
# clangd configuration.
#
# `linux/runner/` is the GTK host program Flutter generates for the Linux
# desktop target. It is stock scaffolding — unmodified since `flutter create` —
# and it builds correctly on a Linux machine with the GTK development headers
# installed.
#
# On macOS and Windows those headers do not exist, so clangd cannot resolve
# `#include <gtk/gtk.h>`. Everything downstream then collapses: `MyApplication`,
# `my_application_new` and `g_autoptr` are all produced by the
# `G_DECLARE_FINAL_TYPE` macro, which never expands, so the editor reports a
# handful of undeclared identifiers and an unused include on a six-line file
# that has nothing wrong with it.
#
# Those are editor diagnostics, not build errors. `flutter build linux` on a
# Linux box compiles this unchanged; nothing in the Flutter toolchain reads
# clangd. Suppressing them here keeps the noise out of the problems panel
# without touching generated code that the Linux build depends on.
#
# If you do want real analysis of this directory, do it on Linux with the GTK
# headers present and a compile_commands.json — not by editing the runner.
If:
PathMatch: linux/.*
Diagnostics:
Suppress: '*'
UnusedIncludes: None

View File

@@ -11,10 +11,26 @@ Brand colour `#662582` · Inter typeface · 16px corner radius · touch-first ta
```bash
flutter pub get
flutter run -d windows # or macos, linux, or a connected tablet
flutter test # 60+ unit and widget tests
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](docs/integration-guide.md) | Wiring a terminal to NATS/MQTT and an HTTP catalogue, hop by hop, with commands to prove each one |
| [Sync contract](docs/sync-contract.md) | 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](#version-compatibility) if `flutter analyze` complains about theme types.
---
@@ -146,3 +162,4 @@ Deliberately out of scope, stubbed as clear extension points:
- **Bottom navigation modules** — Dashboard, Products, Inventory, Customers, Promos, Reports, Suppliers and Settings render with live badge counts but are not routed.
- **Authentication** — `cashierSessionProvider` 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.
# pos

BIN
assets/images/bg.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

267
docs/integration-guide.md Normal file
View File

@@ -0,0 +1,267 @@
# Connecting a terminal to your back office
Step-by-step wiring. The companion to [`sync-contract.md`](sync-contract.md),
which specifies *what* the back office must implement — this one covers *how* to
get a terminal talking to it, and how to prove each hop works before moving to
the next.
Nothing here needs a rebuild. A terminal is pointed at a back office from
Settings.
---
## The five hops
```
1. Mosquitto reachable, with a scoped account for the tills
2. Terminal connects and shows LIVE
3. Terminal publishes a bill
4. Your consumer commits it and acks
5. Terminal marks it synced and stops re-sending
```
Do them in order. A failure at hop 4 looks identical to a failure at hop 2 from
the terminal's side — it just keeps queueing — so proving each one saves a lot of
guessing.
---
## Hop 1 — Mosquitto, with an account for the tills
The deployed broker is **Eclipse Mosquitto 2.1.2** at `66.116.225.226:1883`,
already carrying `nearle/riders/#` and `doormile/#`. There is no NATS in this
estate — the NATS servers that exist belong to other projects, on hosts whose
ports are closed, and their configs expose no MQTT gateway.
Current config has `allow_anonymous false` and a password file, but **no
`acl_file`** — so every authenticated user, including the `admin` account
hardcoded in the rider APK, has full run of every topic. Add scoped accounts
before a hundred tills start publishing takings:
```bash
mosquitto_passwd -b /mosquitto/config/passwd pos_terminal '<strong-pw>'
mosquitto_passwd -b /mosquitto/config/passwd pos_ingest '<different-pw>'
```
```conf
# /mosquitto/config/acl, then add `acl_file /mosquitto/config/acl` to mosquitto.conf
user pos_terminal
topic write nearle/pos/+/+/order
topic write nearle/pos/+/+/customer
topic write nearle/pos/+/+/status
topic write nearle/pos/+/+/health
topic read nearle/pos/+/+/ack
topic read nearle/pos/+/+/command
topic read nearle/pos/+/catalogue
user pos_ingest
topic read nearle/pos/+/+/order
topic read nearle/pos/+/+/customer
topic read nearle/pos/+/+/health
topic write nearle/pos/+/+/ack
topic write nearle/pos/+/catalogue
user admin
topic readwrite nearle/riders/#
topic readwrite doormile/#
```
Prove it:
```bash
mosquitto_sub -h 66.116.225.226 -p 1883 -u pos_ingest -P '<pw>' \
-t 'nearle/pos/#' -v &
mosquitto_pub -h 66.116.225.226 -p 1883 -u pos_terminal -P '<pw>' \
-t nearle/pos/12/T0000/order -m 'hello'
```
**The broker is a transport, not a ledger.** `max_queued_messages` defaults to
1000 and `autosave_interval` to 30 minutes, so a long outage or a hard kill can
drop queued messages. That costs nothing here — an undelivered batch is never
acked, so the till keeps it and sends again — but only while nobody
acknowledges on the broker's behalf.
**TLS is not configured** and 8883 is closed. Bills carry customer names and
mobile numbers; worth adding a listener before rollout rather than after.
## Hop 2 — Point the terminal at it
On the terminal: **Settings → Connectivity & sync → Configure**.
| Field | Value |
|---|---|
| Terminal name | What staff call this till, e.g. "Counter 2" |
| Store ID | **The numeric `locationid`.** The tenant is resolved from it server-side |
| Transport | MQTT |
| Broker host / port | `66.116.225.226`, port `1883`, **TLS off** |
| Username / password | The `pos_terminal` account from hop 1 |
| Use TLS | **Off** — 8883 is not configured on this broker yet |
The dialog also shows a **Device ID** and a terminal code like `T4A9`. Neither is
editable. They are minted on first run and stay with the physical machine, which
is what keeps 100 terminals from colliding on topics, client ids and invoice
numbers. Note the code down — it is what a support call needs.
Credentials go to the OS keystore (Keychain / Credential Manager / Android
Keystore), not into the database alongside the bills.
Prove it: the header pill switches from `OFFLINE (SIM)` to `LIVE`, and
```bash
mosquitto_sub -h 66.116.225.226 -u pos_ingest -P '<pw>' -t 'nearle/pos/+/+/status' -v
```
should immediately show a retained presence record for the terminal. If it
doesn't, the terminal never connected — check the broker log for an auth
rejection before looking anywhere else.
---
## Hop 3 — Ring a sale and watch it publish
```bash
mosquitto_sub -h 66.116.225.226 -u pos_ingest -P '<pw>' -t 'nearle/pos/+/+/order' -v
```
Ring a bill on the terminal. Within a couple of seconds you should see the
envelope from [`sync-contract.md`](sync-contract.md#payloads) — a `batch_id`, the
store and terminal, and an `orders` array.
The header pill will show `1 QUEUED` and stay there, because nothing has
acknowledged it yet. That is correct behaviour, not a fault.
---
## Hop 4 — Consume, commit, acknowledge
**This is already built**, in the Fiesta backend. See
`backend_fiesta/POS_TERMINAL_INGEST.md` for how to turn it on; what follows is
what it guarantees, so you can check it still holds if anyone changes it.
Set `MQTT_URL` and it subscribes to `nearle/pos/+/+/{order,customer,health}`,
commits, and acknowledges. Bills land in `pos_orders` / `pos_order_items`, and
the stock they consumed goes through the same `productstocks` ledger an app
order uses.
Three rules the implementation is built around, and that any replacement must
also keep:
**Acknowledge from the consumer, after the database commit.** Not from a handler
that has merely queued the work. That ack is the terminal's only evidence, and
it deletes its own copy seven days later on the strength of it.
**A duplicate is accepted, not rejected.** QoS 1 is at-least-once and a lost ack
makes the terminal re-send the whole batch. Reporting those as failures would
strand a day of takings on the till. Deduplication is a unique index on the
till's UUID plus a Postgres advisory lock.
**Read the store and terminal from the topic, never the body.** A till that
could name its own store in a payload could redirect another counter's
acknowledgements.
Prove the ack path by hand before trusting the consumer:
```bash
# copy batch_id and the order id from the hop-3 output
mosquitto_pub -h 66.116.225.226 -u pos_ingest -P '<pw>' \
-t 'nearle/pos/12/T4A9/ack' \
-m '{"batch_id":"<paste>","accepted":["<paste-order-id>"]}'
```
The pill should flip to `LIVE` and the bill disappear from the queue.
### Shopper registrations
A second uplink runs on `nearle/pos/{loc}/{terminal}/customer`, acked on the
same topic by the same rules, and handled by the same consumer.
The id is a UUIDv5 over the shopper's normalised ten-digit mobile, so two tills
registering the same person independently produce the same row. It is stored
insert-if-absent — never an update, so a profile corrected at head office is not
reverted by a terminal replaying an old capture. No loyalty figures travel
upward: those are derived from the bill stream, which is idempotent and sees
every counter.
```bash
mosquitto_sub -h 66.116.225.226 -u pos_ingest -P '<pw>' -t 'nearle/pos/+/+/customer' -v
```
Add a shopper on the terminal — no sale needed — and it should appear.
### Terminal health
Every till publishes to `nearle/pos/{loc}/{terminal}/health` every 30 seconds.
The consumer writes it to Redis as `pos:terminal:{code}` under a 90-second TTL,
so a till that loses power ages off the board by itself. Read it back at
`GET /live/api/v1/pos/health/location?location_id=12`.
Heartbeats are never acknowledged — a till that could be blocked by a busy
dashboard would be a self-inflicted outage.
## Hop 5 — Catalogue down
The catalogue is a bulk pull over HTTP, not MQTT — a broker is the wrong shape
for tens of thousands of rows. Set the **Base URL** in the same Configure dialog
and implement:
```
GET {base}/catalogue?since={revision}&page={n}&store_id=…&terminal_id=…
Authorization: Bearer {apiKey}
```
Full field-by-field behaviour, including what happens when something is missing,
is in [`sync-contract.md`](sync-contract.md#catalogue-pull). The two things
easiest to get wrong:
- **`is_delta` is load-bearing.** A full snapshot withdraws every product it does
not mention. Answer `is_delta: true` for a change set, or the first morning
price change empties the shelf.
- **Send `stock` only when you mean it.** Any product in the payload gets its
count overwritten with your figure, which predates sales the terminal has rung
but not uploaded. The terminal replays those — but only for products the
payload carried.
To push a change mid-day rather than waiting for the next pull:
```bash
mosquitto_pub -h 66.116.225.226 -u pos_ingest -P '<pw>' \
-t 'nearle/pos/12/catalogue' -m '{"revision":"rev-8822"}'
```
Every terminal in that store pulls immediately.
---
## Troubleshooting
| Symptom | Where to look |
|---|---|
| Pill stuck on `OFFLINE (SIM)` | Simulate offline is still on in Settings |
| Pill shows `LIVE`, no presence on `nearle/pos/+/+/status` | Terminal never connected — check broker auth logs |
| Bills publish, queue never empties | You are acking the wrong `batch_id`, or not acking at all |
| Queue empties then refills with the same bills | Ack arriving after `ackTimeout` (20s default) — the terminal gave up and re-sent |
| Two terminals fighting for the connection | They share a client id. Each device mints its own; check they have different terminal codes |
| `SYNC HALTED` | You named an id in `rejected`. The reason is on the pill tooltip and in Events |
| Duplicate rows server-side | No unique index on `order.id`. At-least-once delivery makes it mandatory |
| Shelf empties after a price change | You sent a delta with `is_delta: false`, or a stale `stock` |
The **Events** module on the terminal shows every sync attempt with its error,
and per-bill state — start there before the broker logs.
---
## Before a fleet rollout
- **Add the broker ACL first** (hop 1). Today every authenticated user is
unrestricted on every topic, including the `admin` account hardcoded in the
rider APK.
- **Back up `nearle_pos.db` on any terminal already trading.** The schema goes to
v8 on first launch and the migration is one-way. It also queues every shopper
already on the terminal for upload, so expect one burst of registrations from
each existing store — collapse those onto the mobile number.
- **Build per-ABI.** `flutter build apk --split-per-abi` gives ~23MB per
architecture instead of a 69MB universal APK — worth it over shop wifi.
- **Change the seed PINs.** `4821` / `5093` / `6274` are in the source. Every
account is flagged to force a change at first sign-in, but a shop that
dismisses it is running a published credential.
- **Turn TLS on.** Bills carry customer names and mobile numbers.

310
docs/sync-contract.md Normal file
View File

@@ -0,0 +1,310 @@
# Terminal ↔ back office sync contract
What the till guarantees, and what the back office must do to hold up its end.
Everything here is enforced by tests in `test/unit/retention_test.dart`,
`test/unit/transport_test.dart` and `test/unit/sync_engine_test.dart`.
## The shape
```
Customer pays
One SQLite transaction: bill + stock + loyalty ← never blocked on network
orders row lands at sync_status = 0 ← this table IS the outbox
SyncEngine drains on: sale committed · network back · 5-min poll · head office
│ asked · cashier pressed Sync
Transport publishes a batch
Back office commits and names the ids it took
Those rows → sync_status = 1, folded into day_archive, kept 7 days, then purged
```
Anything the back office does not name stays at 0 and goes again.
## Non-negotiables
**1. Only an application acknowledgement counts.**
A broker PUBACK means "I hold these bytes". It is not evidence the ledger
accepted anything, and the terminal never treats it as such. The back office
must answer on the ack topic naming the order ids it committed.
**2. Silence is not acceptance.**
A `200 OK` with an empty body, or an ack with no `accepted` array, marks *zero*
bills synced. The terminal will send them again rather than guess.
**3. Delivery is at-least-once, so the back office must be idempotent.**
QoS 1 re-delivers, and a lost ack makes the terminal re-send the whole batch.
Every `order.id` is a UUID minted at the till. Put a unique index on it and
upsert. Without this you will double-count a day's takings the first time a
shop's line wobbles.
Invoice numbers are `INV-2608-T4A9-00042` — the terminal code is in there
because each till's sequence counter lives in its own database and starts at 1.
Unique per terminal, not globally sequential. Do not assume gaps mean missing
bills; a till that was replaced restarts its own series.
**4. A refusal is final, a failure is not.**
Naming an id in `rejected` halts the terminal's drain — it will not retry the
same bytes, and a person has to press Sync. Use it for "this bill is wrong"
(unknown product, duplicate invoice). For "I am having a bad minute", drop the
connection or return 5xx instead, and the terminal will back off and retry.
## MQTT topics
| Topic | Direction | QoS | Retained |
|---|---|---|---|
| `nearle/pos/{loc}/{terminal}/order` | till → cloud | 1 | no |
| `nearle/pos/{loc}/{terminal}/customer` | till → cloud | 1 | no |
| `nearle/pos/{loc}/{terminal}/health` | till → cloud | 1 | no |
| `nearle/pos/{loc}/{terminal}/ack` | cloud → till | 1 | no |
| `nearle/pos/{loc}/{terminal}/status` | till → cloud | 1 | **yes** |
| `nearle/pos/{loc}/{terminal}/command` | cloud → till | 1 | no |
| `nearle/pos/{loc}/catalogue` | cloud → all tills | 1 | **yes** |
Namespaced under `nearle/` alongside the rider fleet's `nearle/riders/…`, so one
broker ACL rule covers each system.
`{loc}` is the back office's numeric location id, entered once in Settings; the
tenant is resolved from it server-side and never taken from the wire.
`{terminal}` comes from the device's own identity, minted on first run and
stored in its database. They are never literals — 100 tills sharing one
id would collide on every topic and evict each other from the broker, since a
second connection with the same client id kicks the first off.
`status` is also the Last Will. If a till loses power the broker publishes
`{"state":"offline"}` on its behalf — that is what makes a "which tills are
dark" board possible, and it is the only way to tell *closed for the night*
from *unplugged*.
### Running this on Mosquitto
The deployed broker is Eclipse Mosquitto 2.1.2. A consumer binds to the topics
above directly, using `+` as the single-level wildcard:
| Purpose | Filter |
|---|---|
| Every till's bills | `nearle/pos/+/+/order` |
| Every till's heartbeat | `nearle/pos/+/+/health` |
| One shop's bills | `nearle/pos/12/+/order` |
| Ack back to one till | `nearle/pos/12/T4A9/ack` |
Two things to get right:
- **Publish the ack from the consumer, after the database commit** — not from an
ingest handler that has merely queued the work. The ack is the terminal's only
evidence, and it deletes its copy seven days later on the strength of it.
- **Do not treat the broker as durable storage.** Mosquitto's default
`max_queued_messages` is 1000 and its `autosave_interval` is 30 minutes, so a
long outage or a hard kill can drop queued messages. Nothing is lost, because
an undelivered batch is simply never acked and the till sends it again — but
only as long as nobody acknowledges on the broker's behalf.
### Fleet presence
Every terminal publishes a retained record on its status topic on connect and
once a minute. Retained matters: a dashboard connecting at noon gets all 100
terminals' last state immediately instead of a blank board.
```json
{
"schema": 1, "state": "online",
"device_id": "…", "terminal_code": "T4A9", "terminal_name": "Counter 2",
"store_id": "store-01", "app_version": "1.1.0",
"reported_at": "2026-08-01T14:22:05Z",
"pending_bills": 3, "last_upload_at": "…", "catalogue_revision": "rev-8821",
"sync_halted": false, "sync_error": null, "consecutive_failures": 0,
"transport": "mqtt"
}
```
The Last Will answers *is it reachable*. These fields answer *is it healthy*
a till can be connected and still be holding 200 unsent bills or running last
month's price list, and only `pending_bills` and `catalogue_revision` will say
so.
## Payloads
**Uplink**`nearle/pos/{loc}/{terminal}/order`
```json
{
"schema": 1,
"batch_id": "9f1c…",
"store_id": "store-01",
"terminal_id": "T4A9",
"sent_at": "2026-08-01T14:22:05.123Z",
"orders": [ { "id": "…", "invoice_number": "…", "items": [ ] } ]
}
```
**Ack**`nearle/pos/{loc}/{terminal}/ack`. Must echo `batch_id`; anything else is
ignored as belonging to a batch the terminal is no longer waiting on.
```json
{
"batch_id": "9f1c…",
"accepted": ["order-uuid-a", "order-uuid-b"],
"rejected": { "order-uuid-c": "duplicate invoice number" }
}
```
No ack within `SyncConfig.ackTimeout` (20s default) → the outcome is unknown,
nothing is marked synced, and the batch goes again.
**HTTP equivalent**`POST {base}/orders`, same body, ack shape as the 200
response. Carries an `idempotency-key` header that is stable across retries of
the same bills.
### Shopper registrations
**Uplink**`nearle/pos/{loc}/{terminal}/customer`, or `POST {base}/customers`.
Acked on the same topic and by the same rules: only ids you name are marked
sent.
```json
{
"schema": 1,
"batch_id": "3d7a…",
"store_id": "store-01",
"terminal_id": "T4A9",
"customers": [
{
"id": "7a24e082-060f-5503-aa9e-da0ef42df047",
"mobile": "9840012345",
"name": "Meena",
"email": null,
"gender": "female",
"date_of_birth": null,
"registered_at": "2026-08-01T10:14:00.000Z",
"registered_by_terminal": "T4A9"
}
]
}
```
Three things about this payload are load-bearing:
- **`id` is derived from the mobile number**, not minted at random — a UUIDv5
over the normalised ten-digit number in a fixed namespace. Two tills that
register the same shopper independently produce *the same id*, so you
deduplicate on a primary key rather than guessing at a merge later. Do not
reassign it.
- **Treat it as insert-if-absent on `id`.** A registration is not a financial
record: it is replayed freely, and it must never overwrite a profile
corrected at head office. `ON CONFLICT (id) DO NOTHING`.
- **No loyalty figures are sent.** Points, lifetime spend and visit counts are
absent on purpose — derive them from the bill stream, which is authoritative
and idempotent. Accepting a terminal's local balance would make the last till
to sync win, and a shopper who bought at two counters on the same day would
end up with whichever figure happened to arrive second.
Registrations are uploaded *before* bills on every pass, so a bill naming a new
shopper arrives after the shopper does. A failure here is logged and does not
hold up the bills behind it.
Terminals that were trading before schema v8 carry shoppers with random ids
from the old scheme. Those are queued once by the migration and arrive with
their original ids — merge them onto the mobile number. It is a one-off for
existing stores; a new terminal never produces one.
## Catalogue pull
The other direction: products and customers coming down.
```
GET {base}/catalogue?since={revision}&page={n}&store_id=…&terminal_id=…
Authorization: Bearer {apiKey}
```
```json
{
"revision": "rev-8821",
"is_delta": true,
"has_more": false,
"products": [ { "id": "…", "name": "…", "barcode": "…", "price": 62.0, } ],
"customers": [ { "id": "…", "name": "…", "mobile": "…", } ],
"retired_product_ids": ["sku-9912"]
}
```
**Paged.** A supermarket catalogue is tens of thousands of rows; one response
times out on a shop's line and stalls the UI while it decodes. Answer
`has_more: true` and the terminal asks for the next page, up to 200 — past that
it stops rather than looping against the shop's connection.
**`since` is the revision the terminal already holds.** Answer with what has
moved and set `is_delta: true`. On a normal morning that is a handful of price
changes rather than the whole book. A server that cannot do deltas ignores the
parameter and answers `is_delta: false`; the terminal reads the flag rather
than assuming, so both work.
**The flag matters more than it looks.** A full snapshot withdraws every product
it does not mention. A delta must not — read as a snapshot, the first morning
price change would empty the shelf. Withdraw items in a delta with
`retired_product_ids`; the terminal marks them inactive rather than deleting,
because order lines already recorded point at them.
**Send stock only when you mean it.** Any product in the payload has its count
overwritten by the server's figure, which predates sales this terminal has rung
but not yet uploaded. The terminal replays those sales — but only for products
the payload actually carried. A delta that ships a stale count for an untouched
product will quietly empty a shelf that is full.
### Field handling
| Field | Missing | Notes |
|---|---|---|
| `id`, `name`, `barcode`, `price` | **import fails** | A dropped product is a shelf item that scans to nothing |
| `sku` | falls back to `id` | |
| `stock` | `0` | Means "not tracked" |
| `gst_rate` | 18% | Accepts `18` or `0.18` — both read the same |
| `category` | Grocery | An unrecognised one also falls back; the item still sells |
| `unit` | piece | Matched by name or symbol |
| `is_active` | `true` | An omitted flag is not a withdrawn catalogue |
Dates are ISO 8601 or epoch milliseconds; both are accepted.
### Pushing a change mid-day
Publish to `nearle/pos/{loc}/catalogue` (retained) and every terminal in the shop
pulls immediately instead of waiting for tomorrow morning. The message body is
only a nudge — the catalogue itself still comes over HTTP, because a broker is
the wrong shape for tens of thousands of rows.
## Retention on the terminal
Accepted bills stay for 7 days (`OrderDao.retentionWindow`) so a batch the
back office later loses can be re-sent in full. After that only the archived
day totals survive, and a lost bill's line items are gone for good.
While a bill is retained it exists in two places — its own row and
`day_archive`. `forBusinessDate` therefore reads **pending rows only**; without
that filter every synced bill would be counted twice and the shift report would
overstate the day.
## What is deliberately not built
- **Downlink beyond catalogue-changed and sync-requested.** The plumbing routes
unknown commands to the events log rather than dropping them, so adding one
is a server change plus a case arm.
- **Historical correction.** Bills already synced by an older build went up
with an overstated total. Nothing here fixes that; it needs a server-side
reconciliation against `bill_discount`.
- **Loyalty balances coming back down.** Points and lifetime spend are computed
per terminal from the bills that terminal rang. A shopper who buys at two
stores has two partial balances until the back office derives the real one
from the bill stream and sends it down in a catalogue pull. The uplink
deliberately does not carry local balances, so nothing is corrupted by this —
but a shopper's points at the till are that till's view, not the group's.
- **Merging pre-v8 shoppers.** Terminals that traded before the customer outbox
carry rows with random ids. They are uploaded once by the migration, but
collapsing them onto the mobile number is the back office's job.

View File

@@ -3,13 +3,32 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/constants/app_constants.dart';
import '../core/router/app_router.dart';
import '../core/theme/app_colors.dart';
import '../core/theme/app_theme.dart';
import '../presentation/auth/providers/auth_controller.dart';
import '../presentation/sync/providers/sync_controller.dart';
class NearlePosApp extends ConsumerWidget {
const NearlePosApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watched, not awaited: the till opens immediately and the queue drains
// behind it. Nothing on screen depends on this having finished.
ref.watch(syncBootstrapProvider);
// This one *is* awaited. Re-opening a stored session is a keystore read —
// a few milliseconds — and building the router before it lands would show
// an already-signed-in terminal the login screen and then snatch it away.
final restored = ref.watch(sessionBootstrapProvider);
if (restored.isLoading) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: ColoredBox(color: AppColors.background),
);
}
return MaterialApp.router(
title: AppConstants.appName,
debugShowCheckedModeBanner: false,

View File

@@ -1,22 +1,66 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/config/sync_config.dart';
import '../core/services/connectivity_service.dart';
import '../core/services/receipt_service.dart';
import '../core/services/sound_service.dart';
import '../data/datasources/local_store.dart';
import '../data/repositories/customer_repository_impl.dart';
import '../data/repositories/product_repository_impl.dart';
import '../data/datasources/remote_catalogue_source.dart';
import '../data/remote/catalogue_source.dart';
import '../data/remote/http_catalogue_source.dart';
import '../data/remote/simulated_catalogue_source.dart';
import '../data/remote/http_order_transport.dart';
import '../data/remote/mqtt_order_transport.dart';
import '../data/remote/order_transport.dart';
import '../data/remote/pos_auth_api.dart';
import '../data/remote/simulated_order_transport.dart';
import '../data/repositories/store_repository_impl.dart';
import '../data/repositories/sync_repository_impl.dart';
import '../data/repositories/transaction_repository_impl.dart';
import '../data/local/session_store.dart';
import '../data/local/terminal_identity.dart';
import '../data/sync/sync_engine.dart';
import '../domain/repositories/customer_repository.dart';
import '../domain/repositories/product_repository.dart';
import '../domain/repositories/sync_repository.dart';
import '../domain/repositories/transaction_repository.dart';
import '../domain/entities/promo.dart';
import '../domain/entities/store_account.dart';
import '../domain/usecases/checkout_sale.dart';
import '../presentation/auth/providers/auth_controller.dart';
/// Root data source. Overridden in tests with an in-memory double.
final localStoreProvider = Provider<LocalStore>((ref) => LocalStore.instance);
// ------------------------------------------------------------------- Auth
/// Where the back office lives.
///
/// Its own provider, and deliberately free of any dependency on the session:
/// [posAuthApiProvider] needs it *before* anyone is signed in, so a base URL
/// derived from the session would be a cycle — sign-in needing the thing that
/// sign-in produces.
final backOfficeBaseUrlProvider = Provider<String>(
(ref) => 'https://fiesta.nearle.app/live/api/v1/pos',
);
/// Which back-office configuration this build's terminals belong to. Sent as
/// `configid` on every sign-in.
final posConfigIdProvider = Provider<int>((ref) => 1);
/// `POST /login`. One client, closed when the endpoint is re-pointed.
final posAuthApiProvider = Provider<PosAuthApi>((ref) {
final api = PosAuthApi(
baseUrl: ref.watch(backOfficeBaseUrlProvider),
configId: ref.watch(posConfigIdProvider),
);
ref.onDispose(api.dispose);
return api;
});
/// The signed-in session on disk, in the platform keystore.
final sessionStoreProvider = Provider<SessionStore>((ref) => SessionStore());
// ---------------------------------------------------------- Repositories
final productRepositoryProvider = Provider<ProductRepository>(
(ref) => ProductRepositoryImpl(ref.watch(localStoreProvider)),
@@ -36,28 +80,135 @@ final transactionRepositoryProvider = Provider<TransactionRepository>(
/// purpose, which reads as a real network fault.
final simulateOfflineProvider = StateProvider<bool>((ref) => false);
/// Simulated back-office endpoints. Held as singletons so the offline toggle
/// in Settings affects every call.
final remoteCatalogueProvider = Provider<RemoteCatalogueSource>(
(ref) => RemoteCatalogueSource(
isOffline: () => ref.read(simulateOfflineProvider),
),
);
/// Where products and customers come from.
///
/// Rebuilt when the route changes, and the old one closed, so a re-pointed
/// terminal does not keep a stale client alive.
final catalogueSourceProvider = Provider<CatalogueSource>((ref) {
final config = ref.watch(syncConfigProvider);
final remoteOrderSinkProvider = Provider<RemoteOrderSink>(
(ref) => RemoteOrderSink(
final source = switch (config.transport) {
// MQTT carries the *notification* that the catalogue moved; the catalogue
// itself is a bulk pull, which is an HTTP job. A broker is the wrong shape
// for tens of thousands of rows.
TransportKind.http || TransportKind.mqtt =>
config.httpBaseUrl.isEmpty
? SimulatedCatalogueSource(
isOffline: () => ref.read(simulateOfflineProvider),
)
: HttpCatalogueSource(config: config),
TransportKind.simulated => SimulatedCatalogueSource(
isOffline: () => ref.read(simulateOfflineProvider),
),
};
ref.onDispose(source.dispose);
return source;
});
// ------------------------------------------------------------------- Sync
/// How this terminal reaches the back office.
///
/// Defaults to this store's live HTTP endpoint, so importing works out of the
/// box against real products rather than the offline demo catalogue.
/// Settings → Connectivity & sync → Configure re-points it to a different
/// store, endpoint, or transport without a rebuild.
///
/// Terminal id always comes from this device's own identity, never from a
/// literal — two terminals publishing on the same topic is the failure this
/// exists to prevent.
final syncConfigProvider = StateProvider<SyncConfig>((ref) {
final terminal = ref.watch(terminalIdentityProvider);
return SyncConfig(
transport: TransportKind.http,
httpBaseUrl: ref.watch(backOfficeBaseUrlProvider),
storeId: terminal.storeId,
terminalId: terminal.code,
);
});
/// Real network state, folded with the Settings offline switch.
final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
final service = ConnectivityService(
isSimulatedOffline: () => ref.read(simulateOfflineProvider),
);
ref.onDispose(service.dispose);
return service;
});
/// The wire itself. Rebuilt when the configuration changes, and the old one is
/// closed so a re-pointed terminal does not keep a stale broker session open.
final orderTransportProvider = Provider<OrderTransport>((ref) {
final config = ref.watch(syncConfigProvider);
final transport = switch (config.transport) {
TransportKind.mqtt => MqttOrderTransport(config: config),
TransportKind.http => HttpOrderTransport(config: config),
TransportKind.simulated => SimulatedOrderTransport(
isOffline: () => ref.read(simulateOfflineProvider),
),
};
ref.onDispose(transport.dispose);
return transport;
});
final syncRepositoryProvider = Provider<SyncRepository>(
(ref) => SyncRepositoryImpl(
ref.watch(localStoreProvider),
ref.watch(remoteCatalogueProvider),
ref.watch(remoteOrderSinkProvider),
ref.watch(catalogueSourceProvider),
ref.watch(orderTransportProvider),
batchSize: ref.watch(syncConfigProvider).batchSize,
),
);
/// Decides when bills are uploaded. Started once, by the app shell.
final syncEngineProvider = Provider<SyncEngine>((ref) {
final transport = ref.watch(orderTransportProvider);
final engine = SyncEngine(
repository: ref.watch(syncRepositoryProvider),
connectivity: ref.watch(connectivityServiceProvider).onlineChanges,
downlink: transport.downlink,
onCatalogueChanged: () async {
await ref.read(syncRepositoryProvider).importCatalogue();
ref.invalidate(localStoreProvider);
},
);
ref.onDispose(engine.dispose);
return engine;
});
/// Live engine state for the header pill and the events screen.
final syncEngineStateProvider = StreamProvider<SyncEngineState>((ref) {
final engine = ref.watch(syncEngineProvider);
return engine.states.map((s) => s);
});
/// Store details and staff, read from this terminal's database.
final storeRepositoryProvider = Provider<StoreRepositoryImpl>(
(ref) => StoreRepositoryImpl(ref.watch(localStoreProvider)),
);
/// The outlet, refreshed whenever staff or details change.
final storeAccountProvider = FutureProvider<StoreAccount>(
(ref) => ref.watch(storeRepositoryProvider).load(
// The account the terminal is signed in as, or blank before sign-in.
email: ref.watch(sessionAuthnameProvider),
),
);
/// Campaigns stored on this terminal.
final promosProvider = FutureProvider<List<Promo>>(
(ref) => ref.watch(localStoreProvider).promos.all(),
);
/// Only the campaigns the till should be applying right now.
final activePromosProvider = FutureProvider<List<Promo>>(
(ref) => ref.watch(localStoreProvider).promos.all(activeOnly: true),
);
// ------------------------------------------------------------- Use cases
final checkoutSaleProvider = Provider<CheckoutSale>(
(ref) => CheckoutSale(
@@ -87,13 +238,31 @@ class CashierSession {
final String terminalId;
}
final cashierSessionProvider = StateProvider<CashierSession>(
(ref) => const CashierSession(
/// Identity of the physical till, read from its own database.
///
/// Falls back only before the store has opened; every real read happens after
/// `LocalStore.init`, which mints the identity if this device has never run
/// before.
final terminalIdentityProvider = Provider<TerminalIdentity>((ref) {
final store = ref.watch(localStoreProvider);
return store.isReady
? store.terminal
: const TerminalIdentity(
deviceId: 'unopened',
code: 'T0000',
name: 'Terminal',
storeId: 'store-01',
);
});
final cashierSessionProvider = StateProvider<CashierSession>((ref) {
final terminal = ref.watch(terminalIdentityProvider);
return CashierSession(
name: 'Suriya',
role: 'ADMIN',
terminalId: 'TERM-01',
),
terminalId: terminal.code,
);
});
/// Ticks once a minute to drive the header clock without rebuilding on every
/// frame.

View File

@@ -0,0 +1,168 @@
/// Which route completed bills take to the back office.
enum TransportKind {
/// No back office wired up — bills queue and drain against a local stub.
simulated,
/// Plain request/response upload. Easiest to debug: curl reproduces it and
/// failures come back as status codes.
http,
/// Persistent connection. Costs a broker, and buys the downlink: head office
/// can push a price change or ask a terminal to sync without waiting for it
/// to ask first.
///
/// Speaks MQTT 3.1.1, so it works against Mosquitto, EMQX or a NATS server
/// with its MQTT gateway enabled. Against NATS the topics below arrive as
/// subjects with `/` mapped to `.` — `pos/store-01/T4A9/order` becomes
/// `pos.store-01.T4A9.order` — which is what a JetStream consumer binds to.
mqtt,
}
/// Everything the terminal needs to reach the back office.
///
/// One object rather than scattered constants so a store can be re-pointed at
/// a different broker without a rebuild, and so tests can construct a whole
/// configuration inline.
class SyncConfig {
const SyncConfig({
this.transport = TransportKind.simulated,
this.storeId = 'store-01',
this.terminalId = 'TERM-01',
this.brokerHost = '',
this.brokerPort = 8883,
this.useTls = true,
this.username,
this.password,
this.httpBaseUrl = '',
this.apiKey,
this.ackTimeout = const Duration(seconds: 20),
this.batchSize = 50,
});
final TransportKind transport;
/// Namespaces every topic. Two stores on one broker must never collide.
final String storeId;
final String terminalId;
final String brokerHost;
final int brokerPort;
final bool useTls;
final String? username;
final String? password;
final String httpBaseUrl;
final String? apiKey;
/// How long to wait for the back office to confirm a batch before treating
/// the outcome as unknown and leaving every row pending.
///
/// Generous on purpose: a timeout that fires while the server is committing
/// produces a duplicate send, which is safe only because the back office
/// keys on order id — but it is still wasted traffic on a slow shop line.
final Duration ackTimeout;
/// Bills per publish. Small enough to stay well inside broker message limits
/// on a day that has built up a long backlog.
final int batchSize;
bool get isConfigured => switch (transport) {
TransportKind.simulated => true,
TransportKind.http => httpBaseUrl.isNotEmpty,
TransportKind.mqtt => brokerHost.isNotEmpty,
};
// ------------------------------------------------------------------ Topics
/// Namespaced under `nearle/` alongside the rider fleet's
/// `nearle/riders/{riderId}/…`, so one broker ACL rule covers each system and
/// a topic says at a glance which one it belongs to.
///
/// [storeId] carries the back office's numeric location id. The tenant is
/// resolved from it server-side and never taken from the wire — a till that
/// could name its own tenant could post sales into another shop's books.
String get _base => 'nearle/pos/$storeId/$terminalId';
/// Uplink. Completed bills, QoS 1.
String get orderTopic => '$_base/order';
/// Uplink. Shoppers registered at this till, QoS 1.
///
/// Separate from [orderTopic] because the two have different shapes and
/// different consumers: bills are financial records that must never be
/// replayed twice, registrations are insert-if-absent and can be replayed
/// freely. Sharing a topic would force one consumer to branch on a type tag
/// and would put a registration behind a stuck bill.
String get customerTopic => '$_base/customer';
/// The back office's answer, naming the ids it committed. Subscribed at
/// QoS 1: losing an ack means re-sending bills that are already banked.
///
/// Carries acks for both uplinks. The `batch_id` says which send is being
/// answered, so one subscription is enough.
String get ackTopic => '$_base/ack';
/// Retained, and set as the will message. A terminal that loses power stops
/// refreshing it and the broker publishes `offline` on its behalf, which is
/// what makes a head-office "which tills are dark" board possible.
String get statusTopic => '$_base/status';
/// Liveness, published on a timer rather than on an event.
///
/// Separate from [statusTopic]: that one is retained and doubles as the Last
/// Will, so it must stay small and rarely written. This carries queue depth,
/// today's trading and device state — the things a head-office board needs to
/// tell a till that is merely quiet from one that is in trouble.
String get healthTopic => '$_base/health';
/// Store-wide downlink: catalogue changes land here for every terminal.
String get catalogueTopic => 'nearle/pos/$storeId/catalogue';
/// Addressed to this terminal alone.
String get commandTopic => '$_base/command';
/// Stable across restarts so the broker can resume a session and redeliver
/// anything in flight, rather than treating each launch as a new client.
///
/// Derived from the terminal's own identity, which is minted per device — two
/// tills sharing a client id would knock each other off the broker in a loop,
/// since a second connection with the same id evicts the first.
String get clientId => 'pos-$storeId-$terminalId';
/// The same topic as a NATS subject.
///
/// NATS' MQTT gateway maps `/` to `.`, so this is what a JetStream stream or
/// consumer is configured against. Provided so the wildcard a back-office
/// consumer needs can be read off the terminal rather than guessed:
/// `nearle.pos.*.*.order` for every till's bills, `nearle.pos.*.*.health`
/// for presence.
static String asNatsSubject(String topic) => topic.replaceAll('/', '.');
SyncConfig copyWith({
TransportKind? transport,
String? storeId,
String? terminalId,
String? brokerHost,
int? brokerPort,
bool? useTls,
String? username,
String? password,
String? httpBaseUrl,
String? apiKey,
Duration? ackTimeout,
int? batchSize,
}) =>
SyncConfig(
transport: transport ?? this.transport,
storeId: storeId ?? this.storeId,
terminalId: terminalId ?? this.terminalId,
brokerHost: brokerHost ?? this.brokerHost,
brokerPort: brokerPort ?? this.brokerPort,
useTls: useTls ?? this.useTls,
username: username ?? this.username,
password: password ?? this.password,
httpBaseUrl: httpBaseUrl ?? this.httpBaseUrl,
apiKey: apiKey ?? this.apiKey,
ackTimeout: ackTimeout ?? this.ackTimeout,
batchSize: batchSize ?? this.batchSize,
);
}

View File

@@ -3,6 +3,10 @@ class AppConstants {
const AppConstants._();
static const String appName = 'Nearle POS';
/// Reported in every presence record. Across a fleet this is how you find
/// the twelve tills still on last month's build when one of them misbehaves.
static const String appVersion = '1.1.0';
static const String storeName = 'Nearle Daily';
static const String storeAddress = '12 Gandhipuram Main Rd, Coimbatore 641012';
static const String storeGstin = '33ABCDE1234F1Z5';
@@ -35,8 +39,13 @@ class AppConstants {
static const Duration barcodeScanTimeout = Duration(milliseconds: 120);
static const int minBarcodeLength = 6;
/// Idle time after a completed sale before the terminal resets itself.
static const Duration postSaleResetDelay = Duration(seconds: 3);
/// Window after a completed sale during which the terminal waits, and the
/// bill is held back from the server — long enough for the cashier to
/// catch a mistake and cancel it before it becomes final. If the window
/// runs out (or "New Sale" is pressed early) the terminal resets and the
/// bill goes up; if it's cancelled first, the sale is voided and the cart
/// comes back exactly as it was.
static const Duration postSaleResetDelay = Duration(seconds: 30);
static const int lowStockThreshold = 10;
static const int maxParkedBills = 20;

View File

@@ -1,11 +1,21 @@
/// Typed references to bundled assets.
///
/// Only sounds are bundled: product imagery uses emoji glyphs and the welcome
/// artwork is painted in code, so there are no raster or SVG assets to ship.
/// Product imagery uses emoji glyphs and the welcome artwork is painted in
/// code, so the only raster asset shipped is the mark itself.
class AssetPaths {
const AssetPaths._();
static const String _snd = 'assets/sounds';
static const String _img = 'assets/images';
/// The Nearle mark. Every place that used to draw a letter "N" in a
/// gradient box now renders this instead, so the brand cannot drift between
/// the login screen, the sidebar and the cashier header.
static const String logo = '$_img/logo.png';
/// Shopfront photograph behind the sign-in screen. Blurred and darkened in
/// place, so it reads as atmosphere rather than as something to look at.
static const String loginBackground = '$_img/bg.webp';
static const String beepSuccess = '$_snd/beep_success.wav';
static const String beepError = '$_snd/beep_error.wav';

View File

@@ -8,44 +8,98 @@ import '../../presentation/auth/screens/login_screen.dart';
import '../../presentation/payment/screens/payment_screen.dart';
import '../../presentation/pos/screens/pos_dashboard_screen.dart';
import '../../presentation/receipt/screens/receipt_screen.dart';
import '../../presentation/shift/screens/end_shift_screen.dart';
class AppRoutes {
const AppRoutes._();
static const String login = '/login';
/// The terminal itself. Signing in lands here directly — customer capture
/// happens at checkout, not before the sale.
/// The admin shell: billing plus catalogue import, promos, staff, settings.
static const String adminDashboard = '/admin';
/// The cashier shell: billing, and nothing else.
static const String cashierDashboard = '/cashier';
/// Alias for "wherever this session lives".
///
/// Kept because everything that returns to billing — finishing a receipt,
/// abandoning a payment — should land on the caller's own dashboard without
/// having to know which one that is. It never renders; [routerProvider]
/// resolves it to one of the two above.
static const String pos = '/';
static const String payment = '/payment';
/// Drawer count and hand-over. Reached from the session-end chooser, never
/// linked to directly, and guarded like every other signed-in route.
static const String endShift = '/end-shift';
static const String receipt = '/receipt';
/// Which dashboard a session owns.
///
/// The single place the role-to-screen decision is written down. `null` —
/// nobody signed in — resolves to the cashier till, which is the smaller of
/// the two; the guard sends an unauthenticated terminal to [login] before
/// this is ever reached.
static String homeFor(TerminalLogin? login) =>
login == TerminalLogin.admin ? adminDashboard : cashierDashboard;
/// True for the two dashboards, so the guard can spot a session sitting on
/// the wrong one.
static bool isDashboard(String location) =>
location == adminDashboard || location == cashierDashboard;
}
/// Router with an authentication guard.
/// Router with an authentication and role guard.
///
/// Every route except [AppRoutes.login] requires a signed-in store, and an
/// already-signed-in terminal is bounced away from the login screen.
/// Three rules, in order:
///
/// 1. Every route except [AppRoutes.login] requires a signed-in session.
/// 2. A signed-in terminal is bounced off the login screen onto its own
/// dashboard — admin for Admin/Supervisor/Manager/Owner accounts, cashier
/// for everything else. See [PosSession.isCashier].
/// 3. A session on the *other* role's dashboard is moved to its own. Typing
/// `/admin` on a cashier till must not open the back office, and the guard
/// is what makes that true regardless of how the route was reached.
final routerProvider = Provider<GoRouter>((ref) {
// GoRouter re-evaluates `redirect` whenever this notifier fires.
final authChanged = ValueNotifier<bool>(
ref.read(authControllerProvider).isAuthenticated,
// GoRouter re-evaluates `redirect` whenever this notifier fires. It carries
// the destination rather than a bare bool, so a role change — a cashier
// signing out and a supervisor signing in — also moves the terminal, which
// watching `isAuthenticated` alone would miss.
String? home(AuthState state) => state is Authenticated
? AppRoutes.homeFor(state.login)
: null;
final destination = ValueNotifier<String?>(
home(ref.read(authControllerProvider)),
);
ref.listen<AuthState>(
authControllerProvider,
(_, next) => authChanged.value = next.isAuthenticated,
(_, next) => destination.value = home(next),
);
ref.onDispose(authChanged.dispose);
ref.onDispose(destination.dispose);
return GoRouter(
initialLocation: AppRoutes.login,
refreshListenable: authChanged,
refreshListenable: destination,
debugLogDiagnostics: false,
redirect: (context, state) {
final signedIn = ref.read(authControllerProvider).isAuthenticated;
final atLogin = state.matchedLocation == AppRoutes.login;
final auth = ref.read(authControllerProvider);
final location = state.matchedLocation;
final atLogin = location == AppRoutes.login;
if (auth is! Authenticated) return atLogin ? null : AppRoutes.login;
final myHome = AppRoutes.homeFor(auth.login);
// Signed in and still on the login screen, or on the `/` alias.
if (atLogin || location == AppRoutes.pos) return myHome;
// On the other role's dashboard.
if (AppRoutes.isDashboard(location) && location != myHome) return myHome;
if (!signedIn) return atLogin ? null : AppRoutes.login;
if (atLogin) return AppRoutes.pos;
return null;
},
routes: [
@@ -54,12 +108,40 @@ final routerProvider = Provider<GoRouter>((ref) {
name: 'login',
pageBuilder: (context, state) => _fade(state, const LoginScreen()),
),
// Redirect-only. `/` is an alias, never a screen — the guard above has
// already resolved it, and this exists so the path matches a route at
// all rather than falling through to [errorBuilder].
GoRoute(
path: AppRoutes.pos,
name: 'pos',
name: 'home',
redirect: (context, state) => AppRoutes.homeFor(
ref.read(terminalLoginProvider) ?? TerminalLogin.cashier,
),
),
// Both dashboards are the same shell. It reads `isCashierModeProvider`
// and hides the sidebar, the catalogue and the back-office modules in
// cashier mode — so the two routes are the *addresses* of two shapes of
// one screen, not two screens to keep in step with each other.
GoRoute(
path: AppRoutes.adminDashboard,
name: 'adminDashboard',
pageBuilder: (context, state) =>
_fade(state, const PosDashboardScreen()),
),
GoRoute(
path: AppRoutes.cashierDashboard,
name: 'cashierDashboard',
pageBuilder: (context, state) =>
_fade(state, const PosDashboardScreen()),
),
GoRoute(
path: AppRoutes.endShift,
name: 'endShift',
pageBuilder: (context, state) => _slide(state, const EndShiftScreen()),
),
GoRoute(
path: AppRoutes.payment,
name: 'payment',

View File

@@ -0,0 +1,102 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
/// Turns a staff PIN into something safe to store.
///
/// PINs used to be string literals in `auth_controller.dart`, which meant every
/// shipped build carried every till's credentials — readable by anyone who
/// unzipped the APK. Storing them as plain rows in SQLite would be no better:
/// the database file sits on a shop-floor machine.
///
/// So: PBKDF2-HMAC-SHA256, per-user random salt, and only the derived key is
/// kept. A four-digit PIN has 10,000 possibilities, so the iteration count is
/// doing the real work — it makes checking all of them slow enough to matter.
class PinHasher {
const PinHasher._();
/// Chosen so one verification costs roughly a tenth of a second on terminal
/// hardware. A cashier signing in never notices; someone working through all
/// 10,000 PINs against a stolen database is looking at ~15 minutes per
/// account rather than milliseconds.
static const int iterations = 12000;
static const int _keyLength = 32;
static const int _saltLength = 16;
static final Random _random = Random.secure();
/// A fresh salt. Random.secure draws from the OS, not a seeded PRNG.
static String newSalt() {
final bytes = Uint8List.fromList(
List.generate(_saltLength, (_) => _random.nextInt(256)),
);
return base64Encode(bytes);
}
static String hash(String pin, String salt) {
final derived = _pbkdf2(
utf8.encode(pin),
base64Decode(salt),
iterations,
_keyLength,
);
return base64Encode(derived);
}
/// Constant-time comparison.
///
/// `==` on strings returns as soon as it finds a difference, and the timing
/// of that leaks how much of the guess was right.
static bool verify(String pin, {required String salt, required String hash}) {
final candidate = base64Decode(PinHasher.hash(pin, salt));
final expected = base64Decode(hash);
if (candidate.length != expected.length) return false;
var difference = 0;
for (var i = 0; i < candidate.length; i++) {
difference |= candidate[i] ^ expected[i];
}
return difference == 0;
}
/// PBKDF2 as specified in RFC 8018, with HMAC-SHA256 as the pseudorandom
/// function.
static Uint8List _pbkdf2(
List<int> password,
List<int> salt,
int iterations,
int keyLength,
) {
final hmac = Hmac(sha256, password);
final blocks = (keyLength / 32).ceil();
final output = BytesBuilder();
for (var block = 1; block <= blocks; block++) {
// U1 = PRF(password, salt || INT_32_BE(block))
final input = <int>[
...salt,
(block >> 24) & 0xff,
(block >> 16) & 0xff,
(block >> 8) & 0xff,
block & 0xff,
];
var u = Uint8List.fromList(hmac.convert(input).bytes);
final accumulator = Uint8List.fromList(u);
for (var i = 1; i < iterations; i++) {
u = Uint8List.fromList(hmac.convert(u).bytes);
for (var j = 0; j < accumulator.length; j++) {
accumulator[j] ^= u[j];
}
}
output.add(accumulator);
}
return Uint8List.fromList(output.toBytes().sublist(0, keyLength));
}
}

View File

@@ -0,0 +1,101 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
/// What happened when the till drawer was asked to open.
enum DrawerResult {
opened,
/// No drawer address configured. Not an error — plenty of shops take card
/// only, or open the drawer by hand.
notConfigured,
unreachable,
refused,
}
/// Opens the cash drawer.
///
/// The drawer is wired to the receipt printer's RJ11 port and fires when the
/// printer receives `ESC p m t1 t2`. That is a raw byte sequence, and it cannot
/// go through the PDF pipeline — a PDF is rendered by the platform driver,
/// which will not pass arbitrary bytes to the device. This used to be a
/// `debugPrint`, so the drawer never opened at all.
///
/// So it goes over the wire instead: nearly every thermal receipt printer with
/// a network port listens on 9100 (JetDirect) and forwards whatever arrives
/// straight to the print head. Sending five bytes to that socket is the whole
/// protocol.
///
/// A USB-only printer has no such path from Flutter and reports
/// [DrawerResult.notConfigured] rather than pretending.
class CashDrawerService {
CashDrawerService({
Future<Socket> Function(String host, int port, {Duration? timeout})? connect,
}) : _connect = connect ?? _defaultConnect;
final Future<Socket> Function(String host, int port, {Duration? timeout})
_connect;
static Future<Socket> _defaultConnect(
String host,
int port, {
Duration? timeout,
}) =>
Socket.connect(host, port, timeout: timeout ?? const Duration(seconds: 3));
/// `ESC p 0 25 250` — pin 2, 50ms on, 500ms off.
///
/// Pin 2 is the near-universal wiring. A drawer on pin 5 wants `27 112 1 …`,
/// which is the one thing worth checking if the printer clicks and nothing
/// opens.
static const List<int> kickCommand = [27, 112, 0, 25, 250];
/// Short on purpose. This runs while the cashier is taking cash, and a drawer
/// that opens three seconds late has already been opened by hand.
static const Duration timeout = Duration(seconds: 3);
Future<DrawerResult> open({String? host, int port = 9100}) async {
if (host == null || host.trim().isEmpty) return DrawerResult.notConfigured;
Socket? socket;
try {
socket = await _connect(host.trim(), port, timeout: timeout);
socket.add(kickCommand);
await socket.flush().timeout(timeout);
return DrawerResult.opened;
} on SocketException catch (e) {
debugPrint('Cash drawer at $host:$port unreachable: ${e.message}');
return DrawerResult.unreachable;
} on TimeoutException {
debugPrint('Cash drawer at $host:$port did not accept the kick in time');
return DrawerResult.refused;
} on Object catch (e) {
debugPrint('Cash drawer kick failed: $e');
return DrawerResult.refused;
} finally {
// Never awaited: a printer that accepted the bytes but will not close the
// socket must not hold up the sale.
unawaited(socket?.close().catchError((_) {}));
}
}
}
/// Human-readable outcome, for the Settings test button.
extension DrawerResultMessage on DrawerResult {
String get message => switch (this) {
DrawerResult.opened => 'Drawer opened.',
DrawerResult.notConfigured =>
'No drawer address set. Enter the receipt printer\'s IP address to '
'kick the drawer automatically after a cash sale.',
DrawerResult.unreachable =>
'Could not reach the printer. Check it is powered on and on the same '
'network as this terminal.',
DrawerResult.refused =>
'The printer accepted the connection but not the command. Check the '
'drawer is wired to its RJ11 port.',
};
bool get isSuccess => this == DrawerResult.opened;
}

View File

@@ -0,0 +1,76 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
/// Tells the terminal when it is worth trying the network.
///
/// This is a *hint*, not proof. The platform reports whether an interface is
/// up, which on shop wifi is routinely true while the line itself is dead. So
/// nothing here decides that a sync succeeded — only the transport's answer
/// does. What this buys is the moment to try: the difference between a bill
/// going up the second the router comes back and it waiting for the next poll.
///
/// The Settings "Simulate offline" switch is folded in here so there is one
/// answer to "are we online", rather than a real state and a demo state that
/// can disagree.
class ConnectivityService {
ConnectivityService({
Connectivity? connectivity,
bool Function()? isSimulatedOffline,
}) : _connectivity = connectivity ?? Connectivity(),
_isSimulatedOffline = isSimulatedOffline ?? (() => false);
final Connectivity _connectivity;
final bool Function() _isSimulatedOffline;
final _controller = StreamController<bool>.broadcast();
StreamSubscription<List<ConnectivityResult>>? _subscription;
bool _hasInterface = true;
bool _started = false;
/// Fires only when the answer changes, so a subscriber can treat every event
/// as an edge.
Stream<bool> get onlineChanges => _controller.stream;
bool get isOnline => _hasInterface && !_isSimulatedOffline();
Future<void> start() async {
if (_started) return;
_started = true;
try {
_hasInterface = _hasAny(await _connectivity.checkConnectivity());
} on Exception {
// No platform channel — a test host, or a desktop build without the
// plugin registered. Assume online and let the transport be the judge;
// refusing to try would be worse than trying and failing.
_hasInterface = true;
}
try {
_subscription = _connectivity.onConnectivityChanged.listen((results) {
_update(_hasAny(results));
});
} on Exception {
// As above: without the stream the periodic poll still drains the queue.
}
}
/// Re-evaluates after the Settings switch is flipped.
void refresh() => _update(_hasInterface);
void _update(bool hasInterface) {
final was = isOnline;
_hasInterface = hasInterface;
if (isOnline != was) _controller.add(isOnline);
}
static bool _hasAny(List<ConnectivityResult> results) =>
results.any((r) => r != ConnectivityResult.none);
Future<void> dispose() async {
await _subscription?.cancel();
await _controller.close();
}
}

View File

@@ -1,5 +1,7 @@
import 'package:flutter/foundation.dart';
import 'cash_drawer_service.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
@@ -224,6 +226,11 @@ class ReceiptService {
pw.SizedBox(height: 2),
_amount('Gross Sales Value', gross),
if (discount > 0) _amount('Total Discount', discount),
// Named on the printed bill too. A shopper who came in for an advertised
// offer needs to see it on the receipt, not just a total that happens to
// be lower than the shelf price.
for (final applied in cart.appliedPromos)
_amount(' ${applied.promo.name}', applied.amount),
_amount('Net Sales Value (Inclusive of GST)', cart.netAmount),
if (cart.roundOff != 0) _amount('Round Off', cart.roundOff),
_amount('Total Amount Paid', txn.total, bold: true),
@@ -578,6 +585,10 @@ class ReceiptService {
if (cart.billDiscountTotal > 0) {
b.writeln('Discount: -${Formatters.money(cart.billDiscountTotal)}');
for (final applied in cart.appliedPromos) {
b.writeln(' ${applied.promo.name}: '
'-${Formatters.money(applied.amount)}');
}
}
b
@@ -597,26 +608,17 @@ class ReceiptService {
return b.toString();
}
/// ESC/POS drawer kick: `ESC p m t1 t2` on pin 2.
/// Opens the till drawer, if one is configured.
///
/// Most drawers are wired to the printer's RJ11 port and open when the
/// printer receives this. It cannot be sent through the PDF pipeline — a
/// PDF is rendered by the driver, not passed through as bytes — so this
/// needs a raw channel to the printer.
///
/// On desktop with a driver-installed printer there is no raw path from
/// Flutter, so this stays a no-op. Wire it up when you move to ESC/POS:
/// send [drawerKickCommand] over the same socket or Bluetooth link that
/// carries the receipt.
Future<void> openCashDrawer() async {
debugPrint(
'Cash drawer kick requested — needs a raw ESC/POS channel, '
'not the PDF driver. Command: ${drawerKickCommand.join(' ')}',
);
}
/// `ESC p 0 25 250` — pin 2, 50ms on, 500ms off.
static const List<int> drawerKickCommand = [27, 112, 0, 25, 250];
/// Delegates to [CashDrawerService], which talks raw ESC/POS over a socket.
/// The PDF pipeline cannot carry the command — a PDF is rendered by the
/// platform driver, which will not pass arbitrary bytes through to the
/// device.
Future<DrawerResult> openCashDrawer({
String? host,
int port = 9100,
}) =>
CashDrawerService().open(host: host, port: port);
}
/// One GST rate's slice of a bill.

View File

@@ -16,10 +16,8 @@ class SoundService {
static final SoundService instance = SoundService._();
final AudioPlayer _player = AudioPlayer(playerId: 'nearle_pos_sfx');
bool _enabled = true;
bool get enabled => _enabled;
set enabled(bool value) => _enabled = value;
/// Muted from Settings when a shop finds the beeps intrusive.
bool enabled = true;
Future<void> preload() async {
try {
@@ -40,7 +38,7 @@ class SoundService {
bool haptic = false,
bool heavy = false,
}) async {
if (!_enabled) return;
if (!enabled) return;
// Haptics matter on tablets where the speaker may be muted on the floor.
if (heavy) {

View File

@@ -59,9 +59,59 @@ class Formatters {
.toUpperCase();
}
static String invoiceNumber(int sequence, DateTime date) {
/// `INV-2608-T4A9-00042`.
///
/// The terminal code is not decoration. The sequence counter lives in each
/// till's own database, so without it every terminal in the fleet mints
/// `INV-2608-00001` for its first sale of the month — 100 different bills
/// sharing one number, and no way to tell them apart on a receipt or in an
/// audit. The order's UUID keeps the *data* distinct; this keeps the number
/// a human quotes distinct too.
///
/// [terminalCode] is optional only so older call sites and fixtures keep
/// working; anything that writes a real bill passes it.
static String invoiceNumber(
int sequence,
DateTime date, {
String? terminalCode,
}) {
final y = date.year.toString().substring(2);
final m = date.month.toString().padLeft(2, '0');
return 'INV-$y$m-${sequence.toString().padLeft(5, '0')}';
final seq = sequence.toString().padLeft(5, '0');
final code = (terminalCode == null || terminalCode.isEmpty)
? ''
: '$terminalCode-';
return 'INV-$y$m-$code$seq';
}
/// A timestamp the back office cannot misread, with its UTC offset attached.
///
/// `DateTime.toIso8601String()` on a local time emits no zone marker at all —
/// `2026-08-05T12:49:28.245`. That is not wrong, it is *silent*, and the
/// receiver has to guess. Go's `time.Parse` guesses UTC, so a bill rung at
/// 12:49 in Coimbatore was stored as 12:49 UTC: five and a half hours in the
/// future, and reading as *later than the moment it was received*.
///
/// The daily figures survived that by luck. `businessdate` is derived from
/// the wall clock either way, and the wall clock was always the till's own —
/// so a day's takings landed on the right day even while the instant was
/// wrong. Anything comparing `billedat` to real time did not survive it.
///
/// Emitting the offset ends the guessing: `2026-08-05T12:49:28.245+05:30`
/// parses to the correct instant *and* still formats to the correct local
/// date, so both readings stay right.
static String isoWithOffset(DateTime time) {
final local = time.toLocal();
final offset = local.timeZoneOffset;
final sign = offset.isNegative ? '-' : '+';
final magnitude = offset.abs();
final hours = magnitude.inHours.toString().padLeft(2, '0');
// India is +05:30, so the minutes are load-bearing here in a way they are
// not in a whole-hour zone. Taken from the total rather than assumed zero.
final minutes =
(magnitude.inMinutes % 60).toString().padLeft(2, '0');
return '${local.toIso8601String()}$sign$hours:$minutes';
}
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import '../constants/asset_paths.dart';
import '../theme/app_colors.dart';
/// The Nearle mark on its tile.
///
/// Every surface that shows the brand — login, sidebar, cashier header —
/// renders this, so the mark cannot drift between them. It replaces the
/// hand-drawn letter "N" in a gradient box that each of those screens used to
/// build for itself.
class BrandMark extends StatelessWidget {
const BrandMark({
super.key,
this.size = 36,
this.radius,
this.onDark = false,
});
final double size;
final double? radius;
/// Set on a coloured background, where the tile needs no border to separate
/// it from what is behind.
final bool onDark;
@override
Widget build(BuildContext context) {
final corner = BorderRadius.circular(radius ?? size * 0.28);
return Container(
width: size,
height: size,
// Clipped, not padded: the mark fills the tile edge to edge and the
// rounded corner does the trimming, so nothing can spill past the box
// whatever aspect ratio the file happens to have.
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: corner,
border: onDark ? null : Border.all(color: AppColors.border),
),
child: Image.asset(
AssetPaths.logo,
fit: BoxFit.fill,
width: size,
height: size,
filterQuality: FilterQuality.medium,
// A missing or undeclared asset would otherwise blank the brand out
// of the sidebar entirely; the letterform is a poor substitute but a
// better failure than nothing.
errorBuilder: (context, _, __) => FittedBox(
fit: BoxFit.contain,
child: Text(
'N',
style: TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.w800,
fontSize: size,
),
),
),
),
);
}
}

View File

@@ -4,7 +4,12 @@ import '../../domain/entities/sync_event.dart';
import '../local/app_database.dart';
import '../local/catalogue_dao.dart';
import '../local/order_dao.dart';
import '../local/promo_dao.dart';
import '../local/staff_dao.dart';
import '../local/sync_config_store.dart';
import '../local/sync_log_dao.dart';
import '../local/terminal_identity.dart';
import '../local/void_pin_store.dart';
/// Terminal-side storage facade.
///
@@ -20,6 +25,14 @@ class LocalStore {
late CatalogueDao catalogue;
late OrderDao orders;
late SyncLogDao syncLog;
late StaffDao staff;
late PromoDao promos;
late SyncConfigStore syncConfig;
late TerminalIdentityStore identityStore;
late VoidPinStore voidPin;
/// Who this till is. Minted on first run, then stable forever.
late TerminalIdentity terminal;
final Map<String, Product> _products = {};
final Map<String, Customer> _customers = {};
@@ -28,6 +41,7 @@ class LocalStore {
DateTime? _lastImportAt;
String? _catalogueRevision;
int _unsyncedOrders = 0;
int _unsyncedCustomers = 0;
bool _ready = false;
@@ -44,6 +58,19 @@ class LocalStore {
catalogue = CatalogueDao(AppDatabase.instance.db);
orders = OrderDao(AppDatabase.instance.db);
syncLog = SyncLogDao(AppDatabase.instance.db);
staff = StaffDao(AppDatabase.instance.db);
promos = PromoDao(AppDatabase.instance.db);
syncConfig = SyncConfigStore(catalogue);
identityStore = TerminalIdentityStore(catalogue);
voidPin = VoidPinStore(catalogue, staff);
// A terminal with no staff cannot be signed into at all, so this runs
// before anything else can ask who is on shift.
await staff.seedIfEmpty();
// Before anything can be written or published: a bill stamped with the
// wrong terminal cannot be traced back to the till that rang it.
terminal = await identityStore.load();
await hydrate();
_ready = true;
@@ -67,7 +94,9 @@ class LocalStore {
? null
: DateTime.fromMillisecondsSinceEpoch(int.parse(stamp));
_catalogueRevision = await catalogue.meta(MetaKeys.catalogueRevision);
terminal = await identityStore.load();
_unsyncedOrders = await orders.unsyncedCount();
_unsyncedCustomers = await catalogue.unsyncedCustomerCount();
_syncEvents
..clear()
@@ -89,6 +118,12 @@ class LocalStore {
if (!_ready) await init(inMemory: true);
await AppDatabase.instance.clear();
// Staff and identity are cleared with everything else, and a terminal
// without them cannot be signed into or stamp a bill. Re-minted here so a
// reset leaves a usable till rather than a half-built one.
await staff.seedIfEmpty();
terminal = await identityStore.load();
if (withCatalogue) {
// Imported here rather than at the top of the file so the seed data is
// only pulled in by tests and the simulated remote source.
@@ -114,18 +149,62 @@ class LocalStore {
String? get catalogueRevision => _catalogueRevision;
int get unsyncedOrders => _unsyncedOrders;
/// Drops the imported catalogue from disk and from the in-memory cache.
///
/// Called when a *cashier* signs out, so the next shift never bills against
/// a copy left over from this one — [hasCatalogue] goes back to false, and
/// the only way to sell again is a fresh pull from the back office.
///
/// Not called on an admin sign-out. An admin's whole job at this terminal is
/// to pull the catalogue and hand the till over, so wiping it on the way out
/// would undo the thing they just did.
Future<void> clearCatalogue() async {
await catalogue.clearCatalogue();
_products.clear();
_lastImportAt = null;
_catalogueRevision = null;
}
Future<void> importCatalogue({
required List<Product> products,
required List<Customer> customers,
required String revision,
required DateTime at,
bool isDelta = false,
List<String> retiredProductIds = const [],
}) async {
await catalogue.replaceCatalogue(products: products, customers: customers);
if (isDelta) {
// A change set must not withdraw what it does not mention. Applied as a
// full snapshot, the first morning price change would empty the shelf.
await catalogue.applyCatalogueDelta(
products: products,
customers: customers,
retiredProductIds: retiredProductIds,
);
} else {
await catalogue.replaceCatalogue(
products: products,
customers: customers,
);
}
// The server's stock figure predates any sale this terminal has made but
// not yet uploaded, so those units would reappear on the shelf. Replay them
// before anyone can bill against the inflated count.
final committed = await orders.unsyncedStockCommitments();
//
// Scoped to the products the pull actually overwrote. A full snapshot
// rewrites every row, so the replay covers everything; a delta rewrites
// only what it carried, and replaying the rest would subtract those units a
// second time from a count that was never reset — quietly emptying a shelf
// that is full.
var committed = await orders.unsyncedStockCommitments();
if (isDelta) {
final touched = products.map((p) => p.id).toSet();
committed = {
for (final entry in committed.entries)
if (touched.contains(entry.key)) entry.key: entry.value,
};
}
if (committed.isNotEmpty) {
await catalogue.decrementStock(committed);
}
@@ -181,11 +260,20 @@ class LocalStore {
Future<void> putCustomer(Customer c) async {
await catalogue.upsertCustomer(c);
_customers[c.id] = c;
await refreshUnsyncedCustomerCount();
}
/// Mirrors a customer already written to disk into the memory cache.
void cacheCustomer(Customer c) => _customers[c.id] = c;
/// Shoppers registered here and not yet uploaded.
int get unsyncedCustomers => _unsyncedCustomers;
Future<int> refreshUnsyncedCustomerCount() async {
_unsyncedCustomers = await catalogue.unsyncedCustomerCount();
return _unsyncedCustomers;
}
// ----------------------------------------------------------------- Orders
/// Refreshes the cached unsynced tally after a write or a sync.
Future<int> refreshUnsyncedCount() async {

View File

@@ -1,112 +0,0 @@
import '../../domain/entities/customer.dart';
import '../../domain/entities/product.dart';
import 'local_store.dart';
import 'seed_data.dart';
/// What one catalogue pull returns.
class CatalogueSnapshot {
const CatalogueSnapshot({
required this.products,
required this.customers,
required this.fetchedAt,
required this.revision,
});
final List<Product> products;
final List<Customer> customers;
final DateTime fetchedAt;
/// Server-side catalogue version, shown so the cashier can tell whether a
/// re-import actually changed anything.
final String revision;
}
/// Raised when the catalogue cannot be pulled.
class CatalogueSyncException implements Exception {
const CatalogueSyncException(this.message);
final String message;
@override
String toString() => message;
}
/// Stands in for the back-office catalogue API.
///
/// The real implementation would issue an HTTP request; the contract is the
/// same, so only this class changes.
class RemoteCatalogueSource {
RemoteCatalogueSource({required this.isOffline}) {
LocalStore.registerSeed(
products: SeedData.products,
customers: SeedData.customers,
);
}
/// Reads the Settings switch on every call.
///
/// Deliberately a callback rather than a stored bool: a copied flag can fall
/// out of step with the switch, which makes the terminal behave as offline
/// while showing that it is not.
final bool Function() isOffline;
/// Streams progress so the import screen can show a real bar rather than an
/// indeterminate spinner.
Future<CatalogueSnapshot> fetch({
void Function(double progress, String stage)? onProgress,
}) async {
const stages = [
(0.15, 'Contacting server…'),
(0.35, 'Authorising terminal…'),
(0.60, 'Downloading products…'),
(0.85, 'Downloading customers…'),
(1.00, 'Writing to local storage…'),
];
for (final (progress, stage) in stages) {
await Future<void>.delayed(const Duration(milliseconds: 320));
if (isOffline()) {
throw const CatalogueSyncException(
'Simulate offline is ON in Settings, so the catalogue pull was '
'failed on purpose. Turn it off to import.',
);
}
onProgress?.call(progress, stage);
}
return CatalogueSnapshot(
products: SeedData.products(),
customers: SeedData.customers(),
fetchedAt: DateTime.now(),
revision: 'rev-${DateTime.now().millisecondsSinceEpoch % 100000}',
);
}
}
/// Stands in for the back-office order intake API.
class RemoteOrderSink {
RemoteOrderSink({required this.isOffline});
final bool Function() isOffline;
/// Uploads a batch of orders and returns the ids the server accepted.
///
/// Throws on transport failure so the caller leaves every row at
/// `sync_status = 0` rather than marking anything sent.
Future<List<String>> pushOrders(List<Map<String, Object?>> orders) async {
await Future<void>.delayed(
Duration(milliseconds: 400 + orders.length * 60),
);
if (isOffline()) {
throw const CatalogueSyncException(
'Simulate offline is ON in Settings, so the upload was failed on '
'purpose. Every bill is still stored on this terminal.',
);
}
return orders.map((o) => o['id']! as String).toList();
}
}

View File

@@ -13,7 +13,7 @@ class AppDatabase {
static final AppDatabase instance = AppDatabase._();
static const String _fileName = 'nearle_pos.db';
static const int _version = 4;
static const int _version = 8;
Database? _db;
@@ -60,7 +60,7 @@ class AppDatabase {
path,
options: OpenDatabaseOptions(
version: _version,
onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'),
onConfigure: _configure,
onCreate: (db, version) async => _createSchema(db),
onUpgrade: (db, from, to) async {
if (from < 2) await db.execute(_createDayArchive);
@@ -70,6 +70,14 @@ class AppDatabase {
);
}
if (from < 4) await _upgradeToV4(db, from: from);
if (from < 5) await db.execute(_createStaff);
if (from < 6) await db.execute(_createPromos);
if (from < 7) {
await db.execute(
'ALTER TABLE ${Tables.orders} ADD COLUMN promos_json TEXT',
);
}
if (from < 8) await _upgradeToV8(db);
},
),
);
@@ -84,12 +92,35 @@ class AppDatabase {
inMemoryDatabasePath,
options: OpenDatabaseOptions(
version: _version,
onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'),
onConfigure: _configure,
onCreate: (db, version) async => _createSchema(db),
),
);
}
/// Pragmas applied on every connection, before any query runs.
///
/// Defaults are wrong for a till:
///
/// * **WAL** lets a read proceed while a write is in flight. On the rollback
/// journal the product grid refreshing would block the sale being written.
/// It also survives a power cut better: the database file is never left
/// mid-rewrite.
/// * **busy_timeout** makes a contended lock wait instead of throwing
/// `database is locked` — which, at checkout, is a failed sale.
/// * **synchronous = NORMAL** is the right trade under WAL: an fsync per
/// transaction costs more than a POS can spare, and WAL still recovers a
/// committed transaction after a crash. Only a host OS crash or power loss
/// can lose the last commits, which is what the UPS is for.
static Future<void> _configure(Database db) async {
await db.execute('PRAGMA foreign_keys = ON');
await db.execute('PRAGMA busy_timeout = 5000');
// In-memory databases have no WAL; asking for it is harmless but pointless.
await db.execute('PRAGMA journal_mode = WAL');
await db.execute('PRAGMA synchronous = NORMAL');
}
Future<void> close() async {
await _db?.close();
_db = null;
@@ -106,6 +137,8 @@ class AppDatabase {
Tables.customers,
Tables.parkedBills,
Tables.syncLog,
Tables.staff,
Tables.promos,
Tables.meta,
]) {
batch.delete(t);
@@ -156,12 +189,24 @@ class AppDatabase {
lifetime_spend REAL NOT NULL DEFAULT 0,
visit_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER,
last_visit_at INTEGER
last_visit_at INTEGER,
-- Registration outbox. A shopper signed up at the till has to reach
-- the back office even if they never buy anything, so this table
-- carries the same pending/synced flag the orders table does.
--
-- Defaults to 1: rows that arrived in a catalogue pull came *from*
-- the back office and must not be posted straight back.
sync_status INTEGER NOT NULL DEFAULT 1,
synced_at INTEGER
)
''');
await db.execute(
'CREATE UNIQUE INDEX idx_customers_mobile ON ${Tables.customers}(mobile)',
);
await db.execute(
'CREATE INDEX idx_customers_sync ON ${Tables.customers}(sync_status)',
);
// -------------------------------------------------------------- orders
await db.execute('''
@@ -186,6 +231,10 @@ class AppDatabase {
points_earned INTEGER NOT NULL DEFAULT 0,
points_redeemed INTEGER NOT NULL DEFAULT 0,
payments_json TEXT NOT NULL,
-- Which campaigns fired, and for how much. Stored as amounts rather
-- than ids: a bill read back next year must show what was actually
-- given, not what today's rules would give.
promos_json TEXT,
status TEXT NOT NULL DEFAULT 'completed',
-- 0 = held on this terminal, 1 = accepted by the server
@@ -242,6 +291,8 @@ class AppDatabase {
// ------------------------------------------------------------- archive
await db.execute(_createDayArchive);
await db.execute(_createStaff);
await db.execute(_createPromos);
// ----------------------------------------------------------------- meta
await db.execute('''
@@ -284,6 +335,43 @@ Future<void> _upgradeToV4(Database db, {required int from}) async {
await db.execute('DROP TABLE _day_archive_v3');
}
/// Moves the schema to v8 — customers become an outbox.
///
/// Before this, a shopper registered at the till only ever reached the back
/// office as three fields riding along on a bill. Someone who signed up and
/// then didn't buy anything, or whose bill was still queued, existed on one
/// terminal and nowhere else.
///
/// Every existing row is marked pending rather than synced. The terminal
/// cannot tell which of them came down in a catalogue pull and which were rung
/// up locally, and of the two possible mistakes only one loses a shopper. This
/// is safe precisely because the customer uplink is specified as
/// insert-if-absent on id — re-sending one the back office already holds is a
/// no-op, never an overwrite of a profile edited at head office.
///
/// Ids are deliberately *not* rewritten. New customers are keyed on their
/// mobile number (see `Customer.idForMobile`) so terminals agree without
/// coordinating, but rows created before this version carry random ids that
/// bills already in the back office refer to. Re-keying them here would break
/// that link. They stay as they are, and the back office merges them on
/// mobile — a one-off for stores that were already trading.
Future<void> _upgradeToV8(Database db) async {
await db.execute(
'ALTER TABLE ${Tables.customers} '
'ADD COLUMN sync_status INTEGER NOT NULL DEFAULT 1',
);
await db.execute(
'ALTER TABLE ${Tables.customers} ADD COLUMN synced_at INTEGER',
);
await db.execute(
'CREATE INDEX idx_customers_sync ON ${Tables.customers}(sync_status)',
);
await db.rawUpdate(
'UPDATE ${Tables.customers} SET sync_status = 0, synced_at = NULL',
);
}
const String _createSyncLog = '''
CREATE TABLE sync_log (
id TEXT PRIMARY KEY,
@@ -304,6 +392,55 @@ const String _createSyncLog = '''
/// here first — otherwise "Bills Today" would collapse to zero the moment a
/// mid-shift sync ran. Keyed by cashier as well as date, because once the
/// orders are gone this row is the only thing left to settle a till against.
/// Staff who can sign in at this terminal.
///
/// Replaces three `StaffUser` literals with plaintext PINs that shipped inside
/// every build. Only the PBKDF2 hash and its salt are stored — the PIN itself
/// exists nowhere, including here.
const String _createStaff = '''
CREATE TABLE staff (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
role TEXT NOT NULL,
pin_hash TEXT NOT NULL,
pin_salt TEXT NOT NULL,
-- Set on a seeded or reset account, cleared once the person picks their
-- own, so a shop running a default PIN is at least visibly nagged.
must_change_pin INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
''';
/// Campaigns the till applies automatically.
///
/// Kept local like everything else: a shop mid-promotion with a dead line still
/// has to honour the price on the shelf edge.
const String _createPromos = '''
CREATE TABLE promos (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
value REAL NOT NULL DEFAULT 0,
target_id TEXT,
target_label TEXT,
buy_quantity INTEGER NOT NULL DEFAULT 0,
free_quantity INTEGER NOT NULL DEFAULT 0,
min_bill_value REAL NOT NULL DEFAULT 0,
max_discount REAL,
valid_from INTEGER,
valid_to INTEGER,
-- Comma-separated DateTime.weekday values. Empty means every day.
days_of_week TEXT NOT NULL DEFAULT '',
stackable INTEGER NOT NULL DEFAULT 0,
priority INTEGER NOT NULL DEFAULT 100,
is_active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
''';
const String _createDayArchive = '''
CREATE TABLE day_archive (
business_date TEXT NOT NULL,
@@ -335,6 +472,8 @@ class Tables {
static const String orderItems = 'order_items';
static const String parkedBills = 'parked_bills';
static const String syncLog = 'sync_log';
static const String staff = 'staff';
static const String promos = 'promos';
static const String meta = 'app_meta';
}
@@ -345,10 +484,54 @@ class MetaKeys {
static const String catalogueRevision = 'catalogue_revision';
static const String invoiceSequence = 'invoice_sequence';
/// Fleet identity. Minted once on first run and never changed — it is what
/// ties a bill, an MQTT topic and a presence record to one physical till.
static const String deviceId = 'terminal_device_id';
/// Short code stamped into invoice numbers, e.g. `T4A9`. Unique per device
/// so two tills in the same shop cannot mint the same invoice.
static const String terminalCode = 'terminal_code';
/// Human label shown in Settings and on the fleet board, e.g. "Counter 2".
static const String terminalName = 'terminal_name';
static const String storeId = 'store_id';
/// Printed on every invoice, so they are a legal requirement rather than
/// decoration — and must be editable without a rebuild.
static const String storeName = 'store_name';
static const String storeAddress = 'store_address';
static const String storeGstin = 'store_gstin';
static const String storePhone = 'store_phone';
static const String storePlan = 'store_plan';
/// How this terminal reaches the back office. Non-secret only — the username,
/// password and API key go to the platform keystore, not here.
static const String syncTransport = 'sync_transport';
static const String syncBrokerHost = 'sync_broker_host';
static const String syncBrokerPort = 'sync_broker_port';
static const String syncUseTls = 'sync_use_tls';
static const String syncHttpBaseUrl = 'sync_http_base_url';
/// Network printer that owns the cash drawer, if it is not the receipt
/// printer itself.
static const String drawerHost = 'drawer_host';
static const String drawerPort = 'drawer_port';
/// Printer chosen in Settings. Stored as the printer's `url`, which is what
/// `Printing.directPrintPdf` needs to target it without a dialog.
static const String printerUrl = 'printer_url';
static const String printerName = 'printer_name';
static const String autoPrint = 'auto_print';
static const String openDrawer = 'open_cash_drawer';
/// PIN that authorises taking a rung item back off a bill.
///
/// Stored hashed, like a staff PIN — the terminal only ever holds the hash
/// and its salt, so lifting the database file does not hand over the ability
/// to void. Deliberately separate from staff PINs: an admin sets it once and
/// gives it to whoever is on the counter, so a removal can be authorised
/// without an admin walking over to the till.
static const String voidPinHash = 'void_pin_hash';
static const String voidPinSalt = 'void_pin_salt';
}

View File

@@ -164,7 +164,7 @@ class CatalogueDao {
for (final c in customers) {
batch.insert(
Tables.customers,
customerToRow(c),
_importedCustomerRow(c),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
@@ -173,6 +173,90 @@ class CatalogueDao {
});
}
/// A customer row that arrived from the back office, marked as already sent.
///
/// Stated outright rather than left to the column default, because the
/// default existing to protect imports is not obvious from this call site —
/// and getting it wrong would post the whole customer book straight back to
/// the server that just sent it.
static Map<String, Object?> _importedCustomerRow(Customer c) => {
...customerToRow(c),
'sync_status': syncedCustomer,
'synced_at': DateTime.now().millisecondsSinceEpoch,
};
/// Applies a change set, leaving everything it does not mention alone.
///
/// The counterpart to [replaceCatalogue], and the difference matters: a full
/// snapshot withdraws anything it omits, a delta must not. Reading a delta as
/// if it were a snapshot would empty the shelf on the first morning price
/// change.
///
/// Stock is deliberately *not* taken from a delta unless the back office
/// sends it. A price change that carried a stale count would undo every sale
/// the terminal has rung since the last pull.
Future<void> applyCatalogueDelta({
required List<Product> products,
required List<Customer> customers,
List<String> retiredProductIds = const [],
}) async {
await _db.transaction((txn) async {
final batch = txn.batch();
for (final id in retiredProductIds) {
// Withdrawn rather than deleted: an order line already recorded points
// at this product, and a hard delete would orphan a bill's history.
batch.update(
Tables.products,
{
'is_active': 0,
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
where: 'id = ?',
whereArgs: [id],
);
}
for (final p in products) {
batch.insert(
Tables.products,
productToRow(p),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
// As in a full pull: locally registered shoppers must survive, so an
// existing row is left alone rather than overwritten.
for (final c in customers) {
batch.insert(
Tables.customers,
_importedCustomerRow(c),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
await batch.commit(noResult: true);
});
}
/// Drops every product and forgets when the catalogue was last imported.
///
/// Used at sign-out. Leaves customers, staff, orders and every other table
/// untouched — this is about the shelf, not the terminal's history — so the
/// next session starts with nothing to sell until it pulls a fresh copy from
/// the back office rather than carrying over whatever this session ended
/// with.
Future<void> clearCatalogue() async {
await _db.transaction((txn) async {
await txn.delete(Tables.products);
await txn.delete(
Tables.meta,
where: 'key IN (?, ?)',
whereArgs: [MetaKeys.lastImportAt, MetaKeys.catalogueRevision],
);
});
}
/// Applies stock movement after a sale, clamped at zero.
Future<void> decrementStock(Map<String, double> quantities) async {
if (quantities.isEmpty) return;
@@ -200,11 +284,10 @@ class CatalogueDao {
}
Future<Customer?> customerByMobile(String mobile) async {
final digits = mobile.replaceAll(RegExp(r'\D'), '');
final rows = await _db.query(
Tables.customers,
where: 'mobile = ?',
whereArgs: [digits],
whereArgs: [Customer.normaliseMobile(mobile)],
limit: 1,
);
return rows.isEmpty ? null : customerFromRow(rows.first);
@@ -220,14 +303,59 @@ class CatalogueDao {
return rows.isEmpty ? null : customerFromRow(rows.first);
}
/// Writes a customer created or edited at the till, and queues them.
///
/// Everything registered on this terminal has to reach the back office in its
/// own right — a shopper who signs up for the loyalty scheme and then buys
/// nothing used to exist here and nowhere else.
Future<void> upsertCustomer(Customer c) async {
await _db.insert(
Tables.customers,
customerToRow(c),
{...customerToRow(c), 'sync_status': pendingCustomer, 'synced_at': null},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
// ---------------------------------------------------- Customer outbox
static const int pendingCustomer = 0;
static const int syncedCustomer = 1;
Future<int> unsyncedCustomerCount() async {
final rows = await _db.rawQuery(
'SELECT COUNT(*) AS n FROM ${Tables.customers} WHERE sync_status = ?',
[pendingCustomer],
);
return (rows.first['n']! as num).toInt();
}
/// A bounded page of shoppers waiting to go up, oldest first.
Future<List<Customer>> unsyncedCustomers({int limit = 100}) async {
final rows = await _db.query(
Tables.customers,
where: 'sync_status = ?',
whereArgs: [pendingCustomer],
orderBy: 'created_at ASC',
limit: limit,
);
return rows.map(customerFromRow).toList();
}
/// Flips only the ids the back office named. Anything it stayed silent about
/// is left pending — the same rule the orders outbox follows.
Future<void> markCustomersSynced(List<String> ids, {DateTime? at}) async {
if (ids.isEmpty) return;
final marks = List.filled(ids.length, '?').join(',');
await _db.rawUpdate(
'UPDATE ${Tables.customers} SET sync_status = ?, synced_at = ? '
'WHERE id IN ($marks)',
[
syncedCustomer,
(at ?? DateTime.now()).millisecondsSinceEpoch,
...ids,
],
);
}
// ------------------------------------------------------------------ Meta
Future<String?> meta(String key) async {
final rows = await _db.query(
@@ -239,6 +367,16 @@ class CatalogueDao {
return rows.isEmpty ? null : rows.first['value'] as String?;
}
/// Every stored setting, for support and for tests that assert what is *not*
/// in here — credentials, most of all.
Future<Map<String, String>> allMeta() async {
final rows = await _db.query(Tables.meta);
return {
for (final row in rows)
row['key']! as String: (row['value'] as String?) ?? '',
};
}
Future<void> setMeta(String key, String value) async {
await _db.insert(
Tables.meta,

View File

@@ -5,15 +5,20 @@ import 'package:sqflite/sqflite.dart';
import '../../domain/entities/cart.dart';
import '../../domain/entities/customer.dart';
import '../../domain/entities/product.dart';
import '../../domain/entities/promo.dart';
import '../../domain/entities/transaction.dart';
import 'app_database.dart';
import 'catalogue_dao.dart';
/// Persists bills.
///
/// Every completed sale lands here with `sync_status = 0`. The end-of-day
/// upload selects those rows, sends them, and flips the accepted ones to 1.
/// Nothing is ever deleted as part of syncing.
/// Every completed sale lands here with `sync_status = 0`, which makes this
/// table the terminal's outbox: the drain engine selects those rows, sends
/// them, and flips the ones the back office confirmed to 1.
///
/// Confirmed rows are kept for [retentionWindow] so a batch the server later
/// loses can be re-sent in full, then retired by [purgeSyncedBefore]. Syncing
/// itself never deletes anything.
class OrderDao {
const OrderDao(this._db);
@@ -22,6 +27,9 @@ class OrderDao {
static const int pending = 0;
static const int synced = 1;
/// How long an accepted bill stays re-sendable on the terminal.
static const Duration retentionWindow = Duration(days: 7);
static String businessDateOf(DateTime dt) =>
'${dt.year.toString().padLeft(4, '0')}-'
'${dt.month.toString().padLeft(2, '0')}-'
@@ -53,10 +61,64 @@ class OrderDao {
);
});
if (customerRow != null) {
batch.insert(
// A targeted UPDATE, not an upsert. `ConflictAlgorithm.replace` is a
// DELETE followed by an INSERT, so every column absent from the row
// silently reverts to its schema default — which would reset
// `sync_status` to 1 and strand a shopper who had never been uploaded.
//
// Restricting it to the four figures a sale actually moves also stops
// a bill overwriting a name or number corrected at head office between
// the shopper being added to the cart and the cashier taking payment.
batch.update(
Tables.customers,
customerRow,
conflictAlgorithm: ConflictAlgorithm.replace,
{
'loyalty_points': customerRow['loyalty_points'],
'lifetime_spend': customerRow['lifetime_spend'],
'visit_count': customerRow['visit_count'],
'last_visit_at': customerRow['last_visit_at'],
},
where: 'id = ?',
whereArgs: [customerRow['id']],
);
}
await batch.commit(noResult: true);
});
}
/// Reverses [commitSale]: deletes the order and its lines, adds the stock
/// back, and restores an attached customer's row exactly as passed in
/// (the caller supplies the pre-sale row — reconstructing it from deltas
/// here would get `last_visit_at` wrong).
Future<void> voidSale({
required String orderId,
required Map<String, double> stockMovements,
Map<String, Object?>? customerRow,
}) async {
await _db.transaction((txn) async {
await txn
.delete(Tables.orderItems, where: 'order_id = ?', whereArgs: [orderId]);
await txn.delete(Tables.orders, where: 'id = ?', whereArgs: [orderId]);
final batch = txn.batch();
final now = DateTime.now().millisecondsSinceEpoch;
stockMovements.forEach((id, qty) {
batch.rawUpdate(
'UPDATE ${Tables.products} SET stock = stock + ?, updated_at = ? '
'WHERE id = ?',
[qty, now, id],
);
});
if (customerRow != null) {
batch.update(
Tables.customers,
{
'loyalty_points': customerRow['loyalty_points'],
'lifetime_spend': customerRow['lifetime_spend'],
'visit_count': customerRow['visit_count'],
'last_visit_at': customerRow['last_visit_at'],
},
where: 'id = ?',
whereArgs: [customerRow['id']],
);
}
await batch.commit(noResult: true);
@@ -85,6 +147,17 @@ class OrderDao {
'subtotal': cart.subtotal,
'line_discount': cart.lineDiscountTotal,
'bill_discount': cart.billDiscountTotal,
'promos_json': cart.appliedPromos.isEmpty
? null
: jsonEncode([
for (final applied in cart.appliedPromos)
{
'id': applied.promo.id,
'name': applied.promo.name,
'type': applied.promo.type.name,
'amount': applied.amount,
},
]),
'loyalty_value': cart.loyaltyRedemptionValue,
'taxable_amount': cart.taxableAmount,
'tax_amount': cart.taxAmount,
@@ -132,20 +205,26 @@ class OrderDao {
Future<List<SaleTransaction>> recent({int limit = 100}) =>
_query(orderBy: 'created_at DESC', limit: limit);
/// Bills for a day, optionally narrowed to one operator.
/// Bills for a day that have *not* yet been accepted by the server.
///
/// A shift report that is settled against a till has to cover exactly the
/// bills that cashier rang, not everything the terminal did that day.
///
/// Restricted to pending rows on purpose. The moment a bill is accepted its
/// figures are folded into [Tables.dayArchive], and the report adds the two
/// together — so an accepted row still sitting here during its retention
/// window would be counted twice and inflate the day's takings.
Future<List<SaleTransaction>> forBusinessDate(
DateTime day, {
String? cashierName,
}) =>
_query(
where: cashierName == null
? 'business_date = ?'
: 'business_date = ? AND cashier_name = ?',
? 'business_date = ? AND sync_status = ?'
: 'business_date = ? AND sync_status = ? AND cashier_name = ?',
whereArgs: [
businessDateOf(day),
pending,
if (cashierName != null) cashierName,
],
);
@@ -224,31 +303,43 @@ class OrderDao {
return rows.isEmpty ? null : rows.first;
}
Future<List<Map<String, Object?>>> syncRows({int limit = 200}) => _db.query(
Tables.orders,
columns: [
'id',
'invoice_number',
'total',
'created_at',
'sync_status',
'synced_at',
'sync_attempts',
'sync_error',
],
orderBy: 'created_at DESC',
limit: limit,
/// Rows for the sync log, with each bill's unit count folded in.
///
/// The count comes from a correlated sum over [Tables.orderItems] rather
/// than a column on the order: quantity can be fractional (loose weight), so
/// there is no line count that answers "how many units were on this bill".
/// A single aggregate keeps this to one query rather than one per row.
Future<List<Map<String, Object?>>> syncRows({int limit = 200}) =>
_db.rawQuery(
'''
SELECT o.id, o.invoice_number, o.total, o.created_at, o.sync_status,
o.synced_at, o.sync_attempts, o.sync_error,
COALESCE(
(SELECT SUM(i.quantity) FROM ${Tables.orderItems} i
WHERE i.order_id = o.id),
0
) AS item_count
FROM ${Tables.orders} o
ORDER BY o.created_at DESC
LIMIT ?
''',
[limit],
);
// ----------------------------------------------------------------- Sync
/// Folds accepted orders into the day archive, then deletes them.
/// Folds accepted orders into the day archive and marks them synced.
///
/// Once the server holds a bill the terminal has no reason to keep it, so
/// the rows go. Their figures are added to [Tables.dayArchive] first, so the
/// shift totals a cashier sees do not collapse after a mid-shift sync.
/// Both steps run in one transaction: if the delete fails the archive is
/// rolled back with it, and nothing is counted twice.
Future<void> archiveAndDelete(List<SaleTransaction> orders) async {
/// Their figures are added to [Tables.dayArchive] so the shift totals a
/// cashier sees do not collapse after a mid-shift sync, and the rows
/// themselves are kept — at `sync_status = 1` — until [purgeSyncedBefore]
/// retires them. Keeping them buys a recovery window: if the back office
/// loses a batch, the full bills are still on the terminal and can be sent
/// again. Once deleted only the archived totals survive, and a lost bill's
/// line items are gone for good.
///
/// Both steps run in one transaction: if the status flip fails the archive
/// rolls back with it, and nothing is counted twice.
Future<void> archiveAccepted(List<SaleTransaction> orders) async {
if (orders.isEmpty) return;
await _db.transaction((txn) async {
@@ -331,17 +422,30 @@ class OrderDao {
);
}
// order_items goes with it via ON DELETE CASCADE.
final ids = orders.map((o) => o.id).toList();
final placeholders = List.filled(ids.length, '?').join(',');
await txn.delete(
Tables.orders,
where: 'id IN ($placeholders)',
whereArgs: ids,
await txn.rawUpdate(
'UPDATE ${Tables.orders} '
'SET sync_status = ?, synced_at = ?, sync_error = NULL '
'WHERE id IN ($placeholders)',
[synced, DateTime.now().millisecondsSinceEpoch, ...ids],
);
});
}
/// Retires bills the server took delivery of more than [retention] ago.
///
/// Their archived totals are untouched and stay forever — this drops only the
/// re-sendable copy once the recovery window has closed, so a terminal that
/// trades for years does not carry every bill it has ever rung.
///
/// Returns how many rows went. `order_items` follows via ON DELETE CASCADE.
Future<int> purgeSyncedBefore(DateTime cutoff) => _db.delete(
Tables.orders,
where: 'sync_status = ? AND synced_at IS NOT NULL AND synced_at < ?',
whereArgs: [synced, cutoff.millisecondsSinceEpoch],
);
/// Archived figures for a business day, one row per cashier.
///
/// Empty when nothing has synced yet. Pass [cashierName] to scope it to a
@@ -407,6 +511,36 @@ class OrderDao {
await _db.delete(Tables.parkedBills, where: 'id = ?', whereArgs: [id]);
}
/// Rebuilds the campaigns recorded against a bill.
///
/// The stored rows carry the promo's name and amount rather than a live
/// lookup, so a campaign that has since been edited or deleted still prints
/// on a reissued receipt exactly as it was given.
static List<AppliedPromo> _promosFromRow(String? json) {
if (json == null || json.isEmpty) return const [];
try {
return (jsonDecode(json) as List)
.cast<Map<String, Object?>>()
.map((p) => AppliedPromo(
promo: Promo(
id: (p['id'] as String?) ?? '',
name: (p['name'] as String?) ?? 'Promotion',
type: PromoType.values
.where((t) => t.name == p['type'])
.firstOrNull ??
PromoType.flatOffBill,
),
amount: (p['amount'] as num?)?.toDouble() ?? 0,
),)
.toList();
} on Object {
// A bill that cannot name its campaigns is still a valid bill; the total
// is on the row itself and does not depend on this.
return const [];
}
}
// -------------------------------------------------------------- Internals
Future<List<SaleTransaction>> _query({
String? where,
@@ -499,19 +633,28 @@ class OrderDao {
// payload, the day archive, the shift report — is overstated.
final billDiscount = (o['bill_discount']! as num).toDouble();
// Campaigns are restored so a reprinted receipt still names what the
// shopper was given. Their amounts are then *subtracted* from the manual
// discount, because `bill_discount` already contains them — restoring both
// at full value would discount the bill twice on the way back in.
final promos = _promosFromRow(o['promos_json'] as String?);
final promoTotal = promos.fold<double>(0, (sum, p) => sum + p.amount);
final manualDiscount = (billDiscount - promoTotal).clamp(0, billDiscount);
return SaleTransaction(
id: o['id']! as String,
invoiceNumber: o['invoice_number']! as String,
cart: Cart(
lines: lines,
customer: customer,
billDiscount: billDiscount > 0
billDiscount: manualDiscount > 0
? Discount(
type: DiscountType.flat,
value: billDiscount,
value: manualDiscount.toDouble(),
reason: 'Bill discount',
)
: Discount.none,
appliedPromos: promos,
pointsRedeemed: (o['points_redeemed'] as int?) ?? 0,
),
payments: payments,

View File

@@ -0,0 +1,185 @@
import 'package:sqflite/sqflite.dart';
import 'package:uuid/uuid.dart';
import '../../domain/entities/promo.dart';
import 'app_database.dart';
/// Raised when a campaign would be saved in a state the till cannot apply.
class PromoException implements Exception {
const PromoException(this.message);
final String message;
@override
String toString() => message;
}
/// Stores campaigns on the terminal.
///
/// Local like everything else the till needs: a shop mid-promotion with a dead
/// line still has to honour the price on the shelf edge.
class PromoDao {
const PromoDao(this._db);
final Database _db;
static const _uuid = Uuid();
Future<List<Promo>> all({bool activeOnly = false}) async {
final rows = await _db.query(
Tables.promos,
where: activeOnly ? 'is_active = 1' : null,
orderBy: 'priority ASC, created_at ASC',
);
return rows.map(_fromRow).toList();
}
Future<Promo?> findById(String id) async {
final rows = await _db.query(
Tables.promos,
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
return rows.isEmpty ? null : _fromRow(rows.first);
}
/// Inserts or updates. Returns the stored campaign, with its id.
Future<Promo> save(Promo promo) async {
_validate(promo);
final id = promo.id.isEmpty ? _uuid.v4() : promo.id;
final now = DateTime.now().millisecondsSinceEpoch;
final existing = await findById(id);
await _db.insert(
Tables.promos,
{
'id': id,
'name': promo.name.trim(),
'type': promo.type.name,
'value': promo.value,
'target_id': promo.targetId,
'target_label': promo.targetLabel,
'buy_quantity': promo.buyQuantity,
'free_quantity': promo.freeQuantity,
'min_bill_value': promo.minBillValue,
'max_discount': promo.maxDiscount,
'valid_from': promo.validFrom?.millisecondsSinceEpoch,
'valid_to': promo.validTo?.millisecondsSinceEpoch,
'days_of_week': (promo.daysOfWeek.toList()..sort()).join(','),
'stackable': promo.stackable ? 1 : 0,
'priority': promo.priority,
'is_active': promo.isActive ? 1 : 0,
// Preserved on update so the list keeps a stable order.
'created_at': existing == null ? now : await _createdAt(id) ?? now,
'updated_at': now,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
// Read back rather than echoing the input, so the caller gets exactly what
// the database holds — including the id minted for a new campaign.
return (await findById(id))!;
}
Future<int?> _createdAt(String id) async {
final rows = await _db.query(
Tables.promos,
columns: ['created_at'],
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
return rows.isEmpty ? null : rows.first['created_at'] as int?;
}
Future<void> setActive(String id, {required bool active}) async {
await _db.update(
Tables.promos,
{
'is_active': active ? 1 : 0,
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
where: 'id = ?',
whereArgs: [id],
);
}
/// Hard delete. Unlike staff, nothing already recorded points at a promo row
/// — a bill stores the amount it was given, not a reference to the campaign,
/// so deleting one cannot change a past total.
Future<void> delete(String id) async {
await _db.delete(Tables.promos, where: 'id = ?', whereArgs: [id]);
}
// ------------------------------------------------------------- Internals
static void _validate(Promo promo) {
if (promo.name.trim().isEmpty) {
throw const PromoException('A campaign needs a name.');
}
if (promo.type.needsTarget &&
(promo.targetId == null || promo.targetId!.isEmpty)) {
throw const PromoException(
'This campaign needs a product or category to apply to.',
);
}
if (promo.type == PromoType.buyXGetY) {
if (promo.buyQuantity < 1 || promo.freeQuantity < 1) {
throw const PromoException(
'Buy and free quantities must both be at least one.',
);
}
} else if (promo.value <= 0) {
throw const PromoException('A campaign must give something away.');
}
if (promo.type.isPercentage && promo.value > 100) {
// Over 100% is a refund with extra steps.
throw const PromoException('A percentage cannot exceed 100.');
}
final from = promo.validFrom;
final to = promo.validTo;
if (from != null && to != null && to.isBefore(from)) {
throw const PromoException('The end date is before the start date.');
}
if (promo.daysOfWeek.any((d) => d < 1 || d > 7)) {
throw const PromoException('Days of the week must be 1 (Mon) to 7 (Sun).');
}
}
static Promo _fromRow(Map<String, Object?> row) {
final days = (row['days_of_week'] as String? ?? '')
.split(',')
.where((s) => s.isNotEmpty)
.map(int.parse)
.toSet();
return Promo(
id: row['id']! as String,
name: row['name']! as String,
type: PromoType.values.byName(row['type']! as String),
value: (row['value'] as num? ?? 0).toDouble(),
targetId: row['target_id'] as String?,
targetLabel: row['target_label'] as String?,
buyQuantity: (row['buy_quantity'] as int?) ?? 0,
freeQuantity: (row['free_quantity'] as int?) ?? 0,
minBillValue: (row['min_bill_value'] as num? ?? 0).toDouble(),
maxDiscount: (row['max_discount'] as num?)?.toDouble(),
validFrom: row['valid_from'] == null
? null
: DateTime.fromMillisecondsSinceEpoch(row['valid_from']! as int),
validTo: row['valid_to'] == null
? null
: DateTime.fromMillisecondsSinceEpoch(row['valid_to']! as int),
daysOfWeek: days,
stackable: (row['stackable'] as int? ?? 0) == 1,
priority: (row['priority'] as int?) ?? 100,
isActive: (row['is_active'] as int? ?? 1) == 1,
);
}
}

View File

@@ -0,0 +1,83 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../domain/entities/pos_session.dart';
/// Where the signed-in session lives between launches.
///
/// The platform keystore, not SQLite — Keychain on macOS, Credential Manager
/// on Windows, the Android Keystore on a tablet. The response carries a bearer
/// token and every staff PIN in the clear, and the SQLite file sits on a
/// machine behind a shop counter readable by anything that can open it.
///
/// Stored as one blob rather than field by field so [clear] is a single
/// delete. A sign-out that leaves half a session behind is worse than one that
/// leaves none.
class SessionStore {
SessionStore({FlutterSecureStorage? secureStorage})
: _secure = secureStorage ?? const FlutterSecureStorage();
final FlutterSecureStorage _secure;
static const String _key = 'pos.session';
/// The stored session, or null if there is none, it cannot be read, or it
/// has expired.
///
/// An expired token is treated as absent and swept: carrying it forward only
/// moves the failure to the first call that uses it, which is a cashier
/// discovering it mid-sale rather than at the login screen.
Future<PosSession?> read() async {
String? raw;
try {
raw = await _secure.read(key: _key);
} on Object catch (e) {
// No keystore — a headless test host, or a Linux box with no secret
// service. The terminal still runs, it just asks for credentials.
debugPrint('SessionStore: keystore unavailable ($e)');
return null;
}
if (raw == null || raw.isEmpty) return null;
PosSession session;
try {
session = PosSession.fromJson(jsonDecode(raw) as Map<String, Object?>);
} on Object catch (e) {
// A blob this build cannot parse — an upgrade that changed the shape.
// Drop it rather than failing every launch from here on.
debugPrint('SessionStore: unreadable session dropped ($e)');
await clear();
return null;
}
if (session.token.isEmpty || session.isExpired) {
await clear();
return null;
}
return session;
}
Future<void> save(PosSession session) async {
try {
await _secure.write(key: _key, value: jsonEncode(session.toJson()));
} on Object catch (e) {
// Not fatal: the session is live in memory and this shift carries on.
// The next launch just asks for credentials again.
debugPrint('SessionStore: could not persist session ($e)');
}
}
/// Removes the session. Called on every sign-out, and on an expired or
/// rejected token.
Future<void> clear() async {
try {
await _secure.delete(key: _key);
} on Object catch (e) {
debugPrint('SessionStore: could not clear session ($e)');
}
}
}

View File

@@ -0,0 +1,279 @@
import 'package:sqflite/sqflite.dart';
import 'package:uuid/uuid.dart';
import '../../core/security/pin_hasher.dart';
import '../../domain/entities/store_account.dart';
import 'app_database.dart';
/// Raised when a staff change would leave the terminal unusable or unowned.
class StaffException implements Exception {
const StaffException(this.message);
final String message;
@override
String toString() => message;
}
/// Who can sign in at this till.
///
/// The PIN is never stored, only a PBKDF2 hash and its salt — so a stolen
/// database file does not hand over the terminal, and neither does an unzipped
/// APK, which is what the previous hardcoded literals did.
class StaffDao {
const StaffDao(this._db);
final Database _db;
static const _uuid = Uuid();
/// The accounts a shop starts with.
///
/// Deliberately not 1234/2345/3456: those are the first thing anyone tries,
/// and [_assertPinIsAcceptable] refuses them for exactly that reason — a
/// seed the rule itself would reject is not a defensible default.
///
/// They are still known values in source, which is why every one is flagged
/// [StaffUser.mustChangePin]. They get a shop trading on day one and are
/// replaced at first sign-in, rather than becoming the permanent credentials
/// the way the old hardcoded PINs did.
static const seedAccounts = [
(name: 'Suriya', role: StaffRole.admin, pin: '4821'),
(name: 'Divya', role: StaffRole.manager, pin: '5093'),
(name: 'Rahul', role: StaffRole.cashier, pin: '6274'),
];
/// Creates the starting accounts the first time a terminal runs.
///
/// Idempotent: a terminal that already has staff is left alone, so an upgrade
/// never resurrects a deleted account or resets a PIN someone chose.
Future<void> seedIfEmpty() async {
final existing = await _db.rawQuery(
'SELECT COUNT(*) AS c FROM ${Tables.staff}',
);
if ((existing.first['c']! as int) > 0) return;
for (final account in seedAccounts) {
await create(
name: account.name,
role: account.role,
pin: account.pin,
mustChangePin: true,
);
}
}
Future<List<StaffUser>> all({bool includeInactive = false}) async {
final rows = await _db.query(
Tables.staff,
where: includeInactive ? null : 'is_active = 1',
orderBy: 'created_at ASC',
);
return rows.map(_fromRow).toList();
}
Future<StaffUser?> findById(String id) async {
final rows = await _db.query(
Tables.staff,
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
return rows.isEmpty ? null : _fromRow(rows.first);
}
/// Checks a PIN and returns whose it is.
///
/// Every active account is tried, because a cashier types only a PIN — there
/// is no username at the till. Returns null on no match, without saying
/// whether the PIN was close.
Future<StaffUser?> authenticate(String pin) async {
final rows = await _db.query(Tables.staff, where: 'is_active = 1');
for (final row in rows) {
final matches = PinHasher.verify(
pin,
salt: row['pin_salt']! as String,
hash: row['pin_hash']! as String,
);
if (matches) return _fromRow(row);
}
return null;
}
Future<StaffUser> create({
required String name,
required StaffRole role,
required String pin,
bool mustChangePin = false,
}) async {
_assertPinIsAcceptable(pin);
final trimmed = name.trim();
if (trimmed.isEmpty) {
throw const StaffException('A staff member needs a name.');
}
// Two people sharing a PIN would make the till attribute bills to whichever
// row happened to be checked first.
if (await authenticate(pin) != null) {
throw const StaffException(
'Another staff member already uses that PIN. Choose a different one.',
);
}
final salt = PinHasher.newSalt();
final now = DateTime.now().millisecondsSinceEpoch;
final id = _uuid.v4();
await _db.insert(Tables.staff, {
'id': id,
'name': trimmed,
'role': role.name,
'pin_hash': PinHasher.hash(pin, salt),
'pin_salt': salt,
'must_change_pin': mustChangePin ? 1 : 0,
'is_active': 1,
'created_at': now,
'updated_at': now,
});
return StaffUser(
id: id,
name: trimmed,
role: role,
mustChangePin: mustChangePin,
);
}
Future<void> updateDetails({
required String id,
String? name,
StaffRole? role,
}) async {
if (role != null) await _assertNotLastAdmin(id, newRole: role);
await _db.update(
Tables.staff,
{
if (name != null) 'name': name.trim(),
if (role != null) 'role': role.name,
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
where: 'id = ?',
whereArgs: [id],
);
}
/// Sets a new PIN. [mustChangePin] is for an admin resetting someone else's;
/// a person choosing their own clears the flag.
Future<void> setPin(
String id,
String pin, {
bool mustChangePin = false,
}) async {
_assertPinIsAcceptable(pin);
final owner = await authenticate(pin);
if (owner != null && owner.id != id) {
throw const StaffException(
'Another staff member already uses that PIN. Choose a different one.',
);
}
final salt = PinHasher.newSalt();
await _db.update(
Tables.staff,
{
'pin_hash': PinHasher.hash(pin, salt),
'pin_salt': salt,
'must_change_pin': mustChangePin ? 1 : 0,
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
where: 'id = ?',
whereArgs: [id],
);
}
/// Deactivates rather than deletes.
///
/// Bills carry the cashier's name, and reports are settled against it. A hard
/// delete would leave yesterday's takings attributed to nobody.
Future<void> deactivate(String id) async {
await _assertNotLastAdmin(id, deactivating: true);
await _db.update(
Tables.staff,
{
'is_active': 0,
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
where: 'id = ?',
whereArgs: [id],
);
}
Future<void> reactivate(String id) async {
await _db.update(
Tables.staff,
{
'is_active': 1,
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
where: 'id = ?',
whereArgs: [id],
);
}
// ------------------------------------------------------------- Internals
static void _assertPinIsAcceptable(String pin) {
if (pin.length < 4 || int.tryParse(pin) == null) {
throw const StaffException('A PIN must be at least four digits.');
}
// Not security theatre: on a keypad behind a counter these are the ones a
// queue can read off the operator's hand.
const tooObvious = {'0000', '1111', '2222', '3333', '4444', '5555', '6666',
'7777', '8888', '9999', '1234', '4321', '0123',};
if (tooObvious.contains(pin)) {
throw const StaffException(
'That PIN is too easy to guess from across the counter. '
'Choose another.',
);
}
}
/// A till with no admin cannot be administered — including to make someone an
/// admin again. Recovering from it means editing the database by hand.
Future<void> _assertNotLastAdmin(
String id, {
StaffRole? newRole,
bool deactivating = false,
}) async {
final target = await findById(id);
if (target == null || target.role != StaffRole.admin) return;
final losingAdmin = deactivating || (newRole != StaffRole.admin);
if (!losingAdmin) return;
final admins = await _db.rawQuery(
'SELECT COUNT(*) AS c FROM ${Tables.staff} '
"WHERE role = 'admin' AND is_active = 1",
);
if ((admins.first['c']! as int) <= 1) {
throw const StaffException(
'This is the only admin left. Promote someone else first, or the '
'terminal cannot be administered at all.',
);
}
}
static StaffUser _fromRow(Map<String, Object?> row) => StaffUser(
id: row['id']! as String,
name: row['name']! as String,
role: StaffRole.values.byName(row['role']! as String),
mustChangePin: (row['must_change_pin'] as int? ?? 0) == 1,
isActive: (row['is_active'] as int? ?? 1) == 1,
);
}

View File

@@ -0,0 +1,105 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../core/config/sync_config.dart';
import 'app_database.dart';
import 'catalogue_dao.dart';
/// Persists how this terminal reaches the back office.
///
/// Split deliberately across two stores. Which broker, on which port, over TLS
/// — that is configuration, and it goes in the database where it can be read
/// during support. The username, password and API key are credentials, and go
/// to the platform keystore: Keychain on macOS, Credential Manager on Windows,
/// the Android Keystore on a tablet.
///
/// Writing them into SQLite would put them in the same file as the bills, on a
/// machine behind a shop counter, readable by anything that can open it.
class SyncConfigStore {
SyncConfigStore(this._catalogue, {FlutterSecureStorage? secureStorage})
: _secure = secureStorage ?? const FlutterSecureStorage();
final CatalogueDao _catalogue;
final FlutterSecureStorage _secure;
static const _kUsername = 'sync.username';
static const _kPassword = 'sync.password';
static const _kApiKey = 'sync.api_key';
/// Reads the stored configuration, falling back to [fallback] per field.
///
/// The fallback carries the terminal's own store and terminal ids, which are
/// never overwritten from here — they belong to the device's identity.
Future<SyncConfig> load(SyncConfig fallback) async {
final transport = await _catalogue.meta(MetaKeys.syncTransport);
final host = await _catalogue.meta(MetaKeys.syncBrokerHost);
final port = await _catalogue.meta(MetaKeys.syncBrokerPort);
final tls = await _catalogue.meta(MetaKeys.syncUseTls);
final httpUrl = await _catalogue.meta(MetaKeys.syncHttpBaseUrl);
final credentials = await _readCredentials();
return fallback.copyWith(
transport: TransportKind.values
.where((k) => k.name == transport)
.firstOrNull ??
fallback.transport,
brokerHost: host ?? fallback.brokerHost,
brokerPort: int.tryParse(port ?? '') ?? fallback.brokerPort,
useTls: tls == null ? fallback.useTls : tls == '1',
httpBaseUrl: httpUrl ?? fallback.httpBaseUrl,
username: credentials.username,
password: credentials.password,
apiKey: credentials.apiKey,
);
}
Future<void> save(SyncConfig config) async {
await _catalogue.setMeta(MetaKeys.syncTransport, config.transport.name);
await _catalogue.setMeta(MetaKeys.syncBrokerHost, config.brokerHost);
await _catalogue.setMeta(MetaKeys.syncBrokerPort, '${config.brokerPort}');
await _catalogue.setMeta(MetaKeys.syncUseTls, config.useTls ? '1' : '0');
await _catalogue.setMeta(MetaKeys.syncHttpBaseUrl, config.httpBaseUrl);
await _writeSecret(_kUsername, config.username);
await _writeSecret(_kPassword, config.password);
await _writeSecret(_kApiKey, config.apiKey);
}
Future<({String? username, String? password, String? apiKey})>
_readCredentials() async {
try {
return (
username: await _secure.read(key: _kUsername),
password: await _secure.read(key: _kPassword),
apiKey: await _secure.read(key: _kApiKey),
);
} on Object catch (e) {
// No keystore — a headless test host, or a Linux box with no secret
// service. The terminal still runs; it just cannot authenticate until
// someone re-enters the credentials, which is the safe way to fail.
debugPrint('Secure storage unavailable, credentials not loaded: $e');
return (username: null, password: null, apiKey: null);
}
}
Future<void> _writeSecret(String key, String? value) async {
try {
if (value == null || value.isEmpty) {
await _secure.delete(key: key);
} else {
await _secure.write(key: key, value: value);
}
} on Object catch (e) {
debugPrint('Could not persist $key to secure storage: $e');
}
}
/// Wipes stored credentials. Used when a terminal is handed on or re-pointed
/// at a different back office.
Future<void> clearCredentials() async {
for (final key in [_kUsername, _kPassword, _kApiKey]) {
await _writeSecret(key, null);
}
}
}

View File

@@ -0,0 +1,112 @@
import 'package:uuid/uuid.dart';
import 'app_database.dart';
import 'catalogue_dao.dart';
/// Who this till is, across a fleet.
///
/// Everything that has to be distinguishable between 100 installed terminals
/// hangs off this: MQTT topics, presence records, invoice numbers, and which
/// cashier's shift a bill belongs to. Before it existed every device called
/// itself `TERM-01`, so their topics collided, the fleet board showed one
/// terminal, and 100 tills minted the same invoice number.
class TerminalIdentity {
const TerminalIdentity({
required this.deviceId,
required this.code,
required this.name,
required this.storeId,
});
/// Minted once, on first run, and never changed. The stable machine identity
/// — reinstalling the app on the same till keeps it, because it lives in the
/// database rather than in memory.
final String deviceId;
/// Short, unique, and safe inside an invoice number: `T4A9`.
final String code;
/// What a person calls it. Free text, and duplicates are the shop's problem,
/// not the system's — nothing keys on it.
final String name;
final String storeId;
/// Used as the MQTT client id and in every topic. Stable across restarts so
/// the broker can resume a session rather than treating each launch as a new
/// client.
String get clientId => 'pos-$storeId-$code';
@override
String toString() => '$name ($code)';
}
/// Reads the terminal's identity from the database, minting it on first run.
class TerminalIdentityStore {
const TerminalIdentityStore(this._catalogue);
final CatalogueDao _catalogue;
static const _uuid = Uuid();
/// Loads the identity, creating one the first time this device is started.
///
/// The mint is idempotent: an existing device id is never replaced, so a
/// terminal cannot silently change identity and orphan its own history.
///
/// [defaultStoreId] matches the store this build's default HTTP endpoint
/// serves — see `syncConfigProvider` — so a fresh terminal's first import
/// pulls that store's real catalogue without anyone visiting Settings
/// first. Settings → Connectivity & sync → Configure changes it per
/// terminal from there.
Future<TerminalIdentity> load({String defaultStoreId = '1135'}) async {
var deviceId = await _catalogue.meta(MetaKeys.deviceId);
var code = await _catalogue.meta(MetaKeys.terminalCode);
if (deviceId == null || deviceId.isEmpty) {
deviceId = _uuid.v4();
await _catalogue.setMeta(MetaKeys.deviceId, deviceId);
}
if (code == null || code.isEmpty) {
code = codeFor(deviceId);
await _catalogue.setMeta(MetaKeys.terminalCode, code);
}
final name = await _catalogue.meta(MetaKeys.terminalName);
final storeId = await _catalogue.meta(MetaKeys.storeId);
return TerminalIdentity(
deviceId: deviceId,
code: code,
name: (name == null || name.isEmpty) ? 'Terminal $code' : name,
storeId: (storeId == null || storeId.isEmpty) ? defaultStoreId : storeId,
);
}
/// `T` plus four hex characters of the device id.
///
/// Short enough to read off a screen and repeat over the phone, and with
/// 65,536 values a 100-terminal fleet has roughly a 7% chance of a collision
/// somewhere in it — which is why [rename] exists and why the back office
/// should reject a duplicate rather than assume uniqueness.
static String codeFor(String deviceId) {
final hex = deviceId.replaceAll('-', '');
return 'T${hex.substring(0, 4).toUpperCase()}';
}
/// Re-codes a terminal, for when head office wants readable numbers or two
/// devices in one shop happened to collide.
///
/// The device id is deliberately untouched: history already written under the
/// old code keeps pointing at the same physical till.
Future<void> rename({String? code, String? name, String? storeId}) async {
if (code != null && code.isNotEmpty) {
await _catalogue.setMeta(MetaKeys.terminalCode, code.toUpperCase());
}
if (name != null) await _catalogue.setMeta(MetaKeys.terminalName, name);
if (storeId != null && storeId.isNotEmpty) {
await _catalogue.setMeta(MetaKeys.storeId, storeId);
}
}
}

View File

@@ -0,0 +1,87 @@
import '../../core/security/pin_hasher.dart';
import '../../domain/entities/store_account.dart';
import 'app_database.dart';
import 'catalogue_dao.dart';
import 'staff_dao.dart';
/// The PIN that authorises removing a rung item from a bill.
///
/// Set once by an admin and handed to whoever is on the counter, so a cashier
/// can void a line without an admin walking over. It is a *separate* secret
/// from staff PINs on purpose: a staff PIN identifies a person and is what
/// stamps a bill, and sharing one to allow voids would put every sale that
/// shift under the wrong name.
///
/// Stored hashed with its own salt, never in the clear. Until an admin sets
/// one, [verify] falls back to any admin's staff PIN — a terminal that cannot
/// void at all is worse than one that needs the admin present.
class VoidPinStore {
const VoidPinStore(this._meta, this._staff);
final CatalogueDao _meta;
final StaffDao _staff;
/// Whether an admin has set a dedicated removal PIN on this terminal.
///
/// Empty counts as absent: [clearPin] blanks the row rather than deleting
/// it, so a null check alone would report a cleared PIN as still set.
Future<bool> get isConfigured async {
final hash = await _meta.meta(MetaKeys.voidPinHash);
return hash != null && hash.isNotEmpty;
}
Future<void> setPin(String pin) async {
_assertAcceptable(pin);
final salt = PinHasher.newSalt();
await _meta.setMeta(MetaKeys.voidPinHash, PinHasher.hash(pin, salt));
await _meta.setMeta(MetaKeys.voidPinSalt, salt);
}
/// Drops the dedicated PIN, returning the terminal to admin-PIN-only voids.
Future<void> clearPin() async {
await _meta.setMeta(MetaKeys.voidPinHash, '');
await _meta.setMeta(MetaKeys.voidPinSalt, '');
}
/// True when [pin] may authorise a removal.
///
/// Checks the dedicated PIN first, then admin staff PINs. An admin's own PIN
/// always works, so setting a removal PIN never locks the owner out of their
/// own till.
Future<bool> verify(String pin) async {
final hash = await _meta.meta(MetaKeys.voidPinHash);
final salt = await _meta.meta(MetaKeys.voidPinSalt);
if (hash != null && hash.isNotEmpty && salt != null && salt.isNotEmpty) {
if (PinHasher.verify(pin, salt: salt, hash: hash)) return true;
}
final user = await _staff.authenticate(pin);
return user != null && user.role == StaffRole.admin;
}
/// Same rule the staff PINs use, for the same reason: these are typed on a
/// keypad behind a counter, in front of a queue.
static void _assertAcceptable(String pin) {
if (pin.length < 4 || int.tryParse(pin) == null) {
throw const VoidPinException('A PIN must be at least four digits.');
}
const tooObvious = {'0000', '1111', '2222', '3333', '4444', '5555', '6666',
'7777', '8888', '9999', '1234', '4321', '0123',};
if (tooObvious.contains(pin)) {
throw const VoidPinException(
'That PIN is too easy to guess from across the counter. '
'Choose another.',
);
}
}
}
class VoidPinException implements Exception {
const VoidPinException(this.message);
final String message;
@override
String toString() => message;
}

View File

@@ -0,0 +1,77 @@
import '../../domain/entities/customer.dart';
import '../../domain/entities/product.dart';
/// What one catalogue pull returned.
class CatalogueSnapshot {
const CatalogueSnapshot({
required this.products,
required this.customers,
required this.fetchedAt,
required this.revision,
this.isDelta = false,
this.retiredProductIds = const [],
});
final List<Product> products;
final List<Customer> customers;
final DateTime fetchedAt;
/// Server-side catalogue version, so the cashier can tell whether a
/// re-import actually changed anything, and so the next pull can ask for
/// only what has moved since.
final String revision;
/// Whether this is a change set rather than the whole catalogue.
///
/// The distinction matters at the point of applying it: a full snapshot
/// withdraws anything it does not mention, a delta must not.
final bool isDelta;
/// Products the back office has withdrawn. Only meaningful on a delta —
/// a full snapshot expresses the same thing by omission.
final List<String> retiredProductIds;
bool get isEmpty =>
products.isEmpty && customers.isEmpty && retiredProductIds.isEmpty;
int get changeCount =>
products.length + customers.length + retiredProductIds.length;
}
/// Raised when the catalogue cannot be pulled.
class CatalogueSyncException implements Exception {
const CatalogueSyncException(this.message, {this.retryable = true});
final String message;
/// False for a bad credential or an unconfigured endpoint — retrying cannot
/// fix either, and the cashier needs to be told rather than watched to spin.
final bool retryable;
@override
String toString() => message;
}
/// Where the terminal gets its products and customers.
///
/// The counterpart to `OrderTransport`: that one is how bills leave, this is
/// how the catalogue arrives. Same shape, for the same reason — the repository
/// should not know or care whether the answer came from a real endpoint or a
/// local stub.
abstract class CatalogueSource {
/// Shown in the events log, so a cashier reporting a problem can say which
/// route the terminal was using.
String get label;
/// Pulls the catalogue.
///
/// Passing [since] asks for only what has changed, and an implementation
/// that cannot do deltas is free to ignore it and return everything — the
/// caller checks [CatalogueSnapshot.isDelta] rather than assuming.
Future<CatalogueSnapshot> fetch({
String? since,
void Function(double progress, String stage)? onProgress,
});
void dispose() {}
}

View File

@@ -0,0 +1,178 @@
import '../../core/constants/app_constants.dart';
import '../../domain/entities/customer.dart';
import '../../domain/entities/product.dart';
/// Raised when the back office sends something this build cannot read.
class CatalogueFormatException implements Exception {
const CatalogueFormatException(this.message);
final String message;
@override
String toString() => message;
}
/// Translates between the back office's JSON and the terminal's entities.
///
/// Deliberately tolerant in one direction and strict in the other. A missing
/// optional field takes a sensible default, because a catalogue of 4,000
/// products should not fail to import over one absent emoji. A missing
/// *required* field throws, because a product with no price or no barcode
/// cannot be sold and silently dropping it would leave a shelf item that
/// scans to nothing.
class CatalogueWire {
const CatalogueWire._();
// ------------------------------------------------------------- Products
static Product productFromJson(Map<String, Object?> json) {
final id = _requireString(json, 'id');
return Product(
id: id,
name: _requireString(json, 'name', context: id),
barcode: _requireString(json, 'barcode', context: id),
sku: (json['sku'] as String?) ?? id,
category: _category(json['category']),
price: _requireNumber(json, 'price', context: id),
// Absent stock means "not tracked", not "none left" — a terminal that
// read it as zero would refuse to sell the item at all.
stock: (json['stock'] as num?)?.toDouble() ?? 0,
mrp: (json['mrp'] as num?)?.toDouble(),
emoji: (json['emoji'] as String?) ?? '📦',
imageUrl: json['image_url'] as String?,
unit: _unit(json['unit']),
gstRate: _gstRate(json['gst_rate']),
hsnCode: json['hsn_code'] as String?,
brand: json['brand'] as String?,
// Absent means active. A back office that omits the flag is not saying
// its whole catalogue is withdrawn.
isActive: json['is_active'] as bool? ?? true,
);
}
static Map<String, Object?> productToJson(Product p) => {
'id': p.id,
'name': p.name,
'barcode': p.barcode,
'sku': p.sku,
'category': p.category.name,
'price': p.price,
'mrp': p.mrp,
'stock': p.stock,
'emoji': p.emoji,
'image_url': p.imageUrl,
'unit': p.unit.name,
'gst_rate': p.gstRate,
'hsn_code': p.hsnCode,
'brand': p.brand,
'is_active': p.isActive,
};
// ------------------------------------------------------------ Customers
static Customer customerFromJson(Map<String, Object?> json) {
final id = _requireString(json, 'id');
return Customer(
id: id,
name: _requireString(json, 'name', context: id),
mobile: _requireString(json, 'mobile', context: id),
email: json['email'] as String?,
gender: Gender.values
.where((g) => g.name == json['gender'])
.firstOrNull ??
Gender.unspecified,
dateOfBirth: _date(json['date_of_birth']),
loyaltyPoints: (json['loyalty_points'] as num?)?.toInt() ?? 0,
lifetimeSpend: (json['lifetime_spend'] as num?)?.toDouble() ?? 0,
visitCount: (json['visit_count'] as num?)?.toInt() ?? 0,
createdAt: _date(json['created_at']),
lastVisitAt: _date(json['last_visit_at']),
);
}
static Map<String, Object?> customerToJson(Customer c) => {
'id': c.id,
'name': c.name,
'mobile': c.mobile,
'email': c.email,
'gender': c.gender.name,
'date_of_birth': c.dateOfBirth?.toIso8601String(),
'loyalty_points': c.loyaltyPoints,
'lifetime_spend': c.lifetimeSpend,
'visit_count': c.visitCount,
'created_at': c.createdAt?.toIso8601String(),
'last_visit_at': c.lastVisitAt?.toIso8601String(),
};
// ------------------------------------------------------------ Internals
static String _requireString(
Map<String, Object?> json,
String key, {
String? context,
}) {
final value = json[key];
if (value is String && value.trim().isNotEmpty) return value.trim();
throw CatalogueFormatException(
'Product or customer${context == null ? '' : ' $context'} has no "$key". '
'The terminal cannot sell or identify a record without it.',
);
}
static double _requireNumber(
Map<String, Object?> json,
String key, {
String? context,
}) {
final value = json[key];
if (value is num) return value.toDouble();
throw CatalogueFormatException(
'Product${context == null ? '' : ' $context'} has no numeric "$key".',
);
}
/// Falls back rather than throwing.
///
/// A category the terminal does not recognise is a display problem — the
/// item still scans, still prices, still bills. Refusing the whole import
/// over one would be a far worse outcome than filing it under Grocery.
static ProductCategory _category(Object? raw) {
if (raw is! String) return ProductCategory.grocery;
final needle = raw.trim().toLowerCase();
return ProductCategory.values.firstWhere(
(c) => c.name.toLowerCase() == needle || c.label.toLowerCase() == needle,
orElse: () => ProductCategory.grocery,
);
}
static UnitOfMeasure _unit(Object? raw) {
if (raw is! String) return UnitOfMeasure.piece;
final needle = raw.trim().toLowerCase();
return UnitOfMeasure.values.firstWhere(
(u) =>
u.name.toLowerCase() == needle ||
u.symbol.toLowerCase() == needle,
orElse: () => UnitOfMeasure.piece,
);
}
/// Accepts a fraction (0.18) or a percentage (18), because back offices
/// disagree about which they mean and getting it wrong silently changes the
/// tax on every line.
static double _gstRate(Object? raw) {
if (raw is! num) return AppConstants.defaultGstRate;
final value = raw.toDouble();
if (value < 0) return AppConstants.defaultGstRate;
return value > 1 ? value / 100 : value;
}
static DateTime? _date(Object? raw) {
if (raw == null) return null;
if (raw is num) return DateTime.fromMillisecondsSinceEpoch(raw.toInt());
if (raw is String) return DateTime.tryParse(raw);
return null;
}
}

View File

@@ -0,0 +1,213 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../core/config/sync_config.dart';
import '../../domain/entities/customer.dart';
import '../../domain/entities/product.dart';
import 'catalogue_source.dart';
import 'catalogue_wire.dart';
/// Pulls the catalogue from the back office over HTTP.
///
/// ```
/// GET {base}/catalogue?since={revision}&page={n}&page_size={pageSize}&store_id={storeId}
/// Authorization: Bearer {apiKey}
/// ```
///
/// Pages are 0-indexed — the first page requested is `page=0` — matching the
/// back office's own convention rather than the more common 1-indexed one.
///
/// ```json
/// {
/// "revision": "rev-8821",
/// "is_delta": true,
/// "has_more": false,
/// "products": [ … ],
/// "customers": [ … ],
/// "retired_product_ids": ["sku-9912"]
/// }
/// ```
///
/// ### Why paged
///
/// A supermarket catalogue is tens of thousands of rows. Asking for it in one
/// response means a request that times out on a shop's line and a JSON decode
/// that stalls the UI thread for seconds. Pages arrive, get counted, and the
/// progress bar moves — which is also the difference between a cashier waiting
/// and a cashier restarting the app.
///
/// ### Why deltas
///
/// `since` carries the revision the terminal already holds. The back office
/// answers with what has moved since, which on a normal morning is a handful of
/// price changes rather than the whole book. A server that cannot do deltas
/// ignores the parameter and answers `is_delta: false`; the terminal reads the
/// flag rather than assuming, so both work.
class HttpCatalogueSource implements CatalogueSource {
HttpCatalogueSource({required this.config, http.Client? client})
: _client = client ?? http.Client();
final SyncConfig config;
final http.Client _client;
/// Guards against a server that always answers `has_more: true`. Without it
/// a bad deployment turns into an infinite request loop against a shop's
/// connection.
static const int maxPages = 200;
/// Rows requested per page. Sent as `page_size` on every request so the
/// back office doesn't fall back to its own (smaller) default.
static const int pageSize = 500;
static const Duration _timeout = Duration(seconds: 30);
@override
String get label => 'HTTP ${config.httpBaseUrl}';
@override
Future<CatalogueSnapshot> fetch({
String? since,
void Function(double progress, String stage)? onProgress,
}) async {
if (config.httpBaseUrl.isEmpty) {
throw const CatalogueSyncException(
'No back-office URL is configured for this terminal. Set one in '
'Settings → Connectivity → Configure.',
retryable: false,
);
}
final products = <Product>[];
final customers = <Customer>[];
final retired = <String>[];
var revision = since ?? '';
var isDelta = false;
var page = 0;
onProgress?.call(0.05, 'Contacting the back office…');
while (page <= maxPages) {
final body = await _fetchPage(since: since, page: page);
revision = (body['revision'] as String?) ?? revision;
isDelta = body['is_delta'] as bool? ?? false;
products.addAll(
_decodeList(body['products'], CatalogueWire.productFromJson),
);
customers.addAll(
_decodeList(body['customers'], CatalogueWire.customerFromJson),
);
retired.addAll(
(body['retired_product_ids'] as List<Object?>? ?? const [])
.whereType<String>(),
);
final hasMore = body['has_more'] as bool? ?? false;
if (!hasMore) break;
page++;
// The total is unknown until the last page, so this walks towards 0.9
// instead of pretending to know how far along it is.
onProgress?.call(
(0.1 + page * 0.05).clamp(0.1, 0.9),
'${products.length} products…',
);
}
if (page > maxPages) {
throw const CatalogueSyncException(
'The back office kept asking for another page past $maxPages. '
'Stopping rather than looping — nothing was changed on this terminal.',
);
}
onProgress?.call(1, 'Writing to local storage…');
return CatalogueSnapshot(
products: products,
customers: customers,
fetchedAt: DateTime.now(),
revision: revision.isEmpty ? 'rev-unknown' : revision,
isDelta: isDelta,
retiredProductIds: retired,
);
}
Future<Map<String, Object?>> _fetchPage({
required String? since,
required int page,
}) async {
final uri = Uri.parse('${config.httpBaseUrl}/catalogue').replace(
queryParameters: {
if (since != null && since.isNotEmpty) 'since': since,
'page': '$page',
'page_size': '$pageSize',
'store_id': config.storeId,
'terminal_id': config.terminalId,
},
);
http.Response response;
try {
response = await _client.get(
uri,
headers: {
'accept': 'application/json',
if (config.apiKey != null) 'authorization': 'Bearer ${config.apiKey}',
},
).timeout(_timeout);
} on Exception catch (e) {
throw CatalogueSyncException('Could not reach the back office: $e');
}
if (response.statusCode == 401 || response.statusCode == 403) {
throw CatalogueSyncException(
'The back office rejected this terminal\'s credentials '
'(${response.statusCode}). The catalogue already on this terminal is '
'untouched, so billing continues.',
retryable: false,
);
}
if (response.statusCode >= 300) {
throw CatalogueSyncException(
'Back office returned ${response.statusCode}: ${_trim(response.body)}',
);
}
try {
return jsonDecode(response.body) as Map<String, Object?>;
} on Object {
throw CatalogueSyncException(
'The back office answered with something this terminal could not '
'read: ${_trim(response.body)}',
);
}
}
/// Decodes a list, letting one bad record fail the import rather than
/// silently vanish.
///
/// A dropped product is a shelf item that scans to nothing, which a cashier
/// discovers with a queue waiting. Better to refuse the import and leave the
/// working catalogue in place.
static List<T> _decodeList<T>(
Object? raw,
T Function(Map<String, Object?>) decode,
) {
if (raw is! List) return const [];
return raw
.whereType<Map<String, Object?>>()
.map(decode)
.toList(growable: false);
}
static String _trim(String body) =>
body.length <= 200 ? body : '${body.substring(0, 200)}';
@override
void dispose() => _client.close();
}

View File

@@ -0,0 +1,211 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:uuid/uuid.dart';
import '../../core/config/sync_config.dart';
import 'order_transport.dart';
/// Ships bills over plain HTTP.
///
/// No downlink and no persistent connection — but a failure is a status code,
/// a request is reproducible with curl, and there is no broker to run. Worth
/// having as the route to bring up first, and as the fallback when a broker is
/// unreachable but the internet is not.
///
/// ```
/// POST {base}/orders
/// { "schema": 1, "batch_id": "…", "store_id": "…", "terminal_id": "…",
/// "orders": [ … ] }
/// ```
///
/// The endpoint must answer with the ids it committed:
///
/// ```json
/// { "accepted": ["<order-id>", ...], "rejected": { "<order-id>": "reason" } }
/// ```
///
/// A bare `200 OK` is not treated as success for any bill. Silence about which
/// rows landed is not the same as landing them, and guessing here would retire
/// a day's takings on an empty response.
class HttpOrderTransport implements OrderTransport {
HttpOrderTransport({required this.config, http.Client? client})
: _client = client ?? http.Client();
final SyncConfig config;
final http.Client _client;
static const _uuid = Uuid();
final _connection = StreamController<bool>.broadcast();
bool _reachable = true;
@override
String get label => 'HTTP ${config.httpBaseUrl}';
@override
bool get isConnected => _reachable;
/// Nothing to receive: HTTP is a route out, not in.
@override
Stream<DownlinkMessage> get downlink => const Stream.empty();
@override
Stream<bool> get connectionState => _connection.stream;
@override
Future<void> connect() async {}
@override
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) =>
_post(path: 'orders', key: 'orders', items: orders);
@override
Future<PushReceipt> pushCustomers(List<Map<String, Object?>> customers) =>
_post(path: 'customers', key: 'customers', items: customers);
/// One heartbeat, posted to the back office.
///
/// Nothing is read back and nothing is retried. A heartbeat is only true for
/// the thirty seconds until the next one, so a failed beat is already stale
/// by the time a retry could land — the correct response is to let the board
/// go blank and say so with the next one.
///
/// Every failure is swallowed for the reason the whole reporter swallows
/// them: a till that cannot say how it is must still sell. Stopping a shop
/// because a dashboard was unreachable would be a self-inflicted outage.
@override
Future<void> publishHealth(String payload) async {
if (config.httpBaseUrl.isEmpty) return;
try {
await _client
.post(
Uri.parse('${config.httpBaseUrl}/health'),
headers: {
'content-type': 'application/json',
if (config.apiKey != null)
'authorization': 'Bearer ${config.apiKey}',
},
body: payload,
)
// Deliberately shorter than ackTimeout. A bill is worth waiting
// twenty seconds for; a heartbeat that takes that long would still
// be in flight when the next one is due.
.timeout(const Duration(seconds: 5));
} on Object {
// See above.
}
}
Future<PushReceipt> _post({
required String path,
required String key,
required List<Map<String, Object?>> items,
}) async {
if (items.isEmpty) return const PushReceipt(accepted: []);
if (config.httpBaseUrl.isEmpty) {
throw const TransportException(
'No back-office URL configured for this terminal.',
retryable: false,
);
}
final uri = Uri.parse('${config.httpBaseUrl}/$path');
// Deterministic from the set of ids in this batch — not a fresh random
// id per attempt — so a retry after a timeout (the same rows, because
// nothing was marked sent) carries the exact same batch_id as the
// attempt that may already have landed. That is what lets the back
// office collapse a retried batch server-side instead of re-billing it.
final batchId = _uuid.v5(
Uuid.NAMESPACE_URL,
items.map((o) => o['id']).join('|'),
);
http.Response response;
try {
response = await _client
.post(
uri,
headers: {
'content-type': 'application/json',
if (config.apiKey != null)
'authorization': 'Bearer ${config.apiKey}',
'idempotency-key': batchId,
},
body: jsonEncode({
'schema': 1,
'batch_id': batchId,
'store_id': config.storeId,
'terminal_id': config.terminalId,
key: items,
}),
)
.timeout(config.ackTimeout);
} on Exception catch (e) {
_setReachable(false);
throw TransportException('Upload failed: $e');
}
if (response.statusCode == 401 || response.statusCode == 403) {
_setReachable(false);
throw TransportException(
'The back office rejected this terminal\'s credentials '
'(${response.statusCode}). Bills are safe locally, but sync will keep '
'failing until the terminal is re-authorised.',
retryable: false,
);
}
if (response.statusCode >= 300) {
_setReachable(false);
throw TransportException(
'Back office returned ${response.statusCode}: '
'${_trim(response.body)}',
);
}
_setReachable(true);
Map<String, Object?> body;
try {
body = jsonDecode(response.body) as Map<String, Object?>;
} on Exception {
throw TransportException(
'Back office answered 200 with a body this terminal could not read, '
'so no bill was marked synced: ${_trim(response.body)}',
);
}
final accepted = (body['accepted'] as List<Object?>? ?? const [])
.whereType<String>()
.toList();
final rejected = <String, String>{};
final raw = body['rejected'];
if (raw is Map) {
raw.forEach((k, v) => rejected['$k'] = '$v');
}
return PushReceipt(accepted: accepted, rejected: rejected);
}
void _setReachable(bool value) {
if (_reachable == value) return;
_reachable = value;
_connection.add(value);
}
static String _trim(String body) =>
body.length <= 200 ? body : '${body.substring(0, 200)}';
@override
Future<void> dispose() async {
_client.close();
await _connection.close();
}
}

View File

@@ -0,0 +1,383 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:mqtt_client/mqtt_client.dart';
import 'package:mqtt_client/mqtt_server_client.dart';
import 'package:uuid/uuid.dart';
import '../../core/config/sync_config.dart';
import 'order_transport.dart';
/// Ships bills over MQTT and listens for what head office pushes back.
///
/// ### Why the broker's acknowledgement is not enough
///
/// QoS 1 gives a PUBACK from the *broker*, meaning "I hold these bytes". It
/// says nothing about whether the back office parsed the batch, or whether the
/// ledger accepted it. Marking bills synced on PUBACK would retire a day's
/// takings on the word of a message queue.
///
/// So every publish carries a `batch_id` and this waits for the back office to
/// answer on [SyncConfig.ackTopic] naming the ids it actually committed. No
/// answer means no sync, and the bills are sent again.
///
/// ### Duplicates are expected
///
/// QoS 1 is at-least-once, and a batch whose ack is lost will be re-sent in
/// full. The back office must key on `order.id` and upsert. Every id is a UUID
/// minted at the till, so this costs the server one unique index.
class MqttOrderTransport implements OrderTransport {
MqttOrderTransport({
required this.config,
MqttClient Function(SyncConfig)? clientFactory,
}) : _clientFactory = clientFactory ?? _defaultClient;
final SyncConfig config;
final MqttClient Function(SyncConfig) _clientFactory;
static const _uuid = Uuid();
MqttClient? _client;
/// Batches published but not yet answered, keyed by `batch_id`.
final _awaitingAck = <String, Completer<PushReceipt>>{};
final _downlink = StreamController<DownlinkMessage>.broadcast();
final _connection = StreamController<bool>.broadcast();
/// Guards against two drains racing to open the same connection.
Future<void>? _connecting;
StreamSubscription<List<MqttReceivedMessage<MqttMessage>>>? _updates;
static MqttClient _defaultClient(SyncConfig config) {
final client = MqttServerClient.withPort(
config.brokerHost,
config.clientId,
config.brokerPort,
);
client.secure = config.useTls;
// Well under the shortest NAT timeout a shop router is likely to impose;
// a connection that dies silently is worse than one that pings.
client.keepAlivePeriod = 20;
client.autoReconnect = true;
client.resubscribeOnAutoReconnect = true;
client.logging(on: false);
return client;
}
@override
String get label => 'MQTT ${config.brokerHost}:${config.brokerPort}';
@override
bool get isConnected =>
_client?.connectionStatus?.state == MqttConnectionState.connected;
@override
Stream<DownlinkMessage> get downlink => _downlink.stream;
@override
Stream<bool> get connectionState => _connection.stream;
// ------------------------------------------------------------- Connection
@override
Future<void> connect() {
if (isConnected) return Future.value();
return _connecting ??= _doConnect().whenComplete(() => _connecting = null);
}
Future<void> _doConnect() async {
if (config.brokerHost.isEmpty) {
throw const TransportException(
'No MQTT broker configured for this terminal.',
retryable: false,
);
}
final client = _client ??= _clientFactory(config);
client.onDisconnected = () {
_connection.add(false);
// Nobody is coming to answer these. Failing them now returns the bills
// to pending immediately instead of holding the drain for the full ack
// timeout on a connection that is already gone.
_failAllAwaiting('Connection to the broker dropped.');
};
client.onConnected = () => _connection.add(true);
// Retained, so head office sees this terminal's last known state even if
// its dashboard connects hours later. The broker publishes it on our
// behalf if the till loses power mid-shift — which is the only way to tell
// "closed for the night" from "unplugged".
client.connectionMessage = MqttConnectMessage()
.withClientIdentifier(config.clientId)
.withWillTopic(config.statusTopic)
.withWillMessage(jsonEncode({'state': 'offline'}))
.withWillQos(MqttQos.atLeastOnce)
.withWillRetain();
try {
await client.connect(config.username, config.password);
} on Exception catch (e) {
client.disconnect();
throw TransportException('Could not reach the broker: $e');
}
if (client.connectionStatus?.state != MqttConnectionState.connected) {
final status = client.connectionStatus;
client.disconnect();
throw TransportException(
'Broker refused the connection: '
'${status?.returnCode?.name ?? 'unknown'}',
// Bad credentials or an unauthorised client id will be refused just as
// firmly on the next attempt; backing off forever would only hide it.
retryable: status?.returnCode != MqttConnectReturnCode.notAuthorized,
);
}
client
..subscribe(config.ackTopic, MqttQos.atLeastOnce)
..subscribe(config.catalogueTopic, MqttQos.atLeastOnce)
..subscribe(config.commandTopic, MqttQos.atLeastOnce);
await _updates?.cancel();
_updates = client.updates?.listen(_onUpdates);
_publish(
config.statusTopic,
jsonEncode({
'state': 'online',
'terminal_id': config.terminalId,
'at': DateTime.now().toIso8601String(),
}),
retain: true,
);
_connection.add(true);
}
void _onUpdates(List<MqttReceivedMessage<MqttMessage>> events) {
for (final event in events) {
final message = event.payload;
if (message is! MqttPublishMessage) continue;
handleInbound(
event.topic,
MqttPublishPayload.bytesToStringAsString(message.payload.message),
);
}
}
// ----------------------------------------------------------------- Uplink
@override
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) =>
_publishBatch(
topic: config.orderTopic,
key: 'orders',
items: orders,
noun: 'bills',
);
@override
Future<PushReceipt> pushCustomers(List<Map<String, Object?>> customers) =>
_publishBatch(
topic: config.customerTopic,
key: 'customers',
items: customers,
noun: 'registrations',
);
/// Publishes one correlated batch and waits for the back office to answer it.
///
/// Shared by both uplinks because the rule they must obey is the same one,
/// and it is the rule the whole design rests on: a batch counts as delivered
/// only when the *application* names its ids, never when the broker
/// acknowledges the bytes.
Future<PushReceipt> _publishBatch({
required String topic,
required String key,
required List<Map<String, Object?>> items,
required String noun,
}) async {
if (items.isEmpty) return const PushReceipt(accepted: []);
await connect();
final batchId = _uuid.v4();
final completer = Completer<PushReceipt>();
_awaitingAck[batchId] = completer;
try {
_publish(
topic,
jsonEncode({
'schema': 1,
'batch_id': batchId,
'store_id': config.storeId,
'terminal_id': config.terminalId,
'sent_at': DateTime.now().toIso8601String(),
key: items,
}),
);
return await completer.future.timeout(
config.ackTimeout,
onTimeout: () => throw TransportException(
'The back office did not confirm the batch within '
'${config.ackTimeout.inSeconds}s. The $noun are still on this '
'terminal and will be sent again.',
),
);
} finally {
_awaitingAck.remove(batchId);
}
}
void _publish(String topic, String payload, {bool retain = false}) {
final builder = MqttClientPayloadBuilder()..addString(payload);
try {
_client!.publishMessage(
topic,
MqttQos.atLeastOnce,
builder.payload!,
retain: retain,
);
} on Exception catch (e) {
throw TransportException('Publish to $topic failed: $e');
}
}
// --------------------------------------------------------------- Downlink
/// Publishes a presence record on the status topic, retained.
///
/// Retained so a dashboard connecting hours later still learns every
/// terminal's last known state, rather than showing a blank board until each
/// one happens to tick.
Future<void> publishStatus(String payload) async {
if (!isConnected) return;
_publish(config.statusTopic, payload, retain: true);
}
/// Publishes a heartbeat, deliberately *not* retained.
///
/// The back office holds these in Redis under a TTL, so a terminal that
/// stops beating ages off the board by itself. A retained heartbeat would
/// survive on the broker after the till was unplugged and keep it looking
/// alive until something happened to overwrite it — which is exactly the
/// failure a health board exists to catch.
@override
Future<void> publishHealth(String payload) async {
if (!isConnected) return;
_publish(config.healthTopic, payload);
}
/// Registers a batch as awaiting its ack, without publishing one.
///
/// Lets a test drive the correlation rules — which is where the logic that
/// decides whether a bill counts as banked actually lives — without standing
/// up a broker.
@visibleForTesting
Future<PushReceipt> awaitAck(String batchId) {
final completer = Completer<PushReceipt>();
_awaitingAck[batchId] = completer;
return completer.future;
}
/// Routes one inbound message. Separated from the client so the correlation
/// and parsing rules can be tested without a broker.
@visibleForTesting
void handleInbound(String topic, String payload) {
Map<String, Object?> body;
try {
body = jsonDecode(payload) as Map<String, Object?>;
} on FormatException {
// A malformed message must not take down the connection: the next one
// may be a perfectly good ack releasing a day's bills.
debugPrint('Discarded unparseable MQTT message on $topic');
return;
}
if (topic == config.ackTopic) {
_resolveAck(body);
return;
}
if (topic == config.catalogueTopic) {
_downlink.add(
DownlinkMessage(kind: DownlinkKind.catalogueChanged, payload: body),
);
return;
}
if (topic == config.commandTopic) {
final command = body['command'] as String?;
_downlink.add(
DownlinkMessage(
kind: command == 'sync'
? DownlinkKind.syncRequested
: DownlinkKind.unknown,
payload: body,
),
);
}
}
void _resolveAck(Map<String, Object?> body) {
final batchId = body['batch_id'] as String?;
if (batchId == null) return;
// An ack for a batch we are no longer waiting on — we timed out, or the
// terminal restarted. Harmless: those bills are still pending and will go
// up again, and the back office keys on order id.
final completer = _awaitingAck.remove(batchId);
if (completer == null || completer.isCompleted) return;
final accepted = (body['accepted'] as List<Object?>? ?? const [])
.whereType<String>()
.toList();
final rejected = <String, String>{};
final raw = body['rejected'];
if (raw is Map) {
raw.forEach((k, v) => rejected['$k'] = '$v');
} else if (raw is List) {
// Tolerates a back office that sends bare ids with no reason.
for (final id in raw.whereType<String>()) {
rejected[id] = 'Rejected by the back office';
}
}
completer.complete(PushReceipt(accepted: accepted, rejected: rejected));
}
void _failAllAwaiting(String reason) {
final waiting = List.of(_awaitingAck.values);
_awaitingAck.clear();
for (final c in waiting) {
if (!c.isCompleted) c.completeError(TransportException(reason));
}
}
@override
Future<void> dispose() async {
_failAllAwaiting('The terminal is shutting down.');
await _updates?.cancel();
if (isConnected) {
// A clean goodbye, so head office does not see a till it thinks crashed.
try {
_publish(
config.statusTopic,
jsonEncode({'state': 'offline', 'clean': true}),
retain: true,
);
} on TransportException {
// Already going down; nothing useful to do about it.
}
}
_client?.disconnect();
await _downlink.close();
await _connection.close();
}
}

View File

@@ -0,0 +1,123 @@
import 'dart:async';
/// Raised when a batch could not be handed to the back office.
///
/// Distinct from *rejection*: a transport failure means nobody knows whether
/// the bills arrived, so every row stays pending and is tried again. A
/// rejection means the back office looked at a bill and refused it, which
/// retrying will not fix.
class TransportException implements Exception {
const TransportException(this.message, {this.retryable = true});
final String message;
/// Whether trying again could plausibly succeed. A dropped connection is
/// retryable; a rejected certificate or a bad credential is not, and the
/// engine should stop rather than hammer the broker.
final bool retryable;
@override
String toString() => message;
}
/// What the back office said about one batch.
///
/// [accepted] is the contract that matters: only ids named here are marked
/// synced. Anything absent stays pending, whatever the transport reported at
/// its own layer.
class PushReceipt {
const PushReceipt({required this.accepted, this.rejected = const {}});
final List<String> accepted;
/// Order id → why the back office refused it. Retrying these unchanged will
/// fail again, so the engine surfaces them instead of looping.
final Map<String, String> rejected;
bool get isEmpty => accepted.isEmpty && rejected.isEmpty;
}
/// Something the cloud pushed down to this terminal.
///
/// Only a transport with a live connection can deliver these; request/response
/// transports expose an empty stream.
class DownlinkMessage {
const DownlinkMessage({required this.kind, this.payload = const {}});
final DownlinkKind kind;
final Map<String, Object?> payload;
}
enum DownlinkKind {
/// The catalogue changed at head office — re-import rather than wait for
/// tomorrow morning's pull.
catalogueChanged,
/// Head office is asking this terminal to upload now.
syncRequested,
/// Anything this build does not recognise. Kept rather than dropped so a
/// newer server talking to an older terminal is visible in the events log
/// instead of silently ignored.
unknown,
}
/// How completed bills leave the terminal.
///
/// The drain engine owns *when* to send and what to do when sending fails;
/// this owns only the wire. Swapping HTTP for MQTT is a change of
/// implementation here and nothing else.
abstract class OrderTransport {
/// Shown in the events log so a cashier reporting a problem can say which
/// route the terminal was using.
String get label;
/// Opens the connection. Safe to call when already open.
///
/// A request/response transport has nothing to open and returns at once.
Future<void> connect();
/// Hands a batch over and reports what the back office committed.
///
/// Implementations must not report an id as accepted until the *application*
/// has confirmed it. A broker acknowledging receipt of the bytes is not the
/// back office confirming the sale.
///
/// Throws [TransportException] when the outcome is unknown.
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders);
/// Hands over shoppers registered at this till.
///
/// Same acceptance contract as [pushOrders] — only ids the back office names
/// are marked sent — but the payload is a registration rather than a
/// financial record, so the back office is expected to treat it as
/// insert-if-absent on id. Replaying one it already holds must be a no-op,
/// never an overwrite of a profile corrected at head office.
///
/// Throws [TransportException] when the outcome is unknown.
Future<PushReceipt> pushCustomers(List<Map<String, Object?>> customers);
/// Cloud-initiated messages. Empty for transports that cannot receive.
Stream<DownlinkMessage> get downlink;
/// Whether the route is currently usable. Drives the header's live pill and
/// wakes the drain engine when it flips to true.
Stream<bool> get connectionState;
bool get isConnected;
/// Sends one heartbeat, and never throws.
///
/// On the interface rather than on the broker transport alone, because it was
/// on the broker transport alone and that was the bug: the reporter was
/// started behind an `is MqttOrderTransport` check, so a shop on the HTTP
/// route uploaded every bill correctly and never once appeared on the fleet
/// board. Nothing logged it, because nothing had gone wrong — the feature
/// simply did not exist on that route.
///
/// A transport with nowhere to send it does nothing. That is a real answer,
/// not a stub: the simulated route has no back office to tell.
Future<void> publishHealth(String payload);
Future<void> dispose();
}

View File

@@ -0,0 +1,163 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../domain/entities/pos_session.dart';
/// A sign-in that did not produce a session.
///
/// Carries a message written for the person at the counter, not a status code.
/// [isCredentialFailure] separates "you typed the wrong password" from "the
/// shop's internet is down", because the first is the operator's problem to
/// fix and the second is not.
class AuthApiException implements Exception {
const AuthApiException(
this.message, {
this.isCredentialFailure = false,
this.statusCode,
});
final String message;
final bool isCredentialFailure;
final int? statusCode;
@override
String toString() => message;
}
/// Signs the terminal in against the back office.
///
/// ```
/// POST {base}/login
/// Content-Type: application/json
///
/// { "authname": …, "password": …, "device_id": …, "configid": 1 }
/// ```
///
/// answering
///
/// ```json
/// { "code": 200, "status": true, "message": "Login successful",
/// "details": { "token": …, "role": "Supervisor", … } }
/// ```
///
/// The envelope is checked rather than the HTTP status alone: this API answers
/// `200` with `status: false` for a rejected credential, so trusting the
/// status code would sign a terminal in on a failed login.
class PosAuthApi {
PosAuthApi({
required this.baseUrl,
this.configId = 1,
http.Client? client,
}) : _client = client ?? http.Client();
/// Same base as the catalogue and order endpoints, e.g.
/// `https://fiesta.nearle.app/live/api/v1/pos`.
final String baseUrl;
/// Which back-office configuration this terminal belongs to.
final int configId;
final http.Client _client;
static const Duration _timeout = Duration(seconds: 20);
Future<PosSession> login({
required String authname,
required String password,
required String deviceId,
}) async {
if (baseUrl.isEmpty) {
throw const AuthApiException(
'No back-office URL is configured for this terminal. Set one in '
'Settings → Connectivity & sync → Configure.',
);
}
final uri = Uri.parse('${baseUrl.replaceAll(RegExp(r'/+$'), '')}/login');
http.Response response;
try {
response = await _client
.post(
uri,
headers: const {
'content-type': 'application/json',
'accept': 'application/json',
},
body: jsonEncode({
'authname': authname.trim(),
'password': password,
// This device's own identity, minted on first run. Two terminals
// must never sign in as the same device — the back office keys
// sessions on it.
'device_id': deviceId,
'configid': configId,
}),
)
.timeout(_timeout);
} on TimeoutException {
throw const AuthApiException(
'The back office did not answer in time. Check the connection and '
'try again.',
);
} on http.ClientException {
// DNS failure, refused connection, dropped socket — the shop's line
// rather than the operator's credentials.
throw const AuthApiException(
'Could not reach the back office. Check this terminal\'s internet '
'connection.',
);
} on Exception catch (e) {
throw AuthApiException('Could not reach the back office: $e');
}
Map<String, Object?> body;
try {
body = jsonDecode(response.body) as Map<String, Object?>;
} on Object {
throw AuthApiException(
'The back office answered with something this terminal could not '
'read (${response.statusCode}).',
statusCode: response.statusCode,
);
}
final ok = body['status'] == true && response.statusCode < 300;
if (!ok) {
final raw = body['message'];
final message = raw is String ? raw.trim() : '';
const rejectedCodes = {400, 401, 403, 422};
throw AuthApiException(
// The server's own wording, when it gave one. It knows whether the
// account is disabled, the device is unregistered or the password is
// simply wrong, and a generic message would throw that away.
message.isEmpty ? 'Sign-in failed (${response.statusCode}).' : message,
isCredentialFailure: rejectedCodes.contains(response.statusCode),
statusCode: response.statusCode,
);
}
final details = body['details'];
if (details is! Map<String, Object?>) {
throw const AuthApiException(
'The back office accepted the sign-in but sent no session back.',
);
}
final session = PosSession.fromDetails(details, authname: authname.trim());
if (session.token.isEmpty) {
throw const AuthApiException(
'The back office accepted the sign-in but issued no token.',
);
}
return session;
}
void dispose() => _client.close();
}

View File

@@ -0,0 +1,55 @@
import '../datasources/seed_data.dart';
import 'catalogue_source.dart';
/// Stands in for the back office when no endpoint is configured.
///
/// Keeps a fresh install demonstrable: products exist, a shift can be rehearsed
/// end to end, and the offline switch in Settings fails the pull exactly the
/// way a dead line would.
class SimulatedCatalogueSource implements CatalogueSource {
const SimulatedCatalogueSource({required this.isOffline});
/// Read on every call rather than copied, so the Settings switch takes effect
/// immediately instead of at the next restart.
final bool Function() isOffline;
@override
String get label => 'Simulated';
@override
Future<CatalogueSnapshot> fetch({
String? since,
void Function(double progress, String stage)? onProgress,
}) async {
const stages = [
(0.15, 'Contacting server…'),
(0.35, 'Authorising terminal…'),
(0.60, 'Downloading products…'),
(0.85, 'Downloading customers…'),
(1.00, 'Writing to local storage…'),
];
for (final (progress, stage) in stages) {
await Future<void>.delayed(const Duration(milliseconds: 320));
if (isOffline()) {
throw const CatalogueSyncException(
'Simulate offline is ON in Settings, so the catalogue pull was '
'failed on purpose. Turn it off to import.',
);
}
onProgress?.call(progress, stage);
}
return CatalogueSnapshot(
products: SeedData.products(),
customers: SeedData.customers(),
fetchedAt: DateTime.now(),
revision: 'rev-${DateTime.now().millisecondsSinceEpoch % 100000}',
);
}
@override
void dispose() {}
}

View File

@@ -0,0 +1,77 @@
import 'dart:async';
import 'order_transport.dart';
/// Stands in for the back office when no broker or endpoint is configured.
///
/// Keeps the terminal demonstrable on a bare laptop: bills queue, drain, and
/// respect the Settings offline switch exactly as they would against a real
/// server, so the drain engine can be exercised without one.
class SimulatedOrderTransport implements OrderTransport {
SimulatedOrderTransport({required this.isOffline});
/// Read on every call rather than copied, so the Settings switch takes effect
/// immediately instead of at the next restart.
final bool Function() isOffline;
final _downlink = StreamController<DownlinkMessage>.broadcast();
final _connection = StreamController<bool>.broadcast();
@override
String get label => 'Simulated';
@override
bool get isConnected => !isOffline();
@override
Stream<DownlinkMessage> get downlink => _downlink.stream;
@override
Stream<bool> get connectionState => _connection.stream;
@override
Future<void> connect() async {}
/// Nowhere to send it. A fresh install has no back office configured, and
/// inventing a destination would only hide that.
@override
Future<void> publishHealth(String payload) async {}
@override
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) =>
_accept(orders, 'bill');
@override
Future<PushReceipt> pushCustomers(List<Map<String, Object?>> customers) =>
_accept(customers, 'registration');
Future<PushReceipt> _accept(
List<Map<String, Object?>> items,
String noun,
) async {
await Future<void>.delayed(
Duration(milliseconds: 400 + items.length * 60),
);
if (isOffline()) {
throw TransportException(
'Simulate offline is ON in Settings, so the upload was failed on '
'purpose. Every $noun is still stored on this terminal.',
);
}
return PushReceipt(
accepted: items.map((o) => o['id']! as String).toList(),
);
}
/// Lets the events screen prove the downlink path end to end without a
/// broker.
void emit(DownlinkMessage message) => _downlink.add(message);
@override
Future<void> dispose() async {
await _downlink.close();
await _connection.close();
}
}

View File

@@ -1,5 +1,3 @@
import 'package:uuid/uuid.dart';
import '../../core/utils/extensions.dart';
import '../../domain/entities/customer.dart';
import '../../domain/repositories/customer_repository.dart';
@@ -9,15 +7,15 @@ class CustomerRepositoryImpl implements CustomerRepository {
CustomerRepositoryImpl(this._store);
final LocalStore _store;
static const _uuid = Uuid();
String _digits(String v) => v.replaceAll(RegExp(r'\D'), '');
@override
Future<Customer?> findByMobile(String mobile) async {
final needle = _digits(mobile);
return _store.customers
.firstWhereOrNull((c) => _digits(c.mobile) == needle);
// Normalised on both sides, so a shopper stored from `9840012345` is still
// found when a cashier at the next till types `+91 98400 12345`.
final needle = Customer.normaliseMobile(mobile);
return _store.customers.firstWhereOrNull(
(c) => Customer.normaliseMobile(c.mobile) == needle,
);
}
@override
@@ -30,9 +28,14 @@ class CustomerRepositoryImpl implements CustomerRepository {
throw StateError('A customer with this mobile number already exists.');
}
final created = Customer(
id: _uuid.v4(),
// Derived from the number, not random — see [Customer.idForMobile].
// Two tills registering the same shopper independently produce the same
// row rather than a duplicate the back office has to reconcile.
id: Customer.idForMobile(customer.mobile),
name: customer.name.trim(),
mobile: _digits(customer.mobile),
// Stored normalised, so the unique index on `mobile` actually catches a
// second attempt to register the same shopper.
mobile: Customer.normaliseMobile(customer.mobile),
email: customer.email?.trim().isEmpty ?? true
? null
: customer.email!.trim(),
@@ -60,11 +63,15 @@ class CustomerRepositoryImpl implements CustomerRepository {
// Stored numbers are digits only, so the query has to be reduced the same
// way — otherwise a cashier typing "98765 43210" or "98-76" matches nothing.
final digits = _digits(q);
//
// Raw digits rather than the normalised form on purpose: this is a partial
// match on whatever has been typed so far, and a half-entered number is not
// a number to be normalised.
final digits = Customer.digitsOf(q);
return _store.customers.where((c) {
if (c.name.toLowerCase().contains(q)) return true;
return digits.isNotEmpty && _digits(c.mobile).contains(digits);
return digits.isNotEmpty && Customer.digitsOf(c.mobile).contains(digits);
}).toList();
}

View File

@@ -0,0 +1,77 @@
import '../../core/constants/app_constants.dart';
import '../../domain/entities/store_account.dart';
import '../datasources/local_store.dart';
import '../local/app_database.dart';
/// The outlet this terminal belongs to, read from and written to its own
/// database.
///
/// Store details were compile-time constants, so the name, address and GSTIN
/// printed on every invoice could only be changed by rebuilding the app. On a
/// GST invoice those fields are a legal requirement, not decoration.
class StoreRepositoryImpl {
const StoreRepositoryImpl(this._store);
final LocalStore _store;
Future<StoreAccount> load({required String email}) async {
final catalogue = _store.catalogue;
// Seeded from the build's constants the first time, then owned by the
// database. Falling back to the constant on every read would silently undo
// an edit that failed to save.
return StoreAccount(
id: await catalogue.meta(MetaKeys.storeId) ?? 'store-001',
name: await catalogue.meta(MetaKeys.storeName) ?? AppConstants.storeName,
email: email,
address: await catalogue.meta(MetaKeys.storeAddress) ??
AppConstants.storeAddress,
gstin:
await catalogue.meta(MetaKeys.storeGstin) ?? AppConstants.storeGstin,
phone:
await catalogue.meta(MetaKeys.storePhone) ?? AppConstants.storePhone,
staff: await _store.staff.all(),
plan: await catalogue.meta(MetaKeys.storePlan) ?? 'Business',
);
}
Future<void> save({
required String name,
required String address,
required String gstin,
required String phone,
}) async {
final catalogue = _store.catalogue;
await catalogue.setMeta(MetaKeys.storeName, name.trim());
await catalogue.setMeta(MetaKeys.storeAddress, address.trim());
await catalogue.setMeta(MetaKeys.storeGstin, gstin.trim().toUpperCase());
await catalogue.setMeta(MetaKeys.storePhone, phone.trim());
}
}
/// Checks a GSTIN's shape.
///
/// Not a lookup against the GST portal — this only catches a typo before it is
/// printed on a few hundred invoices. Format is 2 state digits, a 10-character
/// PAN, an entity digit, a literal Z, and a checksum character.
class GstinValidator {
const GstinValidator._();
static final RegExp _pattern = RegExp(
r'^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$',
);
/// Returns an error message, or null when the GSTIN looks well formed.
static String? validate(String? value) {
final text = (value ?? '').trim().toUpperCase();
if (text.isEmpty) return 'A GSTIN is required on a tax invoice.';
if (text.length != 15) return 'A GSTIN is exactly 15 characters.';
if (!_pattern.hasMatch(text)) return 'That is not a valid GSTIN format.';
final stateCode = int.parse(text.substring(0, 2));
if (stateCode < 1 || stateCode > 38) {
return 'The first two digits are not a valid state code.';
}
return null;
}
}

View File

@@ -3,20 +3,32 @@ import 'dart:convert';
import 'package:uuid/uuid.dart';
import '../../core/utils/formatters.dart';
import '../../domain/entities/customer.dart';
import '../../domain/entities/shift_report.dart';
import '../../domain/entities/sync_event.dart';
import '../../domain/entities/transaction.dart';
import '../../domain/repositories/sync_repository.dart';
import '../datasources/local_store.dart';
import '../datasources/remote_catalogue_source.dart';
import '../local/order_dao.dart';
import '../remote/catalogue_source.dart';
import '../remote/order_transport.dart';
class SyncRepositoryImpl implements SyncRepository {
SyncRepositoryImpl(this._store, this._catalogue, this._orderSink);
SyncRepositoryImpl(
this._store,
this._catalogue,
this._transport, {
this.batchSize = 50,
});
final LocalStore _store;
final RemoteCatalogueSource _catalogue;
final RemoteOrderSink _orderSink;
final CatalogueSource _catalogue;
final OrderTransport _transport;
/// Bills per publish. Kept modest because an MQTT broker will refuse an
/// oversized message outright, and a terminal that has been offline for a
/// day can easily hold hundreds of bills.
final int batchSize;
static const _uuid = Uuid();
@@ -46,13 +58,21 @@ class SyncRepositoryImpl implements SyncRepository {
final started = DateTime.now();
try {
final snapshot = await _catalogue.fetch(onProgress: onProgress);
// Carries the revision this terminal already holds, so the back office
// can answer with just what has moved. On a normal morning that is a
// handful of price changes rather than the whole book.
final snapshot = await _catalogue.fetch(
since: _store.catalogueRevision,
onProgress: onProgress,
);
await _store.importCatalogue(
products: snapshot.products,
customers: snapshot.customers,
revision: snapshot.revision,
at: snapshot.fetchedAt,
isDelta: snapshot.isDelta,
retiredProductIds: snapshot.retiredProductIds,
);
final event = SyncEvent(
@@ -61,12 +81,20 @@ class SyncRepositoryImpl implements SyncRepository {
status: SyncStatus.synced,
createdAt: started,
syncedAt: DateTime.now(),
summary: '${snapshot.products.length} products saved to SQLite '
summary: snapshot.isDelta
? (snapshot.isEmpty
? 'Already up to date · ${snapshot.revision}'
: '${snapshot.changeCount} changes applied '
'· ${snapshot.revision}')
: '${snapshot.products.length} products saved to SQLite '
'· ${snapshot.revision}',
payload: {
'products': snapshot.products.length,
'customers': snapshot.customers.length,
'retired': snapshot.retiredProductIds.length,
'delta': snapshot.isDelta,
'revision': snapshot.revision,
'via': _catalogue.label,
},
attempts: 1,
);
@@ -150,6 +178,7 @@ class SyncRepositoryImpl implements SyncRepository {
createdAt:
DateTime.fromMillisecondsSinceEpoch(r['created_at']! as int),
isSynced: (r['sync_status']! as int) == OrderDao.synced,
itemCount: (r['item_count'] as num?)?.toDouble() ?? 0,
syncedAt: r['synced_at'] == null
? null
: DateTime.fromMillisecondsSinceEpoch(r['synced_at']! as int),
@@ -176,13 +205,14 @@ class SyncRepositoryImpl implements SyncRepository {
var attempted = 0;
var uploaded = 0;
var refused = 0;
final syncedInvoices = <String>[];
// `unsynced()` returns a bounded page. Draining it in a loop means a day
// with more bills than one page still uploads completely, instead of
// reporting success with the remainder silently left behind.
while (true) {
final batch = await _store.orders.unsynced();
final batch = await _store.orders.unsynced(limit: batchSize);
if (batch.isEmpty) break;
final ids = batch.map((o) => o.id).toList();
@@ -193,36 +223,19 @@ class SyncRepositoryImpl implements SyncRepository {
'Uploading $attempted of $total bills…',
);
PushReceipt receipt;
try {
final accepted = await _orderSink.pushOrders(
receipt = await _transport.pushOrders(
batch.map(_orderToPayload).toList(),
);
// Only what the server confirmed is archived and removed. Anything it
// did not acknowledge stays on disk.
final acceptedOrders =
batch.where((o) => accepted.contains(o.id)).toList();
await _store.orders.archiveAndDelete(acceptedOrders);
await _store.refreshUnsyncedCount();
uploaded += acceptedOrders.length;
syncedInvoices.addAll(acceptedOrders.map((o) => o.invoiceNumber));
final rejected = ids.where((id) => !accepted.contains(id)).toList();
if (rejected.isNotEmpty) {
await _store.orders.markFailed(rejected, 'Rejected by server');
// Rejected rows stay pending, so the next page would return the same
// bills forever. Stop and let the cashier retry.
break;
}
} catch (e) {
// Transport failed: record the attempt but leave every row at 0.
} on Object catch (e) {
// The outcome is unknown, so nothing is marked sent. The attempt is
// recorded against the rows and every one of them stays at 0.
await _store.orders.markFailed(ids, e.toString());
await _store.refreshUnsyncedCount();
final remaining = await _store.orders.unsyncedCount();
final pendingValue =
batch.fold<double>(0, (s, o) => s + o.total);
final pendingValue = batch.fold<double>(0, (s, o) => s + o.total);
await _log(SyncEvent(
id: _uuid.v4(),
@@ -238,35 +251,199 @@ class SyncRepositoryImpl implements SyncRepository {
return SyncOutcome(
attempted: attempted,
uploaded: uploaded,
rejected: refused,
error: e.toString(),
isRetryable: e is! TransportException || e.retryable,
);
}
// Only what the back office named is archived and marked synced.
// Anything it stayed silent about is left pending — silence is not
// acceptance, whatever the transport reported at its own layer.
final acceptedIds = receipt.accepted.toSet();
final acceptedOrders =
batch.where((o) => acceptedIds.contains(o.id)).toList();
await _store.orders.archiveAccepted(acceptedOrders);
await _store.refreshUnsyncedCount();
uploaded += acceptedOrders.length;
syncedInvoices.addAll(acceptedOrders.map((o) => o.invoiceNumber));
final unconfirmed = ids.where((id) => !acceptedIds.contains(id)).toList();
if (unconfirmed.isNotEmpty) {
for (final id in unconfirmed) {
await _store.orders.markFailed(
[id],
receipt.rejected[id] ?? 'Not confirmed by the back office',
);
}
refused += unconfirmed.length;
// These rows are still pending, so the next page would hand back the
// same bills forever. Stop, and let a person look at why.
final reasons = receipt.rejected.values.toSet().join('; ');
await _log(SyncEvent(
id: _uuid.v4(),
type: SyncEventType.shiftReport,
status: SyncStatus.failed,
createdAt: started,
summary: '$uploaded uploaded, ${unconfirmed.length} refused',
error: reasons.isEmpty ? 'Not confirmed by the back office' : reasons,
attempts: 1,
),);
return SyncOutcome(
attempted: attempted,
uploaded: uploaded,
rejected: refused,
error: '${unconfirmed.length} bill(s) were not accepted'
'${reasons.isEmpty ? '' : ': $reasons'}',
// A refusal is a decision, not a fault. Retrying the same bytes gets
// the same answer, so the engine halts instead of looping.
isRetryable: false,
);
}
}
onProgress?.call(0.95, 'Tidying up…');
await purgeExpired();
onProgress?.call(1, 'Done');
// Synced bills are deleted from the terminal, so this log line is the only
// remaining record on the device that they went up.
// Once the retention window closes this log line is the only remaining
// record on the device that these bills went up.
await _log(SyncEvent(
id: _uuid.v4(),
type: SyncEventType.shiftReport,
status: SyncStatus.synced,
createdAt: started,
syncedAt: DateTime.now(),
summary: '$uploaded of $attempted bills uploaded',
summary: '$uploaded of $attempted bills uploaded '
'via ${_transport.label}',
payload: {'invoices': syncedInvoices},
attempts: 1,
),);
return SyncOutcome(
attempted: attempted,
uploaded: uploaded,
rejected: refused,
);
}
@override
Future<int> purgeExpired() => _store.orders.purgeSyncedBefore(
DateTime.now().subtract(OrderDao.retentionWindow),
);
// ------------------------------------------------- Registrations: uplink
@override
Future<int> unsyncedCustomerCount() =>
_store.catalogue.unsyncedCustomerCount();
@override
Future<SyncOutcome> syncCustomers() async {
final started = DateTime.now();
var attempted = 0;
var uploaded = 0;
while (true) {
final batch = await _store.catalogue.unsyncedCustomers(limit: batchSize);
if (batch.isEmpty) break;
attempted += batch.length;
PushReceipt receipt;
try {
receipt = await _transport.pushCustomers(
batch.map(_customerToPayload).toList(),
);
} on Object catch (e) {
// Nothing is marked sent when the outcome is unknown. Unlike a bill,
// a registration is safe to send twice, so this simply waits for the
// next pass rather than needing a per-row attempt counter.
if (attempted > batch.length || uploaded > 0) {
await _store.refreshUnsyncedCustomerCount();
}
return SyncOutcome(
attempted: attempted,
uploaded: uploaded,
error: e.toString(),
isRetryable: e is! TransportException || e.retryable,
);
}
final acceptedIds = receipt.accepted.toSet();
await _store.catalogue.markCustomersSynced(acceptedIds.toList());
uploaded += acceptedIds.length;
// Nothing moved, so the next page would hand back the same rows for
// ever. Stop and let the events log show why.
if (acceptedIds.isEmpty) {
final reasons = receipt.rejected.values.toSet().join('; ');
await _log(SyncEvent(
id: _uuid.v4(),
type: SyncEventType.catalogueImport,
status: SyncStatus.failed,
createdAt: started,
summary: '${batch.length} registrations were not accepted',
error:
reasons.isEmpty ? 'Not confirmed by the back office' : reasons,
attempts: 1,
),);
await _store.refreshUnsyncedCustomerCount();
return SyncOutcome(
attempted: attempted,
uploaded: uploaded,
rejected: batch.length,
error: 'No registration in this batch was accepted'
'${reasons.isEmpty ? '' : ': $reasons'}',
isRetryable: false,
);
}
}
await _store.refreshUnsyncedCustomerCount();
if (uploaded > 0) {
await _log(SyncEvent(
id: _uuid.v4(),
type: SyncEventType.catalogueImport,
status: SyncStatus.synced,
createdAt: started,
syncedAt: DateTime.now(),
summary: '$uploaded shopper registrations uploaded '
'via ${_transport.label}',
attempts: 1,
),);
}
return SyncOutcome(attempted: attempted, uploaded: uploaded);
}
/// The JSON body sent per order.
/// The JSON body sent per registration.
///
/// Identity and profile only. Points, spend and visit counts are deliberately
/// left out: they are derived from the bill stream, which is authoritative and
/// idempotent. Uploading a terminal's local balance would make the last till
/// to sync win, and a shopper who bought something at two counters on the same
/// day would end up with whichever figure arrived second.
Map<String, Object?> _customerToPayload(Customer c) => {
'id': c.id,
'mobile': c.mobile,
'name': c.name,
'email': c.email,
'gender': c.gender.name,
'date_of_birth': c.dateOfBirth?.toIso8601String(),
'registered_at': _iso(c.createdAt),
'registered_by_terminal': _store.terminal.code,
};
/// The JSON body sent per order. Matches the back office's `/orders`
/// schema field for field — nothing added beyond it.
Map<String, Object?> _orderToPayload(SaleTransaction t) => {
'id': t.id,
'invoice_number': t.invoiceNumber,
'created_at': t.createdAt.toIso8601String(),
'terminal_id': t.terminalId,
'created_at': Formatters.isoWithOffset(t.createdAt),
'cashier': t.cashierName,
'customer': t.customer == null
? null
@@ -278,6 +455,15 @@ class SyncRepositoryImpl implements SyncRepository {
'subtotal': t.cart.subtotal,
'discount': t.cart.billDiscountTotal + t.cart.lineDiscountTotal,
'tax': t.cart.taxAmount,
// GST per slab, as printed on the invoice. Sent as well as the total
// because a compliant tax return is filed per slab, and recomputing the
// split server-side from line items would have to redo the discount
// apportionment — and get exactly the same answer, or the filed figure
// stops matching the paper the shopper was handed.
'tax_breakdown': {
for (final entry in t.cart.taxBreakdown.entries)
entry.key.toString(): entry.value,
},
'round_off': t.cart.roundOff,
'total': t.total,
'points_earned': t.pointsEarned,
@@ -306,3 +492,12 @@ class SyncRepositoryImpl implements SyncRepository {
],
};
}
/// [Formatters.isoWithOffset], tolerating a null.
///
/// Several of these are genuinely absent — a till that has never uploaded has
/// no last upload, and one with an empty queue has no oldest pending bill.
/// Omitting the key is the honest answer; sending an epoch would put 1970 on a
/// dashboard and read as a real reading.
String? _iso(DateTime? time) =>
time == null ? null : Formatters.isoWithOffset(time);

View File

@@ -30,6 +30,35 @@ class TransactionRepositoryImpl implements TransactionRepository {
await _store.refreshUnsyncedCount();
}
@override
Future<void> voidSale({
required SaleTransaction transaction,
required Map<String, double> stockMovements,
}) async {
// The customer attached to the cart is the pre-sale snapshot — commitSale
// never mutates it, only the separate `updatedCustomer` it computed — so
// restoring exactly this row undoes the loyalty movement precisely,
// rather than trying to reconstruct it from a delta.
final preSaleCustomer = transaction.cart.customer;
await _store.orders.voidSale(
orderId: transaction.id,
stockMovements: stockMovements,
customerRow: preSaleCustomer == null
? null
: CatalogueDao.customerToRow(preSaleCustomer),
);
// Disk is reverted; bring the read caches back in line with it. Negating
// the same map reuses cacheStockMovement's "subtract" semantics to add
// the stock back instead.
_store.cacheStockMovement(
stockMovements.map((id, qty) => MapEntry(id, -qty)),
);
if (preSaleCustomer != null) _store.cacheCustomer(preSaleCustomer);
await _store.refreshUnsyncedCount();
}
@override
Future<List<SaleTransaction>> history({int limit = 50}) =>
_store.orders.recent(limit: limit);

View File

@@ -0,0 +1,226 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../../core/config/sync_config.dart';
import '../../core/utils/formatters.dart';
import '../../domain/repositories/sync_repository.dart';
import '../local/terminal_identity.dart';
import '../remote/order_transport.dart';
import 'sync_engine.dart';
/// What a till reports about itself, every 30 seconds.
///
/// The Last Will already answers *is it dead* — the broker publishes `offline`
/// on a terminal's behalf when it stops responding. That is not enough to run a
/// hundred shops on, because the failure that actually costs money looks
/// completely healthy from outside: a till that is connected, selling, and
/// quietly accumulating two hundred bills it has never managed to upload.
///
/// So this carries the numbers that separate *reachable* from *well*: how deep
/// the queue is, how long the oldest thing in it has been waiting, whether the
/// till has rung anything today, and whether the hardware is in the way.
///
/// Not retained, and never acknowledged. A heartbeat is a fact with an expiry
/// date — the back office holds it in Redis under a TTL, so a terminal that
/// loses power ages off the board by itself. Retaining it would leave a dead
/// till looking alive until something overwrote it.
class HealthReporter {
HealthReporter({
required OrderTransport transport,
required TerminalIdentity terminal,
required SyncConfig config,
required SyncEngine engine,
required SyncRepository repository,
required this.appVersion,
this.deviceState,
this.printerEndpoint,
Duration interval = const Duration(seconds: 30),
DateTime Function()? clock,
Timer Function(Duration, void Function())? scheduleTimer,
}) : _transport = transport,
_terminal = terminal,
_config = config,
_engine = engine,
_repository = repository,
_interval = interval,
_now = clock ?? DateTime.now,
_schedule = scheduleTimer ?? Timer.new;
final OrderTransport _transport;
final TerminalIdentity _terminal;
final SyncConfig _config;
final SyncEngine _engine;
final SyncRepository _repository;
final Duration _interval;
final DateTime Function() _now;
final Timer Function(Duration, void Function()) _schedule;
final String appVersion;
/// Hardware readings, if this build collects any.
///
/// A hook rather than a hard dependency: battery level and free storage need
/// platform packages that a desktop build has no use for, and a health board
/// is not a good enough reason to make the whole app depend on them. What is
/// not collected is *omitted* rather than sent as zero — a dashboard showing
/// every till at 0% battery is worse than one showing nothing.
final Future<Map<String, Object?>> Function()? deviceState;
/// Where the receipt printer lives, if one is configured.
///
/// Read fresh on each beat rather than captured once, because a shop can
/// re-point its printer in Settings without restarting the till.
final ({String host, int port})? Function()? printerEndpoint;
Timer? _timer;
bool _stopped = false;
Future<void> start() async {
if (_stopped) throw StateError('This HealthReporter has been disposed.');
await publish();
_tick();
}
void _tick() {
_timer = _schedule(_interval, () {
if (_stopped) return;
unawaited(publish());
_tick();
});
}
/// One heartbeat.
///
/// Every failure is swallowed. A terminal that cannot say how it is must
/// still sell — losing a heartbeat is a monitoring gap, and stopping a till
/// because a dashboard is unreachable would be a self-inflicted outage.
Future<void> publish() async {
if (!_transport.isConnected) return;
try {
final state = _engine.state;
final pendingBills = state.pending;
final pendingRegistrations = await _repository.unsyncedCustomerCount();
// Terminal-wide rather than scoped to whoever is signed in: the board
// watches a till, not a shift.
final today = await _repository.todayReport(
terminalId: _terminal.code,
cashierName: '',
);
final payload = <String, Object?>{
'schema': 1,
'status': 'online',
'terminal_id': _terminal.code,
'device_id': _terminal.deviceId,
'terminal_name': _terminal.name,
'location_id': _config.storeId,
'store_name': _terminal.name,
'app_version': appVersion,
'transport': _config.transport.name,
// Queue depth — the number that makes a silent failure visible.
'pending_bills': pendingBills,
'pending_registrations': pendingRegistrations,
'oldest_pending_at': _iso(await _oldestPendingAt()),
'sync_halted': state.isHalted,
'sync_error': state.lastError,
'last_upload_at': _iso(state.lastSuccessAt),
// Today's trading. A till that is connected but has rung nothing in
// three hours is usually a jammed printer or an absent cashier, and
// neither shows up on an online/offline board.
'today_bills': today.billCount,
'today_amount': today.grossSales,
'last_bill_at': _iso(today.lastBillAt),
'reported_at': Formatters.isoWithOffset(_now()),
};
final device = await _collectDeviceState();
payload.addAll(device);
await _transport.publishHealth(jsonEncode(payload));
} on Object {
// Deliberately silent — see above.
}
}
/// When the oldest unsent bill was rung.
///
/// More useful than the count on its own: fifty bills queued in the last ten
/// minutes is a broker hiccup, while three queued since Tuesday is a till
/// nobody has looked at.
Future<DateTime?> _oldestPendingAt() async {
try {
final rows = await _repository.orderSyncRows(limit: 500);
DateTime? oldest;
for (final row in rows) {
if (row.isSynced) continue;
if (oldest == null || row.createdAt.isBefore(oldest)) {
oldest = row.createdAt;
}
}
return oldest;
} on Object {
return null;
}
}
/// Hardware readings, plus whatever this build can work out for itself.
Future<Map<String, Object?>> _collectDeviceState() async {
final out = <String, Object?>{};
if (deviceState != null) {
try {
out.addAll(await deviceState!());
} on Object {
// A missing battery reading must not cost the rest of the heartbeat.
}
}
final printer = printerEndpoint?.call();
if (printer != null && printer.host.isNotEmpty) {
out['printer_reachable'] = await _canReach(printer.host, printer.port);
}
return out;
}
/// Opens and immediately closes a socket to the till's printer.
///
/// Cheap enough to run every 30 seconds, and it answers the question a shop
/// actually phones about — a printer that is switched off looks identical to
/// a working one until someone tries to print a bill.
Future<bool> _canReach(String host, int port) async {
try {
final socket = await Socket.connect(
host,
port,
timeout: const Duration(seconds: 2),
);
socket.destroy();
return true;
} on Object {
return false;
}
}
Future<void> dispose() async {
_stopped = true;
_timer?.cancel();
}
}
/// [Formatters.isoWithOffset], tolerating a null.
///
/// Several of these are genuinely absent — a till that has never uploaded has
/// no last upload, and one with an empty queue has no oldest pending bill.
/// Omitting the key is the honest answer; sending an epoch would put 1970 on a
/// dashboard and read as a real reading.
String? _iso(DateTime? time) =>
time == null ? null : Formatters.isoWithOffset(time);

View File

@@ -0,0 +1,109 @@
import 'dart:async';
import 'dart:convert';
import '../../core/config/sync_config.dart';
import '../local/terminal_identity.dart';
import '../remote/mqtt_order_transport.dart';
import 'sync_engine.dart';
/// Publishes what this till is doing, so a fleet of them can be watched.
///
/// The Last Will already answers "is it dead" — the broker publishes `offline`
/// when a terminal stops responding. That is not enough to run 100 shops on: a
/// till can be connected and still be broken, holding 200 unsent bills or
/// running last month's catalogue. This publishes the state that distinguishes
/// *reachable* from *healthy*.
///
/// Retained on purpose. A dashboard that connects at noon gets every terminal's
/// last report immediately, instead of a blank board until each one happens to
/// tick.
class PresenceReporter {
PresenceReporter({
required MqttOrderTransport transport,
required TerminalIdentity terminal,
required SyncConfig config,
required SyncEngine engine,
required this.appVersion,
required Future<String?> Function() catalogueRevision,
Duration interval = const Duration(minutes: 1),
DateTime Function()? clock,
Timer Function(Duration, void Function())? scheduleTimer,
}) : _transport = transport,
_terminal = terminal,
_config = config,
_engine = engine,
_catalogueRevision = catalogueRevision,
_interval = interval,
_now = clock ?? DateTime.now,
_schedule = scheduleTimer ?? Timer.new;
final MqttOrderTransport _transport;
final TerminalIdentity _terminal;
final SyncConfig _config;
final SyncEngine _engine;
final Future<String?> Function() _catalogueRevision;
final Duration _interval;
final DateTime Function() _now;
final Timer Function(Duration, void Function()) _schedule;
final String appVersion;
Timer? _timer;
bool _stopped = false;
Future<void> start() async {
if (_stopped) throw StateError('This PresenceReporter has been disposed.');
await publish();
_tick();
}
void _tick() {
_timer = _schedule(_interval, () {
if (_stopped) return;
unawaited(publish());
_tick();
});
}
/// One presence record.
///
/// Failures are swallowed. A terminal that cannot tell head office how it is
/// must still sell — losing a heartbeat is a monitoring gap, not a reason to
/// stop trading.
Future<void> publish() async {
if (!_transport.isConnected) return;
try {
final state = _engine.state;
await _transport.publishStatus(
jsonEncode({
'schema': 1,
'state': 'online',
'device_id': _terminal.deviceId,
'terminal_code': _terminal.code,
'terminal_name': _terminal.name,
'store_id': _terminal.storeId,
'app_version': appVersion,
'reported_at': _now().toIso8601String(),
// The three numbers that separate a healthy till from a broken one.
'pending_bills': state.pending,
'last_upload_at': state.lastSuccessAt?.toIso8601String(),
'catalogue_revision': await _catalogueRevision(),
'sync_halted': state.isHalted,
'sync_error': state.lastError,
'consecutive_failures': state.consecutiveFailures,
'transport': _config.transport.name,
}),
);
} on Object {
// Deliberately silent — see above.
}
}
Future<void> dispose() async {
_stopped = true;
_timer?.cancel();
}
}

View File

@@ -0,0 +1,371 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart';
import '../../domain/repositories/sync_repository.dart';
import '../remote/order_transport.dart';
/// Why a drain was attempted. Shown in the events log, because "why did it try
/// then" is the first question when a sync misbehaves.
enum SyncTrigger {
startup,
saleCommitted,
connectivityRegained,
periodic,
retry,
headOfficeRequest,
manual,
}
/// What the engine is doing, for the header and the events screen.
@immutable
class SyncEngineState {
const SyncEngineState({
this.isSyncing = false,
this.pending = 0,
this.online = true,
this.consecutiveFailures = 0,
this.lastSuccessAt,
this.lastAttemptAt,
this.nextAttemptAt,
this.lastError,
this.isHalted = false,
this.lastTrigger,
});
final bool isSyncing;
final int pending;
final bool online;
final int consecutiveFailures;
final DateTime? lastSuccessAt;
final DateTime? lastAttemptAt;
/// When the backoff timer will fire. Null when nothing is scheduled.
final DateTime? nextAttemptAt;
final String? lastError;
/// Set after a failure that retrying cannot fix — a bad credential, an
/// unconfigured endpoint. The engine stops its own scheduling so it does not
/// hammer a door that is locked; a manual sync or a config change resumes it.
final bool isHalted;
final SyncTrigger? lastTrigger;
bool get isHealthy => !isHalted && consecutiveFailures == 0;
SyncEngineState copyWith({
bool? isSyncing,
int? pending,
bool? online,
int? consecutiveFailures,
DateTime? lastSuccessAt,
DateTime? lastAttemptAt,
DateTime? nextAttemptAt,
String? lastError,
bool? isHalted,
SyncTrigger? lastTrigger,
bool clearNextAttempt = false,
bool clearError = false,
}) =>
SyncEngineState(
isSyncing: isSyncing ?? this.isSyncing,
pending: pending ?? this.pending,
online: online ?? this.online,
consecutiveFailures: consecutiveFailures ?? this.consecutiveFailures,
lastSuccessAt: lastSuccessAt ?? this.lastSuccessAt,
lastAttemptAt: lastAttemptAt ?? this.lastAttemptAt,
nextAttemptAt:
clearNextAttempt ? null : nextAttemptAt ?? this.nextAttemptAt,
lastError: clearError ? null : lastError ?? this.lastError,
isHalted: isHalted ?? this.isHalted,
lastTrigger: lastTrigger ?? this.lastTrigger,
);
}
/// Decides *when* bills are uploaded.
///
/// The repository knows how to send one batch; this knows when to ask, and what
/// to do when the answer is no. Together they turn the orders table into a
/// queue that empties itself:
///
/// * a sale is committed — try immediately, so a bill is usually up within
/// seconds of the drawer closing;
/// * the network returns — try at once rather than waiting out a poll;
/// * nothing happened for a while — poll, because interface state lies;
/// * head office asked — the MQTT downlink can pull a shift up on demand;
/// * the cashier pressed sync — always allowed, even while halted.
///
/// ### Two guarantees worth stating
///
/// **Single flight.** Only one drain runs at a time. Without this, a busy till
/// firing a trigger per sale would have several passes reading the same pending
/// rows and publishing them concurrently — every bill sent two or three times.
/// A trigger arriving mid-drain sets a flag and is honoured once the current
/// pass finishes, so nothing is dropped either.
///
/// **Backoff with jitter.** A failed attempt waits, and waits longer each time,
/// to a ceiling. The jitter matters more than it looks: when a shop's line
/// drops, every terminal in the store fails at the same instant, and without it
/// they would all retry in lockstep and keep colliding on the way back up.
class SyncEngine {
SyncEngine({
required SyncRepository repository,
Stream<bool>? connectivity,
Stream<DownlinkMessage>? downlink,
Future<void> Function()? onCatalogueChanged,
Duration idlePoll = const Duration(minutes: 5),
Duration baseBackoff = const Duration(seconds: 2),
Duration maxBackoff = const Duration(minutes: 5),
Random? random,
DateTime Function()? clock,
Timer Function(Duration, void Function())? scheduleTimer,
}) : _repository = repository,
_connectivity = connectivity,
_downlink = downlink,
_onCatalogueChanged = onCatalogueChanged,
_idlePoll = idlePoll,
_baseBackoff = baseBackoff,
_maxBackoff = maxBackoff,
_random = random ?? Random(),
_now = clock ?? DateTime.now,
_schedule = scheduleTimer ?? Timer.new;
final SyncRepository _repository;
final Stream<bool>? _connectivity;
final Stream<DownlinkMessage>? _downlink;
final Future<void> Function()? _onCatalogueChanged;
final Duration _idlePoll;
final Duration _baseBackoff;
final Duration _maxBackoff;
final Random _random;
final DateTime Function() _now;
final Timer Function(Duration, void Function()) _schedule;
final _states = StreamController<SyncEngineState>.broadcast();
SyncEngineState _state = const SyncEngineState();
SyncEngineState get state => _state;
Stream<SyncEngineState> get states => _states.stream;
/// Held for the whole of a drain. The single-flight guarantee rests on this
/// being checked and set without an `await` in between.
bool _draining = false;
/// A trigger that arrived while a drain was already running.
SyncTrigger? _queuedTrigger;
Timer? _backoffTimer;
Timer? _pollTimer;
StreamSubscription<bool>? _connectivitySub;
StreamSubscription<DownlinkMessage>? _downlinkSub;
bool _stopped = false;
// ------------------------------------------------------------------ Life
Future<void> start() async {
if (_stopped) throw StateError('This SyncEngine has been disposed.');
_connectivitySub = _connectivity?.listen((online) {
_emit(_state.copyWith(online: online));
// Coming back is the single best moment to try; going offline is not
// worth an attempt that is certain to fail.
if (online) nudge(SyncTrigger.connectivityRegained);
});
_downlinkSub = _downlink?.listen(_onDownlink);
_pollTimer = _startPoll();
await _refreshPending();
nudge(SyncTrigger.startup);
}
Timer _startPoll() => _schedule(_idlePoll, () {
if (_stopped) return;
_pollTimer = _startPoll();
nudge(SyncTrigger.periodic);
});
void _onDownlink(DownlinkMessage message) {
switch (message.kind) {
case DownlinkKind.syncRequested:
nudge(SyncTrigger.headOfficeRequest);
case DownlinkKind.catalogueChanged:
// Sending what we owe before pulling new prices keeps the bills we
// already rang priced as they were rung.
nudge(SyncTrigger.headOfficeRequest);
unawaited(_onCatalogueChanged?.call() ?? Future<void>.value());
case DownlinkKind.unknown:
break;
}
}
// -------------------------------------------------------------- Triggers
/// Asks for a drain. Cheap, non-blocking, and safe to call on every sale.
void nudge(SyncTrigger trigger) {
if (_stopped) return;
if (_draining) {
// Remember it rather than dropping it: bills committed during this pass
// were not in the set it read, and would otherwise wait for the poll.
_queuedTrigger = trigger;
return;
}
if (_state.isHalted && trigger != SyncTrigger.manual) return;
if (!_state.online && trigger != SyncTrigger.manual) return;
unawaited(_drain(trigger));
}
/// The cashier pressed sync. Runs even when halted or believed offline —
/// they may know something the engine does not, and being told why it failed
/// beats a button that does nothing.
Future<SyncOutcome> syncNow({
void Function(double progress, String stage)? onProgress,
}) async {
_backoffTimer?.cancel();
_emit(_state.copyWith(isHalted: false, clearNextAttempt: true));
return _drain(SyncTrigger.manual, onProgress: onProgress);
}
// ----------------------------------------------------------------- Drain
Future<SyncOutcome> _drain(
SyncTrigger trigger, {
void Function(double progress, String stage)? onProgress,
}) async {
if (_draining) return const SyncOutcome(attempted: 0, uploaded: 0);
_draining = true;
_backoffTimer?.cancel();
_emit(_state.copyWith(
isSyncing: true,
lastTrigger: trigger,
lastAttemptAt: _now(),
clearNextAttempt: true,
),);
SyncOutcome outcome;
try {
try {
// Registrations first, so a bill naming a shopper the back office has
// never heard of arrives after the shopper does. A failure here is
// logged and swallowed: shoppers waiting to go up must never be the
// reason a day's takings stay on the terminal.
try {
await _repository.syncCustomers();
} on Object {
// Deliberately ignored — the next pass tries again, and the events
// log already carries the reason.
}
outcome = await _repository.syncOrders(onProgress: onProgress);
} on Object catch (e) {
// The repository is meant to fold failures into the outcome; anything
// escaping it is a defect, not a network fault. Treated as a retryable
// failure so a bad build still empties its queue once fixed.
outcome = SyncOutcome(attempted: 0, uploaded: 0, error: e.toString());
}
await _refreshPending();
if (outcome.isSuccess) {
_emit(_state.copyWith(
isSyncing: false,
consecutiveFailures: 0,
lastSuccessAt: _now(),
clearError: true,
clearNextAttempt: true,
),);
} else {
_onFailure(outcome);
}
} finally {
// Held until the bookkeeping is done, not just until the send is. Freed
// any earlier, a queued trigger could start a second drain whose
// `isSyncing: true` this one would then overwrite with `false`, leaving
// the header claiming idle while an upload is in flight.
_draining = false;
}
// Honour anything that arrived while we were busy. Bills committed during
// the pass were not in the set it read.
final queued = _queuedTrigger;
_queuedTrigger = null;
if (queued != null && outcome.isSuccess && _state.pending > 0) {
nudge(queued);
}
return outcome;
}
void _onFailure(SyncOutcome outcome) {
final failures = _state.consecutiveFailures + 1;
if (!outcome.isRetryable) {
_emit(_state.copyWith(
isSyncing: false,
consecutiveFailures: failures,
lastError: outcome.error,
isHalted: true,
clearNextAttempt: true,
),);
return;
}
final delay = backoffFor(failures);
_emit(_state.copyWith(
isSyncing: false,
consecutiveFailures: failures,
lastError: outcome.error,
nextAttemptAt: _now().add(delay),
),);
_backoffTimer = _schedule(delay, () {
if (_stopped) return;
nudge(SyncTrigger.retry);
});
}
/// Doubles per failure to a ceiling, then ±20% so a store's terminals do not
/// come back in lockstep.
@visibleForTesting
Duration backoffFor(int failures) {
final exponent = (failures - 1).clamp(0, 30);
final raw = _baseBackoff * pow(2, exponent).toDouble();
final capped = raw > _maxBackoff ? _maxBackoff : raw;
final jitter = 0.8 + _random.nextDouble() * 0.4;
return Duration(
milliseconds: (capped.inMilliseconds * jitter).round(),
);
}
Future<void> _refreshPending() async {
try {
// Read first, then emit. Written as `copyWith(pending: await …)` the
// receiver `_state` is evaluated before the await completes, so anything
// that changed during the wait — connectivity dropping, most of all —
// would be overwritten by the stale snapshot.
final count = await _repository.unsyncedCount();
_emit(_state.copyWith(pending: count));
} on Object {
// A count is decoration; failing to read it must not fail the drain.
}
}
void _emit(SyncEngineState next) {
_state = next;
if (!_states.isClosed) _states.add(next);
}
Future<void> dispose() async {
_stopped = true;
_backoffTimer?.cancel();
_pollTimer?.cancel();
await _connectivitySub?.cancel();
await _downlinkSub?.cancel();
await _states.close();
}
}

View File

@@ -4,6 +4,7 @@ import '../../core/constants/app_constants.dart';
import '../../core/utils/extensions.dart';
import 'customer.dart';
import 'product.dart';
import 'promo.dart';
/// How a discount value should be interpreted.
enum DiscountType { none, percentage, flat }
@@ -100,6 +101,7 @@ class Cart extends Equatable {
this.billDiscount = Discount.none,
this.pointsRedeemed = 0,
this.note,
this.appliedPromos = const [],
});
final List<CartLine> lines;
@@ -108,6 +110,14 @@ class Cart extends Equatable {
final int pointsRedeemed;
final String? note;
/// Campaigns that fired on this bill.
///
/// Resolved by `PromoEngine` and handed in, rather than computed here: which
/// campaigns exist is policy that changes weekly, and Cart owns arithmetic
/// that must never be wrong. Stored as amounts so a bill read back years
/// later shows what was actually given, not what today's rules would give.
final List<AppliedPromo> appliedPromos;
static const Cart empty = Cart();
bool get isEmpty => lines.isEmpty;
@@ -143,9 +153,17 @@ class Cart extends Equatable {
double get manualBillDiscountAmount => billDiscount.amountOn(subtotal);
/// What the automatic campaigns took off.
double get promoDiscountAmount =>
appliedPromos.fold(0.0, (sum, p) => sum + p.amount).asMoney;
/// All bill-level reductions combined.
///
/// Clamped to the subtotal so no combination of tier, campaign and manual
/// discount can drive a bill below zero and turn a sale into a payout.
double get billDiscountTotal =>
(membershipDiscountAmount + manualBillDiscountAmount)
(membershipDiscountAmount + manualBillDiscountAmount +
promoDiscountAmount)
.clamp(0, subtotal)
.toDouble()
.asMoney;
@@ -159,13 +177,113 @@ class Cart extends Equatable {
return v.clamp(0, double.infinity).toDouble().asMoney;
}
/// Proportion of the bill remaining after bill-level reductions. Used to
/// spread those reductions fairly across lines when apportioning GST.
double get _billFactor => subtotal <= 0 ? 1 : netAmount / subtotal;
/// Bill-level reductions, allocated to the lines that earned them.
///
/// Returns one figure per line, in [lines] order, summing to exactly
/// `subtotal - netAmount`.
///
/// This exists because GST is charged per line at that line's own slab, so
/// *which* line a discount lands on changes the tax. A bill-wide reduction —
/// a tier discount, a manual markdown, points redeemed — genuinely belongs to
/// every line, and spreading it pro rata is right. A campaign that names a
/// category or a product does not: taking "20% off Beverages" out of the
/// atta line as well understates the 18% slab and overstates the 5% one. The
/// bill total is identical either way, which is exactly why the error is easy
/// to ship — it only shows up in the slab split on a filed return.
List<double> get _lineReductions {
final result = List<double>.filled(lines.length, 0);
if (lines.isEmpty) return result;
// What the shopper actually saved at bill level, after the clamps in
// [billDiscountTotal] and [netAmount] have had their say.
final ceiling = (subtotal - netAmount).asMoney;
if (ceiling <= 0) return result;
void spread(double amount, bool Function(CartLine) targets) {
if (amount <= 0) return;
final matched = <int>[];
var base = 0.0;
for (var i = 0; i < lines.length; i++) {
if (!targets(lines[i])) continue;
matched.add(i);
base += lines[i].payable;
}
if (base <= 0) return;
for (final i in matched) {
result[i] += amount * (lines[i].payable / base);
}
}
for (final applied in appliedPromos) {
spread(applied.amount, (l) => applied.promo.targets(l.product));
}
spread(membershipDiscountAmount, (_) => true);
spread(manualBillDiscountAmount, (_) => true);
spread(loyaltyRedemptionValue, (_) => true);
return _fitToCeiling(result, ceiling);
}
/// Scales [raw] so it sums to [ceiling], with no line reduced below zero.
///
/// The components arrive individually clamped and then clamped again as a
/// group, so their raw sum is only approximately what came off the bill.
/// Scaling reconciles the two. Capping is a separate pass because a targeted
/// campaign can take a line to zero on its own, and the tier discount layered
/// on top would otherwise push it negative — which would show up as a
/// *credit* in that line's GST slab.
List<double> _fitToCeiling(List<double> raw, double ceiling) {
final out = List<double>.filled(raw.length, 0);
final open = [for (var i = 0; i < raw.length; i++) i];
var pool = ceiling;
// Loops because capping one line hands its excess back to the pool, which
// can in turn push another line past its own value.
while (open.isNotEmpty && pool > 0) {
final weight = open.fold(0.0, (sum, i) => sum + raw[i]);
if (weight <= 0) break;
final capped = open
.where((i) => pool * (raw[i] / weight) >= lines[i].payable)
.toList();
if (capped.isEmpty) {
for (final i in open) {
out[i] = pool * (raw[i] / weight);
}
break;
}
for (final i in capped) {
out[i] = lines[i].payable;
pool -= lines[i].payable;
open.remove(i);
}
}
return out;
}
/// What each line is worth after its share of the bill-level reductions.
List<double> get _lineNetAmounts {
final reductions = _lineReductions;
return [
for (var i = 0; i < lines.length; i++)
(lines[i].payable - reductions[i]).clamp(0, double.infinity).toDouble(),
];
}
/// GST payable across the bill, after apportioning bill-level discounts.
double get taxAmount =>
lines.fold(0.0, (sum, l) => sum + l.taxAmount * _billFactor).asMoney;
double get taxAmount {
final nets = _lineNetAmounts;
var total = 0.0;
for (var i = 0; i < lines.length; i++) {
total += nets[i] - nets[i] / (1 + lines[i].product.gstRate);
}
return total.asMoney;
}
double get cgst => (taxAmount / 2).asMoney;
double get sgst => (taxAmount / 2).asMoney;
@@ -180,10 +298,11 @@ class Cart extends Equatable {
/// side of the total printed on the same bill, which a tax invoice cannot
/// show; the residue is absorbed by the largest slab.
Map<double, double> get taxBreakdown {
final nets = _lineNetAmounts;
final raw = <double, double>{};
for (final line in lines) {
final rate = line.product.gstRate;
raw[rate] = (raw[rate] ?? 0) + line.taxAmount * _billFactor;
for (var i = 0; i < lines.length; i++) {
final rate = lines[i].product.gstRate;
raw[rate] = (raw[rate] ?? 0) + (nets[i] - nets[i] / (1 + rate));
}
if (raw.isEmpty) return const {};
@@ -240,6 +359,7 @@ class Cart extends Equatable {
Discount? billDiscount,
int? pointsRedeemed,
String? note,
List<AppliedPromo>? appliedPromos,
}) {
return Cart(
lines: lines ?? this.lines,
@@ -247,10 +367,11 @@ class Cart extends Equatable {
billDiscount: billDiscount ?? this.billDiscount,
pointsRedeemed: pointsRedeemed ?? this.pointsRedeemed,
note: note ?? this.note,
appliedPromos: appliedPromos ?? this.appliedPromos,
);
}
@override
List<Object?> get props =>
[lines, customer, billDiscount, pointsRedeemed, note];
[lines, customer, billDiscount, pointsRedeemed, note, appliedPromos];
}

View File

@@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:uuid/uuid.dart';
import '../../core/constants/app_constants.dart';
import '../../core/utils/extensions.dart';
@@ -74,6 +75,51 @@ class Customer extends Equatable {
final DateTime? createdAt;
final DateTime? lastVisitAt;
/// Fixed namespace for customer ids. Must never change: it is half the
/// input to [idForMobile], so a new one renames every shopper in the fleet.
static const _namespace = '9f2b7c14-3d6e-5a80-b1f7-2c4e8a05d913';
static const _uuid = Uuid();
/// The id for the shopper reachable on [mobile].
///
/// Derived from the number rather than minted at random, which is what lets
/// a hundred terminals agree without talking to each other. A shopper who
/// registers at counter 2 in Anna Nagar and shops at counter 5 in T Nagar
/// gets the same id both times, so the back office collapses them on a
/// primary key instead of guessing at a merge later.
static String idForMobile(String mobile) =>
_uuid.v5(_namespace, normaliseMobile(mobile));
/// Every digit in [mobile], in order. Used for matching what a cashier types
/// against what is stored, where a partial number should still find a row.
static String digitsOf(String mobile) => mobile.replaceAll(RegExp(r'\D'), '');
/// Reduces a number to the ten-digit national one identity is keyed on.
///
/// One cashier types `+91 98400 12345`, another `098400 12345`, a third
/// `9840012345`. Keyed on raw digits those are three different shoppers,
/// which is precisely the duplication [idForMobile] exists to prevent — the
/// country code would fork a customer just as effectively as a random id.
///
/// Only the two prefixes an Indian number actually carries are stripped, and
/// only at the exact lengths that make them unambiguous. Anything else is
/// left alone: mangling a number this rule was not written for is worse than
/// storing it verbatim.
static String normaliseMobile(String mobile) {
final digits = digitsOf(mobile);
// +91 98400 12345
if (digits.length == 12 && digits.startsWith('91')) {
return digits.substring(2);
}
// 0 98400 12345 — the old STD trunk prefix, still muscle memory for many.
if (digits.length == 11 && digits.startsWith('0')) {
return digits.substring(1);
}
return digits;
}
MembershipTier get tier => MembershipTier.forSpend(lifetimeSpend);
/// Cash value of the points currently held.

View File

@@ -0,0 +1,287 @@
import 'package:equatable/equatable.dart';
import 'store_account.dart';
/// One outlet the signed-in account is allowed to work.
///
/// A supervisor at a single-shop tenant gets one entry; a multi-outlet account
/// gets the list, which is what an outlet picker would be built from.
class SessionLocation extends Equatable {
const SessionLocation({
required this.locationId,
required this.locationName,
required this.address,
required this.city,
required this.status,
});
final int locationId;
final String locationName;
final String address;
final String city;
final String status;
bool get isActive => status.toLowerCase() == 'active';
factory SessionLocation.fromJson(Map<String, Object?> json) =>
SessionLocation(
locationId: _asInt(json['location_id']),
locationName: _asString(json['location_name']),
address: _asString(json['address']),
city: _asString(json['city']),
status: _asString(json['status']),
);
Map<String, Object?> toJson() => {
'location_id': locationId,
'location_name': locationName,
'address': address,
'city': city,
'status': status,
};
@override
List<Object?> get props => [locationId, locationName, address, city, status];
}
/// A person the back office says may work this terminal.
///
/// The `pin` the server returns is in the clear. It is kept because switching
/// operators at the till is a PIN entry and nothing else, but it is the reason
/// [PosSession] is written to the platform keystore rather than to SQLite —
/// and it is worth pushing the back office to return a hash instead.
class SessionStaff extends Equatable {
const SessionStaff({
required this.userId,
required this.fullName,
required this.role,
required this.pin,
required this.status,
});
final int userId;
final String fullName;
final String role;
final String pin;
final String status;
bool get isActive => status.toLowerCase() == 'active';
factory SessionStaff.fromJson(Map<String, Object?> json) => SessionStaff(
userId: _asInt(json['user_id']),
fullName: _asString(json['full_name']),
role: _asString(json['role']),
pin: _asString(json['pin']),
status: _asString(json['status']),
);
Map<String, Object?> toJson() => {
'user_id': userId,
'full_name': fullName,
'role': role,
'pin': pin,
'status': status,
};
@override
List<Object?> get props => [userId, fullName, role, pin, status];
}
/// Everything `POST /pos/login` answered with, plus the account it was issued
/// to.
///
/// This is the whole session: the bearer token every later call needs, who is
/// signed in, and which outlet the terminal is now trading as. It is persisted
/// verbatim so a restart does not force a fresh sign-in, and dropped entirely
/// on sign-out.
class PosSession extends Equatable {
const PosSession({
required this.token,
required this.authname,
required this.userId,
required this.fullName,
required this.roleId,
required this.role,
required this.tenantId,
required this.tenantName,
required this.storeId,
required this.locationId,
required this.locationName,
required this.address,
required this.gstin,
required this.phone,
this.expiresAt,
this.canManageStaff = false,
this.locations = const [],
this.staff = const [],
});
/// Bearer token for every subsequent call. Never logged, never printed.
final String token;
/// The credential this session was opened with. Kept only so the login
/// screen can pre-fill it on the next shift.
final String authname;
final int userId;
final String fullName;
/// Numeric role from the back office (7 = Supervisor on this tenant).
///
/// Recorded, but never the thing that decides what the terminal opens — see
/// [isCashier]. Ids are tenant configuration and can be renumbered; the role
/// name is the stable contract.
final int roleId;
/// Role name as the server spells it — `Supervisor`, `Cashier`, `Admin`.
final String role;
final bool canManageStaff;
final int tenantId;
final String tenantName;
/// The outlet, as a string, matching what the sync topics are namespaced on.
final String storeId;
final int locationId;
final String locationName;
/// Printed on every invoice, so these come from the back office rather than
/// from anything typed into this terminal.
final String address;
final String gstin;
final String phone;
final DateTime? expiresAt;
final List<SessionLocation> locations;
final List<SessionStaff> staff;
/// Roles that get the full shell: catalogue import, promos, settings, staff.
static const Set<String> adminRoles = {
'admin',
'administrator',
'owner',
'supervisor',
'manager',
'store manager',
};
/// Whether this session is locked down to the billing screen.
///
/// Anything not in [adminRoles] lands here, including a role this build has
/// never seen. A new back-office role must not silently inherit catalogue
/// and settings access because nobody remembered to list it — the failure
/// should be "the supervisor sees a till", which someone reports in a
/// minute, not "the cashier can edit prices", which nobody notices.
bool get isCashier => !adminRoles.contains(role.trim().toLowerCase());
/// How this account maps onto the terminal's own permission model.
///
/// Two values, not four: the shell has exactly two shapes, and every
/// non-cashier role the back office issues is expected to be able to import
/// products and edit the store's details — which is what `StaffRole.admin`
/// unlocks locally.
StaffRole get staffRole => isCashier ? StaffRole.cashier : StaffRole.admin;
/// The operator, in the shape the rest of the app already speaks.
StaffUser get user => StaffUser(
id: '$userId',
name: fullName,
role: staffRole,
// The back office owns this credential now, so the terminal never
// forces a PIN change on an account it did not seed.
mustChangePin: false,
);
bool get isExpired =>
expiresAt != null && !DateTime.now().toUtc().isBefore(expiresAt!.toUtc());
/// Reads the `details` object of a successful login response.
factory PosSession.fromDetails(
Map<String, Object?> details, {
required String authname,
}) =>
PosSession(
token: _asString(details['token']),
authname: authname,
userId: _asInt(details['user_id']),
fullName: _asString(details['full_name']),
roleId: _asInt(details['role_id']),
role: _asString(details['role']),
canManageStaff: _asBool(details['can_manage_staff']),
tenantId: _asInt(details['tenant_id']),
tenantName: _asString(details['tenant_name']),
storeId: _asString(details['store_id']),
locationId: _asInt(details['location_id']),
locationName: _asString(details['location_name']),
address: _asString(details['address']),
gstin: _asString(details['gstin']),
phone: _asString(details['phone']),
expiresAt: DateTime.tryParse(_asString(details['expires_at'])),
locations: _asList(details['locations'], SessionLocation.fromJson),
staff: _asList(details['staff'], SessionStaff.fromJson),
);
/// Round-trips through [toJson], for reading back out of the keystore.
factory PosSession.fromJson(Map<String, Object?> json) =>
PosSession.fromDetails(json, authname: _asString(json['authname']));
Map<String, Object?> toJson() => {
'token': token,
'authname': authname,
'user_id': userId,
'full_name': fullName,
'role_id': roleId,
'role': role,
'can_manage_staff': canManageStaff,
'tenant_id': tenantId,
'tenant_name': tenantName,
'store_id': storeId,
'location_id': locationId,
'location_name': locationName,
'address': address,
'gstin': gstin,
'phone': phone,
'expires_at': expiresAt?.toUtc().toIso8601String(),
'locations': locations.map((l) => l.toJson()).toList(),
'staff': staff.map((s) => s.toJson()).toList(),
};
@override
List<Object?> get props => [token, userId, roleId, role, locationId];
/// Never let a token reach a log line or a crash report.
@override
String toString() =>
'PosSession($fullName, $role, $locationName, expires $expiresAt)';
}
// --------------------------------------------------------------- Decoding
//
// Tolerant on purpose. `store_id` arrives as a string and `location_id` as a
// number for the same outlet, and a field the back office adds later must not
// crash a till mid-shift.
String _asString(Object? value) => value == null ? '' : '$value';
int _asInt(Object? value) => switch (value) {
final int v => v,
final num v => v.toInt(),
final String v => int.tryParse(v) ?? 0,
_ => 0,
};
bool _asBool(Object? value) => switch (value) {
final bool v => v,
final num v => v != 0,
final String v => v.toLowerCase() == 'true' || v == '1',
_ => false,
};
List<T> _asList<T>(Object? raw, T Function(Map<String, Object?>) decode) {
if (raw is! List) return const [];
return raw
.whereType<Map<String, Object?>>()
.map(decode)
.toList(growable: false);
}

View File

@@ -0,0 +1,225 @@
import 'package:equatable/equatable.dart';
import '../../core/constants/app_constants.dart';
import 'product.dart';
/// What a promo does to a bill.
enum PromoType {
percentOffBill('% off the bill'),
flatOffBill('flat off the bill'),
percentOffCategory('% off a category'),
percentOffProduct('% off a product'),
/// Buy [Promo.buyQuantity], get [Promo.freeQuantity] of the same product
/// free. The cheapest way a shop clears stock, and the one customers ask for
/// by name.
buyXGetY('buy X get Y free');
const PromoType(this.label);
final String label;
bool get needsTarget =>
this == percentOffCategory ||
this == percentOffProduct ||
this == buyXGetY;
bool get isPercentage =>
this == percentOffBill ||
this == percentOffCategory ||
this == percentOffProduct;
}
/// A campaign the till applies automatically.
class Promo extends Equatable {
const Promo({
required this.id,
required this.name,
required this.type,
this.value = 0,
this.targetId,
this.targetLabel,
this.buyQuantity = 0,
this.freeQuantity = 0,
this.minBillValue = 0,
this.maxDiscount,
this.validFrom,
this.validTo,
this.daysOfWeek = const {},
this.stackable = false,
this.priority = 100,
this.isActive = true,
});
final String id;
final String name;
final PromoType type;
/// Percent for a percentage promo, rupees for a flat one.
final double value;
/// Category name or product id, depending on [type].
final String? targetId;
/// Human-readable target, so the bill can say "20% off Beverages" without a
/// lookup.
final String? targetLabel;
final int buyQuantity;
final int freeQuantity;
/// Floor on the bill before this applies at all.
final double minBillValue;
/// Ceiling on what a percentage promo can take off.
///
/// Without one, "20% off" on an unusually large trolley gives away more than
/// the campaign was ever costed for.
final double? maxDiscount;
final DateTime? validFrom;
final DateTime? validTo;
/// 1 = Monday … 7 = Sunday, matching [DateTime.weekday]. Empty means every
/// day.
final Set<int> daysOfWeek;
/// Whether this can combine with other promos.
///
/// Most campaigns should not. Two stacking percentages compound into a
/// discount nobody signed off, and the shop finds out at the end of the
/// month.
final bool stackable;
/// Lower runs first. Only matters for ordering on the bill and for breaking
/// ties between equal-value exclusive promos.
final int priority;
final bool isActive;
/// Whether the promo is live at [at], ignoring the contents of the bill.
bool isLiveAt(DateTime at) {
if (!isActive) return false;
final from = validFrom;
if (from != null && at.isBefore(from)) return false;
final to = validTo;
// Inclusive of the closing day: a campaign "to the 31st" runs all of it.
if (to != null && at.isAfter(_endOfDay(to))) return false;
if (daysOfWeek.isNotEmpty && !daysOfWeek.contains(at.weekday)) return false;
return true;
}
static DateTime _endOfDay(DateTime day) =>
DateTime(day.year, day.month, day.day, 23, 59, 59, 999);
/// Whether this campaign is aimed at [product] in particular.
///
/// A bill-wide promo targets everything; a category or product one targets
/// only what it names. Two things read this and they must never disagree:
/// `PromoEngine` uses it to price the discount, and [Cart] uses it to decide
/// which lines carry the GST reduction. A campaign priced against one set of
/// lines and taxed against another puts the wrong figure in a slab on a
/// filed return, so the rule lives here once rather than in both callers.
bool targets(Product product) => switch (type) {
PromoType.percentOffBill || PromoType.flatOffBill => true,
// Matched on the enum name, which is stable across a label change —
// renaming "Personal Care" must not silently switch off a campaign.
PromoType.percentOffCategory => product.category.name == targetId,
PromoType.percentOffProduct || PromoType.buyXGetY =>
product.id == targetId,
};
/// Whether the discount lands on named lines rather than the whole bill.
bool get isTargeted => type.needsTarget;
/// One-line description for the campaign list.
String get summary => switch (type) {
PromoType.percentOffBill => '${_trim(value)}% off the whole bill',
PromoType.flatOffBill =>
'${AppConstants.currencySymbol}${_trim(value)} off the bill',
PromoType.percentOffCategory =>
'${_trim(value)}% off ${targetLabel ?? targetId}',
PromoType.percentOffProduct =>
'${_trim(value)}% off ${targetLabel ?? targetId}',
PromoType.buyXGetY =>
'Buy $buyQuantity get $freeQuantity free on ${targetLabel ?? targetId}',
};
static String _trim(double v) =>
v == v.roundToDouble() ? v.toStringAsFixed(0) : v.toStringAsFixed(2);
Promo copyWith({
String? name,
PromoType? type,
double? value,
String? targetId,
String? targetLabel,
int? buyQuantity,
int? freeQuantity,
double? minBillValue,
double? maxDiscount,
bool clearMaxDiscount = false,
DateTime? validFrom,
DateTime? validTo,
bool clearDates = false,
Set<int>? daysOfWeek,
bool? stackable,
int? priority,
bool? isActive,
}) =>
Promo(
id: id,
name: name ?? this.name,
type: type ?? this.type,
value: value ?? this.value,
targetId: targetId ?? this.targetId,
targetLabel: targetLabel ?? this.targetLabel,
buyQuantity: buyQuantity ?? this.buyQuantity,
freeQuantity: freeQuantity ?? this.freeQuantity,
minBillValue: minBillValue ?? this.minBillValue,
maxDiscount:
clearMaxDiscount ? null : (maxDiscount ?? this.maxDiscount),
validFrom: clearDates ? null : (validFrom ?? this.validFrom),
validTo: clearDates ? null : (validTo ?? this.validTo),
daysOfWeek: daysOfWeek ?? this.daysOfWeek,
stackable: stackable ?? this.stackable,
priority: priority ?? this.priority,
isActive: isActive ?? this.isActive,
);
@override
List<Object?> get props => [
id,
name,
type,
value,
targetId,
buyQuantity,
freeQuantity,
minBillValue,
maxDiscount,
validFrom,
validTo,
daysOfWeek,
stackable,
priority,
isActive,
];
}
/// A promo that fired on a particular bill, and what it took off.
class AppliedPromo extends Equatable {
const AppliedPromo({required this.promo, required this.amount});
final Promo promo;
final double amount;
@override
List<Object?> get props => [promo.id, amount];
}

View File

@@ -17,23 +17,48 @@ enum StaffRole {
}
/// A person who signs in at the terminal.
///
/// Deliberately carries no PIN. It used to, which meant the credential was in
/// memory, in every widget that held a user, and — because the accounts were
/// declared as constants — inside the shipped binary. Verification now happens
/// in `StaffDao` against a stored hash, and nothing above the data layer ever
/// sees the secret.
class StaffUser extends Equatable {
const StaffUser({
required this.id,
required this.name,
required this.role,
required this.pin,
this.mustChangePin = false,
this.isActive = true,
});
final String id;
final String name;
final StaffRole role;
/// Four-digit quick-unlock code. Never rendered.
final String pin;
/// Set on a seeded or admin-reset account until the person picks their own.
final bool mustChangePin;
/// Deactivated rather than deleted, so bills already rung keep pointing at a
/// real person.
final bool isActive;
StaffUser copyWith({
String? name,
StaffRole? role,
bool? mustChangePin,
bool? isActive,
}) =>
StaffUser(
id: id,
name: name ?? this.name,
role: role ?? this.role,
mustChangePin: mustChangePin ?? this.mustChangePin,
isActive: isActive ?? this.isActive,
);
@override
List<Object?> get props => [id, name, role];
List<Object?> get props => [id, name, role, mustChangePin, isActive];
}
/// The registered outlet this terminal belongs to.
@@ -58,6 +83,26 @@ class StoreAccount extends Equatable {
final List<StaffUser> staff;
final String plan;
StoreAccount copyWith({
String? name,
String? email,
String? address,
String? gstin,
String? phone,
List<StaffUser>? staff,
String? plan,
}) =>
StoreAccount(
id: id,
name: name ?? this.name,
email: email ?? this.email,
address: address ?? this.address,
gstin: gstin ?? this.gstin,
phone: phone ?? this.phone,
staff: staff ?? this.staff,
plan: plan ?? this.plan,
);
@override
List<Object?> get props => [id, email];
List<Object?> get props => [id, name, email, address, gstin, phone, plan];
}

View File

@@ -2,18 +2,30 @@ import '../entities/shift_report.dart';
import '../entities/sync_event.dart';
import '../entities/transaction.dart';
/// Result of one end-of-day upload.
/// Result of one upload pass.
class SyncOutcome {
const SyncOutcome({
required this.attempted,
required this.uploaded,
this.rejected = 0,
this.error,
this.isRetryable = true,
});
final int attempted;
final int uploaded;
/// Bills the back office looked at and refused. These stay on the terminal
/// but sending them again unchanged will fail again, so they need a person.
final int rejected;
final String? error;
/// Whether trying again could plausibly work. False for a bad credential or
/// an unconfigured endpoint — the drain engine halts rather than retrying
/// something that cannot succeed.
final bool isRetryable;
bool get isSuccess => error == null;
bool get hadNothingToDo => attempted == 0;
int get remaining => attempted - uploaded;
@@ -27,6 +39,7 @@ class OrderSyncRow {
required this.total,
required this.createdAt,
required this.isSynced,
this.itemCount = 0,
this.syncedAt,
this.attempts = 0,
this.error,
@@ -37,15 +50,19 @@ class OrderSyncRow {
final double total;
final DateTime createdAt;
final bool isSynced;
/// Units on the bill. Fractional because loose goods are sold by weight.
final double itemCount;
final DateTime? syncedAt;
final int attempts;
final String? error;
}
/// The terminal's two network touchpoints.
/// The terminal's network touchpoints.
///
/// Morning: pull the catalogue. End of day: upload every order still at
/// `sync_status = 0`. Nothing else leaves the device.
/// Pull the catalogue; upload every order still at `sync_status = 0`. Nothing
/// else leaves the device. *When* the upload runs is not decided here — see
/// `SyncEngine`, which owns triggers and retry policy.
abstract class SyncRepository {
bool get hasCatalogue;
DateTime? get lastImportAt;
@@ -71,12 +88,27 @@ abstract class SyncRepository {
bool scopeToCashier = false,
});
/// End-of-day step — uploads pending orders and flips the accepted ones to
/// `sync_status = 1`. Failures leave every row untouched at 0.
/// One upload pass — sends pending orders and flips the ones the back office
/// confirmed to `sync_status = 1`. Failures leave every row untouched at 0.
Future<SyncOutcome> syncOrders({
void Function(double progress, String stage)? onProgress,
});
/// How many shoppers registered at this till are still waiting to go up.
Future<int> unsyncedCustomerCount();
/// Uploads shoppers registered at this till.
///
/// Deliberately separate from [syncOrders]. A registration is not a financial
/// record: it can be replayed safely, and it must not be stuck behind a bill
/// the back office has refused. Run it first so a bill referring to a new
/// shopper arrives after the shopper does.
Future<SyncOutcome> syncCustomers();
/// Retires confirmed bills past their retention window. Archived totals are
/// untouched.
Future<int> purgeExpired();
Future<List<OrderSyncRow>> orderSyncRows({int limit = 200});
List<SyncEvent> get events;

View File

@@ -13,6 +13,18 @@ abstract class TransactionRepository {
Customer? updatedCustomer,
});
/// Reverses a sale still inside its cancellation window: deletes the order,
/// restores the stock it consumed, and puts an attached shopper's loyalty
/// balance back to what it was immediately before the sale.
///
/// Only valid before the bill has been offered to the back office — this
/// does not send a cancellation anywhere, it erases the sale as if it had
/// never happened locally.
Future<void> voidSale({
required SaleTransaction transaction,
required Map<String, double> stockMovements,
});
Future<List<SaleTransaction>> history({int limit = 50});
Future<SaleTransaction?> findByInvoice(String invoiceNumber);

View File

@@ -0,0 +1,143 @@
import '../../core/utils/extensions.dart';
import '../entities/cart.dart';
import '../entities/promo.dart';
/// Decides which campaigns fire on a bill, and for how much.
///
/// Kept out of [Cart] on purpose. Cart owns arithmetic that must never be
/// wrong; this owns policy that a shop changes weekly. Mixing them would put a
/// marketing decision in the same class as the GST calculation.
class PromoEngine {
const PromoEngine._();
/// Evaluates [promos] against [cart] and returns what actually fires.
///
/// ### Stacking
///
/// All eligible **stackable** promos apply together. Of the **exclusive**
/// ones, only the single best applies — the one worth most to the shopper,
/// with [Promo.priority] breaking ties.
///
/// This is the conservative reading, and deliberately so. Letting two
/// percentages compound produces a discount nobody costed, and a shop finds
/// out at the end of the month rather than at the till.
///
/// The total is capped at the cart subtotal: no combination of campaigns can
/// make a bill negative, or turn a sale into a payout.
static List<AppliedPromo> evaluate({
required Cart cart,
required List<Promo> promos,
required DateTime at,
}) {
if (cart.isEmpty || promos.isEmpty) return const [];
final subtotal = cart.subtotal;
final eligible = <AppliedPromo>[];
for (final promo in promos) {
if (!promo.isLiveAt(at)) continue;
if (subtotal < promo.minBillValue) continue;
final amount = amountFor(promo: promo, cart: cart);
if (amount <= 0) continue;
eligible.add(AppliedPromo(promo: promo, amount: amount));
}
if (eligible.isEmpty) return const [];
final stackable = eligible.where((a) => a.promo.stackable).toList()
..sort((a, b) => a.promo.priority.compareTo(b.promo.priority));
final exclusive = eligible.where((a) => !a.promo.stackable).toList()
..sort((a, b) {
// Best for the shopper first; priority only breaks a genuine tie.
final byAmount = b.amount.compareTo(a.amount);
if (byAmount != 0) return byAmount;
return a.promo.priority.compareTo(b.promo.priority);
});
final chosen = <AppliedPromo>[
...stackable,
if (exclusive.isNotEmpty) exclusive.first,
]..sort((a, b) => a.promo.priority.compareTo(b.promo.priority));
return _capped(chosen, subtotal);
}
/// Trims the applied set so it can never exceed the bill.
///
/// Trimming the last one rather than scaling all of them keeps every other
/// figure on the receipt exactly what the campaign promised.
static List<AppliedPromo> _capped(List<AppliedPromo> applied, double ceiling) {
final result = <AppliedPromo>[];
var running = 0.0;
for (final entry in applied) {
final headroom = (ceiling - running).asMoney;
if (headroom <= 0) break;
final amount = entry.amount <= headroom ? entry.amount : headroom;
result.add(AppliedPromo(promo: entry.promo, amount: amount));
running = (running + amount).asMoney;
}
return result;
}
/// What one promo is worth on this cart, ignoring stacking rules.
static double amountFor({required Promo promo, required Cart cart}) {
final raw = switch (promo.type) {
PromoType.percentOffBill => cart.subtotal * (promo.value / 100),
PromoType.flatOffBill => promo.value,
// Both delegate the "does this line count?" question to the promo
// itself, because [Cart] asks the same question when it decides which
// lines carry the GST reduction. Answering it twice invites the two to
// drift apart.
PromoType.percentOffCategory =>
_percentOfMatching(cart, promo.value, promo),
PromoType.percentOffProduct =>
_percentOfMatching(cart, promo.value, promo),
PromoType.buyXGetY => _buyXGetY(cart, promo),
};
final capped = promo.maxDiscount == null
? raw
: (raw < promo.maxDiscount! ? raw : promo.maxDiscount!);
return capped.clamp(0, cart.subtotal).toDouble().asMoney;
}
static double _percentOfMatching(Cart cart, double percent, Promo promo) {
final base = cart.lines
.where((line) => promo.targets(line.product))
.fold(0.0, (sum, line) => sum + line.payable);
return base * (percent / 100);
}
/// Free units are the cheapest way to price this: for every group of
/// (buy + free), the shopper pays for `buy` of them.
///
/// Deliberately counts whole groups only. A "buy 2 get 1" on three items
/// gives one free; on five it still gives one, because the fifth has not
/// earned the second group.
static double _buyXGetY(Cart cart, Promo promo) {
if (promo.buyQuantity <= 0 || promo.freeQuantity <= 0) return 0;
final line = cart.lines.firstWhereOrNull(
(l) => l.product.id == promo.targetId,
);
if (line == null) return 0;
final groupSize = promo.buyQuantity + promo.freeQuantity;
final groups = line.quantity ~/ groupSize;
if (groups <= 0) return 0;
// Priced at the unit rate actually being charged, so a line that already
// carries a manual discount does not refund more than it took.
final unitPrice =
line.quantity <= 0 ? 0.0 : line.payable / line.quantity;
return groups * promo.freeQuantity * unitPrice;
}
}

View File

@@ -51,7 +51,8 @@ class CheckoutSale {
required Cart cart,
required List<PaymentSplit> payments,
required String cashierName,
String terminalId = 'TERM-01',
required String terminalId,
String? terminalCode,
}) async {
_validate(cart, payments);
await _assertStockAvailable(cart);
@@ -82,7 +83,11 @@ class CheckoutSale {
final transaction = SaleTransaction(
id: _uuid.v4(),
invoiceNumber: Formatters.invoiceNumber(sequence, now),
invoiceNumber: Formatters.invoiceNumber(
sequence,
now,
terminalCode: terminalCode ?? terminalId,
),
cart: cart,
payments: payments,
createdAt: now,

View File

@@ -1,6 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/constants/app_constants.dart';
import '../../../app/providers.dart';
import '../../../data/local/app_database.dart';
import '../../../data/remote/pos_auth_api.dart';
import '../../../domain/entities/pos_session.dart';
import '../../../domain/entities/store_account.dart';
/// Sign-in state for the terminal.
@@ -19,10 +23,28 @@ class Authenticating extends AuthState {
}
class Authenticated extends AuthState {
const Authenticated({required this.store, required this.user});
const Authenticated({
required this.store,
required this.user,
required this.login,
required this.session,
});
final StoreAccount store;
final StaffUser user;
/// Which credential opened this session. The authority on what the terminal
/// is allowed to show — not [user], which can be swapped at the till.
final TerminalLogin login;
/// What the back office answered with. Holds the bearer token every later
/// call needs, and the outlet this terminal is trading as.
final PosSession session;
StaffRole get role => login.role;
bool get isAdmin => login == TerminalLogin.admin;
bool get isCashier => login == TerminalLogin.cashier;
}
class AuthFailure extends AuthState {
@@ -31,76 +53,284 @@ class AuthFailure extends AuthState {
final String message;
}
/// Credentials that ship with the demo build.
class DemoCredentials {
const DemoCredentials._();
static const String email = 'admin@nearle.in';
static const String password = 'nearle123';
}
const _demoStore = StoreAccount(
id: 'store-001',
name: AppConstants.storeName,
email: DemoCredentials.email,
address: AppConstants.storeAddress,
gstin: AppConstants.storeGstin,
phone: AppConstants.storePhone,
staff: [
StaffUser(id: 'u1', name: 'Suriya', role: StaffRole.admin, pin: '1234'),
StaffUser(id: 'u2', name: 'Divya', role: StaffRole.manager, pin: '2345'),
StaffUser(id: 'u3', name: 'Rahul', role: StaffRole.cashier, pin: '3456'),
],
/// The two shapes this terminal can take.
///
/// No longer a credential — the back office owns those now. This is the mode
/// the shell runs in, decided from the role the login response came back with
/// (see [PosSession.isCashier]).
///
/// The split is what the two are *for*, not decoration:
///
/// * [admin] runs the whole shell and is the only mode that can pull the
/// catalogue. Signing out leaves the products on the terminal.
/// * [cashier] gets the billing screen and nothing else, and signing out
/// takes the catalogue with it.
enum TerminalLogin {
admin(
label: 'Admin',
role: StaffRole.admin,
blurb: 'Full shell — import products, promos, settings.',
),
cashier(
label: 'Cashier',
role: StaffRole.cashier,
blurb: 'Billing only, on the products the admin imported.',
);
/// Validates store credentials and holds the signed-in session.
///
/// Backed by a hardcoded account for now; swapping in a real identity provider
/// means changing only [signIn].
class AuthController extends StateNotifier<AuthState> {
AuthController() : super(const Unauthenticated());
const TerminalLogin({
required this.label,
required this.role,
required this.blurb,
});
final String label;
final StaffRole role;
final String blurb;
/// The catalogue is pulled once by an admin and billed against by whoever is
/// on the counter, so only the cashier's sign-out drops it. An admin closing
/// the shell is a handover, not the end of the day.
bool get clearsCatalogueOnSignOut => this == TerminalLogin.cashier;
/// Which shell the back office's role name lands in.
static TerminalLogin forSession(PosSession session) =>
session.isCashier ? TerminalLogin.cashier : TerminalLogin.admin;
}
/// Validates store credentials against the back office and holds the session.
class AuthController extends StateNotifier<AuthState> {
AuthController(this._ref) : super(const Unauthenticated());
final Ref _ref;
/// Whether signing out right now would wipe the products off this terminal.
///
/// Read *before* [signOut] by anything that needs to warn the operator, since
/// the session is gone by the time it returns.
bool get clearsCatalogueOnSignOut {
final current = state;
return current is Authenticated && current.login.clearsCatalogueOnSignOut;
}
/// The live bearer token, or null when nobody is signed in.
String? get token {
final current = state;
return current is Authenticated ? current.session.token : null;
}
/// Signs in against `POST /pos/login`.
///
/// [authname] is the account the back office issued for this till, e.g.
/// `supervisor.1135@pos.nearle.in`. The role that comes back — not anything
/// chosen on this screen — decides whether the terminal opens the admin
/// shell or the cashier till.
Future<bool> signIn({
required String email,
required String authname,
required String password,
}) async {
state = const Authenticating();
// Stand-in for the network round trip.
await Future<void>.delayed(const Duration(milliseconds: 600));
try {
final session = await _ref.read(posAuthApiProvider).login(
authname: authname,
password: password,
deviceId: _ref.read(terminalIdentityProvider).deviceId,
);
final normalised = email.trim().toLowerCase();
if (normalised != DemoCredentials.email) {
state = const AuthFailure('No store is registered against that email.');
await _open(session, persist: true);
return true;
} on AuthApiException catch (e) {
state = AuthFailure(e.message);
return false;
} on Object catch (e, stack) {
debugPrint('Sign-in failed: $e\n$stack');
state = const AuthFailure(
'Sign-in failed unexpectedly. Please try again.',
);
return false;
}
if (password != DemoCredentials.password) {
state = const AuthFailure('Incorrect password. Please try again.');
return false;
}
state = Authenticated(store: _demoStore, user: _demoStore.staff.first);
/// Re-opens the session stored on this terminal, if there is a live one.
///
/// Called once at startup, before the first frame, so a till that was signed
/// in when it lost power comes back up on the same shell rather than at a
/// login screen someone has to find the credentials for.
///
/// Returns false — and leaves the terminal signed out — when there is no
/// session, or the token has expired.
Future<bool> restore() async {
final session = await _ref.read(sessionStoreProvider).read();
if (session == null) return false;
try {
// Already on disk, so nothing to persist. Details are re-applied because
// a shop that changed its GSTIN in the back office should not print the
// old one just because this terminal never signed out.
await _open(session, persist: false);
return true;
} on Object catch (e, stack) {
debugPrint('Session restore failed: $e\n$stack');
await _ref.read(sessionStoreProvider).clear();
state = const Unauthenticated();
return false;
}
}
/// Turns a session into a live shell.
Future<void> _open(PosSession session, {required bool persist}) async {
if (persist) await _ref.read(sessionStoreProvider).save(session);
await _applyStoreDetails(session);
// Read directly rather than through `storeAccountProvider`: that provider
// watches this controller, so going through it here would rebuild it in
// the middle of the sign-in that is about to populate it. Setting the
// state below is what refreshes it, once.
final store = await _ref.read(storeRepositoryProvider).load(
email: session.authname,
);
state = Authenticated(
store: store,
// The operator is whoever the back office says signed in, not a local
// seeded account that happens to share a role.
user: session.user,
login: TerminalLogin.forSession(session),
session: session,
);
}
/// Copies the outlet's details out of the login response into this
/// terminal's own record.
///
/// The name, address, GSTIN and phone are printed on every invoice, where
/// they are a legal requirement rather than decoration — so the back office
/// is the source of truth for them, and a correction made there reaches the
/// till on the next sign-in. Blank fields are skipped, so a partial response
/// never erases details that are already right.
///
/// Delete this method if the terminal should keep whatever was typed into
/// Settings instead; nothing else depends on it.
Future<void> _applyStoreDetails(PosSession session) async {
final catalogue = _ref.read(localStoreProvider).catalogue;
Future<void> put(String key, String value) async {
if (value.trim().isEmpty) return;
await catalogue.setMeta(key, value.trim());
}
await put(MetaKeys.storeName, session.locationName);
await put(MetaKeys.storeAddress, session.address);
await put(MetaKeys.storeGstin, session.gstin.toUpperCase());
await put(MetaKeys.storePhone, session.phone);
await _followOutlet(session);
}
/// Re-points the terminal at the outlet the session belongs to.
///
/// The outlet id namespaces every sync topic, so a till moved between shops
/// would otherwise keep publishing its bills into the previous shop's books.
///
/// Goes through the identity store rather than writing the meta row alone:
/// the in-memory [TerminalIdentity] is what `syncConfigProvider` reads, and a
/// row on disk that nothing has re-read is a change that appears to have
/// worked and has not.
Future<void> _followOutlet(PosSession session) async {
if (session.storeId.trim().isEmpty) return;
final local = _ref.read(localStoreProvider);
if (local.terminal.storeId == session.storeId) return;
await local.identityStore.rename(storeId: session.storeId);
local.terminal = await local.identityStore.load();
// Rebuilds the sync configuration, and with it the catalogue source and
// the order transport, onto the new outlet's topics.
_ref.invalidate(terminalIdentityProvider);
}
/// Switches the active operator, checking their PIN.
///
/// Every bill is stamped with whoever is active, so this is the boundary that
/// decides who a sale is attributed to — it cannot be a bare selection from a
/// list. It changes who the bill names, never what the session may open:
/// [Authenticated.login] is untouched, so a cashier terminal stays a cashier
/// terminal.
Future<bool> switchUser(String pin) async {
final current = state;
if (current is! Authenticated) return false;
final store = _ref.read(localStoreProvider);
final user = await store.staff.authenticate(pin);
if (user == null) return false;
state = Authenticated(
store: current.store,
user: user,
login: current.login,
session: current.session,
);
return true;
}
/// Switches the active operator without signing the store out.
void switchUser(StaffUser user) {
/// Re-reads the store after staff or details change, keeping the session.
Future<void> refreshStore() async {
final current = state;
if (current is! Authenticated) return;
state = Authenticated(store: current.store, user: user);
final store = await _ref.read(storeRepositoryProvider).load(
email: current.session.authname,
);
_ref.invalidate(storeAccountProvider);
// The signed-in account comes from the back office and is not in this
// terminal's staff table, so a miss here means "not a local operator",
// not "deactivated". Only a local operator who has actually disappeared
// hands the session back to the account that opened it.
final me = store.staff.where((s) => s.id == current.user.id);
state = Authenticated(
store: store,
user: me.isNotEmpty ? me.first : current.session.user,
login: current.login,
session: current.session,
);
}
void signOut() => state = const Unauthenticated();
/// Ends the session, drops the stored copy of it, and — for a cashier only —
/// takes the catalogue with it.
///
/// Every cashier sign-out drops the products, whatever the reason for it.
/// The next shift should bill against what the back office answers with,
/// never a catalogue carried over, and a terminal left at a login screen
/// must not be sitting on a shop's prices and stock.
///
/// An admin signing out is the opposite case. They have just pulled the
/// products *so that* a cashier can pick the terminal up, so dropping the
/// table here would make the import pointless.
Future<void> signOut() async {
if (clearsCatalogueOnSignOut) {
await _ref.read(localStoreProvider).clearCatalogue();
}
// Unconditional, and before the state change: the token and the staff PINs
// in that blob must not survive a sign-out, and nothing below may be able
// to leave them on disk.
await _ref.read(sessionStoreProvider).clear();
state = const Unauthenticated();
}
void clearError() {
if (state is AuthFailure) state = const Unauthenticated();
}
}
final authControllerProvider =
StateNotifierProvider<AuthController, AuthState>((ref) => AuthController());
final authControllerProvider = StateNotifierProvider<AuthController, AuthState>(
AuthController.new,
);
/// The signed-in store, or null before sign-in.
final currentStoreProvider = Provider<StoreAccount?>((ref) {
@@ -113,3 +343,48 @@ final currentUserProvider = Provider<StaffUser?>((ref) {
final s = ref.watch(authControllerProvider);
return s is Authenticated ? s.user : null;
});
/// What the back office answered with, or null before sign-in.
///
/// Read this for the bearer token, the tenant, or the outlet list.
final posSessionProvider = Provider<PosSession?>((ref) {
final s = ref.watch(authControllerProvider);
return s is Authenticated ? s.session : null;
});
/// The account this terminal is signed in as.
final sessionAuthnameProvider = Provider<String>(
(ref) => ref.watch(posSessionProvider)?.authname ?? '',
);
/// Which mode is holding this session open, or null before sign-in.
final terminalLoginProvider = Provider<TerminalLogin?>((ref) {
final s = ref.watch(authControllerProvider);
return s is Authenticated ? s.login : null;
});
/// True when the terminal is locked down to the billing screen.
///
/// The one flag the shell reads: no sidebar, no back-office modules, sign-out
/// and events promoted to the header.
final isCashierModeProvider = Provider<bool>(
(ref) => ref.watch(terminalLoginProvider) == TerminalLogin.cashier,
);
final isAdminModeProvider = Provider<bool>(
(ref) => ref.watch(terminalLoginProvider) == TerminalLogin.admin,
);
/// True while anyone is still on a seeded or admin-reset PIN.
final mustChangePinProvider = Provider<bool>((ref) {
final user = ref.watch(currentUserProvider);
return user?.mustChangePin ?? false;
});
/// Re-opens a stored session before the first frame.
///
/// Awaited by the app shell, so the router never briefly shows a login screen
/// to a terminal that was already signed in.
final sessionBootstrapProvider = FutureProvider<void>(
(ref) => ref.read(authControllerProvider.notifier).restore(),
);

View File

@@ -4,14 +4,27 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/providers.dart';
import '../../../core/constants/asset_paths.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/validators.dart';
import '../../../core/widgets/primary_button.dart';
import '../providers/auth_controller.dart';
/// Store sign-in. The terminal shows this until a valid account is entered.
/// Store sign-in. The terminal shows this until the back office issues a
/// session.
///
/// One centred card on a plain background, at every width. The split-screen
/// version put a marketing panel beside the form, which meant the thing the
/// person came here to use was never in the middle of the screen, was a
/// different width on every monitor, and collapsed into a different layout
/// below 1000px. A till is signed into at the start of a shift by someone who
/// already bought the product; the pitch was costing the form its position.
///
/// There is no role picker. The role comes back in the login response and is
/// the server's to decide — a tab on this screen would only ever have been a
/// hint, and a hint that disagreed with the response would be a bug someone
/// spends an afternoon on.
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@@ -21,15 +34,16 @@ class LoginScreen extends ConsumerStatefulWidget {
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _email = TextEditingController(text: DemoCredentials.email);
final _password = TextEditingController(text: DemoCredentials.password);
final _authname = TextEditingController();
final _password = TextEditingController();
bool _obscure = true;
bool _rememberTerminal = true;
@override
void dispose() {
_email.dispose();
_authname.dispose();
_password.dispose();
super.dispose();
}
@@ -39,194 +53,89 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
if (!(_formKey.currentState?.validate() ?? false)) return;
final ok = await ref.read(authControllerProvider.notifier).signIn(
email: _email.text,
authname: _authname.text,
password: _password.text,
);
if (ok && mounted) context.go(AppRoutes.pos);
if (!ok || !mounted) return;
// Admin accounts land on the full shell, cashiers on the till. Read from
// the session that was just opened rather than from anything typed here.
context.go(AppRoutes.homeFor(ref.read(terminalLoginProvider)));
}
@override
Widget build(BuildContext context) {
final auth = ref.watch(authControllerProvider);
final session = ref.watch(cashierSessionProvider);
final busy = auth is Authenticating;
return Scaffold(
backgroundColor: AppColors.background,
body: LayoutBuilder(
builder: (context, constraints) {
// Below this there isn't room for the brand panel beside the form.
final showBrandPanel = constraints.maxWidth >= 1000;
return Row(
body: Stack(
fit: StackFit.expand,
children: [
if (showBrandPanel)
const Expanded(flex: 5, child: _BrandPanel()),
Expanded(
flex: 4,
child: _FormPanel(
const _Backdrop(),
SafeArea(
child: LayoutBuilder(
builder: (context, box) {
final tight = box.maxHeight < 620;
return SingleChildScrollView(
padding: EdgeInsets.symmetric(
horizontal: AppSpacing.xl,
vertical: tight ? AppSpacing.xl : AppSpacing.xxxl,
),
child: ConstrainedBox(
// Fills the viewport so the card is centred vertically,
// and scrolls the moment it cannot be.
constraints: BoxConstraints(
minHeight: (box.maxHeight - (tight ? 40 : 64))
.clamp(0.0, double.infinity),
),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: tight ? AppSpacing.lg : AppSpacing.xl,
),
_Card(
formKey: _formKey,
email: _email,
authname: _authname,
password: _password,
obscure: _obscure,
rememberTerminal: _rememberTerminal,
showCompactLogo: !showBrandPanel,
onToggleObscure: () => setState(() => _obscure = !_obscure),
busy: busy,
failure:
auth is AuthFailure ? auth.message : null,
onToggleObscure: () =>
setState(() => _obscure = !_obscure),
onToggleRemember: (v) =>
setState(() => _rememberTerminal = v ?? true),
onSubmit: _submit,
),
const SizedBox(height: AppSpacing.lg),
Center(
child: Text(
'Terminal ${session.terminalId}',
style: TextStyle(
fontSize: 11.5,
color: Colors.white.withValues(alpha: 0.72),
),
),
),
],
),
),
),
),
);
},
),
);
}
}
class _BrandPanel extends StatelessWidget {
const _BrandPanel();
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(gradient: AppColors.primaryGradient),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.giant),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(11),
),
alignment: Alignment.center,
child: const Text(
'N',
style: TextStyle(
color: AppColors.primary,
fontSize: 23,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(width: AppSpacing.md),
const Flexible(
child: Text(
'Nearle POS',
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
),
),
),
],
),
const SizedBox(height: AppSpacing.giant),
const Text(
'Billing that keeps up\nwith your counter.',
style: TextStyle(
color: Colors.white,
fontSize: 34,
height: 1.25,
fontWeight: FontWeight.w700,
letterSpacing: -1,
),
),
const SizedBox(height: AppSpacing.lg),
Text(
'Scanner-first billing, GST-ready invoices and loyalty '
'built in — for supermarkets, pharmacies and retail.',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.78),
fontSize: 15,
height: 1.6,
),
),
const SizedBox(height: AppSpacing.giant),
const _Feature(
icon: Icons.qr_code_scanner_rounded,
title: 'Scan and go',
body: 'No dialogs between items. Barcode to bill instantly.',
),
const _Feature(
icon: Icons.receipt_long_rounded,
title: 'GST compliant',
body: 'Per-slab tax split into CGST and SGST on every bill.',
),
const _Feature(
icon: Icons.stars_rounded,
title: 'Loyalty that runs itself',
body: 'Tiers and points applied without cashier input.',
),
],
),
),
),
),
).animate().fadeIn(duration: 300.ms);
}
}
class _Feature extends StatelessWidget {
const _Feature({
required this.icon,
required this.title,
required this.body,
});
final IconData icon;
final String title;
final String body;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.xl),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.16),
borderRadius: AppRadius.brSm,
),
child: Icon(icon, color: Colors.white, size: 19),
),
const SizedBox(width: AppSpacing.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
body,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
fontSize: 13,
height: 1.5,
),
),
],
),
),
],
),
@@ -234,41 +143,95 @@ class _Feature extends StatelessWidget {
}
}
class _FormPanel extends ConsumerWidget {
const _FormPanel({
/// The shopfront behind the form.
///
/// Darkened, because it is atmosphere rather than something to read: a sharp
/// photograph under a sign-in card competes with the two fields the person came
/// here to fill in. Scaled up slightly so the edges are pushed off-screen
/// instead of showing as a pale border.
///
/// Falls back to the plain background colour if the asset is missing, so an
/// undeclared file costs the login screen its atmosphere and not its function.
class _Backdrop extends StatelessWidget {
const _Backdrop();
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
ClipRect(
child: Transform.scale(
scale: 1.12,
child: Image.asset(
AssetPaths.loginBackground,
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
errorBuilder: (context, _, __) =>
const ColoredBox(color: AppColors.background),
),
),
),
// Two layers, not one: the flat wash guarantees contrast wherever the
// photograph happens to be pale, and the gradient puts the darkest part
// behind the card rather than spreading it evenly and flattening the
// image out.
const ColoredBox(color: Color(0x8A1A0B22)),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x66000000), Color(0x22000000), Color(0x77000000)],
),
),
),
],
);
}
}
class _Card extends StatelessWidget {
const _Card({
required this.formKey,
required this.email,
required this.authname,
required this.password,
required this.obscure,
required this.rememberTerminal,
required this.showCompactLogo,
required this.busy,
required this.failure,
required this.onToggleObscure,
required this.onToggleRemember,
required this.onSubmit,
});
final GlobalKey<FormState> formKey;
final TextEditingController email;
final TextEditingController authname;
final TextEditingController password;
final bool obscure;
final bool rememberTerminal;
final bool showCompactLogo;
final bool busy;
final String? failure;
final VoidCallback onToggleObscure;
final ValueChanged<bool?> onToggleRemember;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authControllerProvider);
final session = ref.watch(cashierSessionProvider);
final busy = auth is Authenticating;
return SafeArea(
child: Center(
child: SingleChildScrollView(
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppSpacing.xxl),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: AppRadius.brXl,
border: Border.all(color: AppColors.border),
boxShadow: const [
BoxShadow(
color: Color(0x33101828),
blurRadius: 40,
offset: Offset(0, 16),
),
],
),
child: Form(
key: formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
@@ -276,56 +239,42 @@ class _FormPanel extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (showCompactLogo) ...[
Center(
child: Container(
width: 52,
height: 52,
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(14),
),
alignment: Alignment.center,
child: const Text(
'N',
style: TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.w800,
),
),
),
),
const SizedBox(height: AppSpacing.xxl),
],
Text(
'Sign in to your store',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: AppSpacing.xs),
const Text(
'Use the credentials issued when your outlet was '
'Sign in to your store',
style: TextStyle(
fontSize: 19,
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
const Text(
'Use the terminal account issued when your outlet was '
'registered.',
style: TextStyle(
fontSize: 13.5,
fontSize: 13,
color: AppColors.textSecondary,
height: 1.5,
),
),
const SizedBox(height: AppSpacing.xxxl),
const SizedBox(height: AppSpacing.xl),
const _Label('Store email'),
const _Label('Terminal account'),
TextFormField(
controller: email,
controller: authname,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
enabled: !busy,
autocorrect: false,
// Deliberately not validated as an email address. It looks like
// one, but it is an account name the back office issues and its
// shape is theirs to change.
validator: (v) => (v ?? '').trim().isEmpty
? 'Store email is required'
: Validators.emailOptional(v),
? 'The terminal account is required'
: null,
decoration: const InputDecoration(
hintText: 'store@example.in',
hintText: 'supervisor.1135@pos.nearle.in',
prefixIcon: Icon(Icons.storefront_outlined),
),
),
@@ -338,11 +287,11 @@ class _FormPanel extends ConsumerWidget {
enabled: !busy,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => onSubmit(),
validator: (v) => (v ?? '').isEmpty
? 'Password is required'
: ((v ?? '').length < 6
? 'Password looks too short'
: null),
// Length is the server's rule to enforce. Refusing to *send* a
// short password only produces a second, different error message
// for the same wrong credential.
validator: (v) =>
(v ?? '').isEmpty ? 'Password is required' : null,
decoration: InputDecoration(
hintText: 'Enter your password',
prefixIcon: const Icon(Icons.lock_outline_rounded),
@@ -366,9 +315,7 @@ class _FormPanel extends ConsumerWidget {
crossAxisAlignment: WrapCrossAlignment.center,
children: [
InkWell(
onTap: busy
? null
: () => onToggleRemember(!rememberTerminal),
onTap: busy ? null : () => onToggleRemember(!rememberTerminal),
borderRadius: AppRadius.brXs,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
@@ -403,7 +350,7 @@ class _FormPanel extends ConsumerWidget {
],
),
if (auth is AuthFailure) ...[
if (failure != null) ...[
const SizedBox(height: AppSpacing.sm),
Container(
padding: const EdgeInsets.all(AppSpacing.md),
@@ -413,12 +360,15 @@ class _FormPanel extends ConsumerWidget {
),
child: Row(
children: [
const Icon(Icons.error_outline_rounded,
color: AppColors.danger, size: 18,),
const Icon(
Icons.error_outline_rounded,
color: AppColors.danger,
size: 18,
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
auth.message,
failure!,
style: const TextStyle(
color: AppColors.danger,
fontSize: 13,
@@ -430,7 +380,7 @@ class _FormPanel extends ConsumerWidget {
).animate().shake(duration: 320.ms, hz: 3),
],
const SizedBox(height: AppSpacing.xl),
const SizedBox(height: AppSpacing.lg),
PrimaryButton(
label: 'Sign in',
icon: Icons.login_rounded,
@@ -438,34 +388,10 @@ class _FormPanel extends ConsumerWidget {
busy: busy,
onPressed: onSubmit,
),
const SizedBox(height: AppSpacing.xl),
_DemoHint(
onFill: busy
? null
: () {
email.text = DemoCredentials.email;
password.text = DemoCredentials.password;
},
),
const SizedBox(height: AppSpacing.xxl),
Center(
child: Text(
'Terminal ${session.terminalId}',
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
),
),
),
],
),
),
),
),
),
);
).animate().fadeIn(duration: 300.ms).slideY(begin: 0.02, end: 0);
}
}
@@ -489,60 +415,3 @@ class _Label extends StatelessWidget {
);
}
}
class _DemoHint extends StatelessWidget {
const _DemoHint({this.onFill});
final VoidCallback? onFill;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brSm,
border: Border.all(color: AppColors.primaryBorder),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.info_outline_rounded,
size: 17, color: AppColors.primary,),
const SizedBox(width: AppSpacing.sm),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Demo account',
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
SizedBox(height: 2),
SelectableText(
'${DemoCredentials.email} · ${DemoCredentials.password}',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
TextButton(
onPressed: onFill,
style: TextButton.styleFrom(
minimumSize: const Size(0, 32),
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
),
child: const Text('Fill', style: TextStyle(fontSize: 12.5)),
),
],
),
);
}
}

View File

@@ -6,18 +6,24 @@ import '../../../app/providers.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/widgets/numeric_keypad.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../core/widgets/status_pill.dart';
import '../../../domain/entities/customer.dart';
import '../../pos/providers/cart_controller.dart';
import '../providers/customer_providers.dart';
/// Attaches a customer to the current bill using nothing but a mobile number.
/// Attaches a customer to the current bill.
///
/// Registration is deliberately minimal: an unknown number can be saved with
/// just a name, or the whole step skipped. Nothing here blocks the sale.
/// A mobile number, optionally a name, or skip. Nothing else — no lookup
/// result to read, no tier, no points balance. Those were a screenful of
/// information nobody at a counter acts on, in front of a queue, for a step
/// that is optional in the first place.
///
/// The lookup still happens; it just does not show. On save the number is
/// matched against what the terminal already holds, so a returning shopper is
/// attached to their existing record rather than duplicated — the loyalty
/// figures stay correct, they simply are not read out at the till.
Future<void> showCustomerCaptureSheet(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
@@ -35,13 +41,14 @@ class _CustomerCaptureSheet extends ConsumerStatefulWidget {
_CustomerCaptureSheetState();
}
class _CustomerCaptureSheetState
extends ConsumerState<_CustomerCaptureSheet> {
class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
String _digits = '';
final _name = TextEditingController();
bool _saving = false;
String? _error;
bool get _complete => _digits.length == AppConstants.mobileNumberLength;
@override
void dispose() {
_name.dispose();
@@ -54,15 +61,14 @@ class _CustomerCaptureSheetState
_digits += d;
_error = null;
});
if (_digits.length == AppConstants.mobileNumberLength) {
ref.read(customerLookupProvider.notifier).search(_digits);
}
}
void _backspace() {
if (_digits.isEmpty) return;
setState(() => _digits = _digits.substring(0, _digits.length - 1));
ref.read(customerLookupProvider.notifier).reset();
setState(() {
_digits = _digits.substring(0, _digits.length - 1);
_error = null;
});
}
void _clear() {
@@ -70,7 +76,6 @@ class _CustomerCaptureSheetState
_digits = '';
_error = null;
});
ref.read(customerLookupProvider.notifier).reset();
}
void _attachAndClose(Customer? customer) {
@@ -78,22 +83,38 @@ class _CustomerCaptureSheetState
Navigator.of(context).pop();
}
/// Saves with whatever was given. Both fields are optional: a bare number
/// is still worth keeping, because it is what the WhatsApp bill is sent to.
Future<void> _quickRegister() async {
final typed = _name.text.trim();
final name = typed.isEmpty
? 'Customer ${_digits.substring(_digits.length - 4)}'
: typed;
/// Saves with whatever was given.
///
/// The name is optional: a bare number is still worth keeping, because it is
/// what the WhatsApp bill is sent to. An existing record wins over creating a
/// second one — silently, because a cashier does not need to be told the
/// shopper has been here before to finish the sale.
Future<void> _save() async {
if (!_complete) return;
setState(() {
_saving = true;
_error = null;
});
final typed = _name.text.trim();
final repository = ref.read(customerRepositoryProvider);
try {
final created = await ref.read(customerRepositoryProvider).create(
Customer(id: '', name: name, mobile: _digits),
final existing = await repository.findByMobile(_digits);
if (existing != null) {
if (mounted) _attachAndClose(existing);
return;
}
final created = await repository.create(
Customer(
id: '',
name: typed.isEmpty
? 'Customer ${_digits.substring(_digits.length - 4)}'
: typed,
mobile: _digits,
),
);
ref.invalidate(recentCustomersProvider);
if (mounted) _attachAndClose(created);
@@ -108,7 +129,6 @@ class _CustomerCaptureSheetState
@override
Widget build(BuildContext context) {
final lookup = ref.watch(customerLookupProvider);
final attached = ref.watch(
cartControllerProvider.select((c) => c.customer),
);
@@ -130,36 +150,100 @@ class _CustomerCaptureSheetState
mainAxisSize: MainAxisSize.min,
children: [
_grabber(),
// Capped and centred. A modal sheet on a 27-inch till used to run
// the full width of the screen, which put the keypad and the save
// button at opposite ends of the desk.
Flexible(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_header(attached),
const Divider(height: 1),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.xxl),
child: LayoutBuilder(
builder: (context, constraints) {
// Side by side once there is room for both columns.
final wide = constraints.maxWidth >= 720;
final entry = _entryColumn();
final result = _resultColumn(lookup);
if (!wide) {
return Column(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xxl,
AppSpacing.xl,
AppSpacing.xxl,
AppSpacing.xxl,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
entry,
const SizedBox(height: AppSpacing.xl),
result,
_display(),
const SizedBox(height: AppSpacing.sm),
Text(
_complete
? 'Ready to save'
: '${AppConstants.mobileNumberLength - _digits.length}'
' more digit(s)',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
),
),
const SizedBox(height: AppSpacing.lg),
Center(
child: NumericKeypad(
maxWidth: 320,
onKey: _append,
onBackspace: _backspace,
onClear: _clear,
),
),
const SizedBox(height: AppSpacing.lg),
TextField(
controller: _name,
textCapitalization:
TextCapitalization.words,
enabled: !_saving,
onSubmitted: (_) => _save(),
inputFormatters: [
LengthLimitingTextInputFormatter(60),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: entry),
const SizedBox(width: AppSpacing.xxl),
Expanded(child: result),
decoration: const InputDecoration(
labelText: 'Customer name',
hintText: 'Optional',
prefixIcon:
Icon(Icons.person_outline_rounded),
),
),
if (_error != null) ...[
const SizedBox(height: AppSpacing.sm),
Text(
_error!,
style: const TextStyle(
color: AppColors.danger,
fontSize: 12.5,
),
),
],
);
},
const SizedBox(height: AppSpacing.lg),
PrimaryButton(
label: 'Save & use',
icon: Icons.check_rounded,
large: true,
busy: _saving,
onPressed: _complete ? _save : null,
),
const SizedBox(height: AppSpacing.sm),
PrimaryButton(
label: 'Skip',
tone: ButtonTone.neutral,
onPressed:
_saving ? null : () => _attachAndClose(null),
),
],
),
),
),
],
),
),
),
),
@@ -184,11 +268,23 @@ class _CustomerCaptureSheetState
padding: const EdgeInsets.fromLTRB(
AppSpacing.xxl,
0,
AppSpacing.md,
AppSpacing.lg,
AppSpacing.lg,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brSm,
border: Border.all(color: AppColors.primaryBorder),
),
child: const Icon(Icons.person_add_alt_1_outlined,
size: 20, color: AppColors.primary,),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -197,10 +293,16 @@ class _CustomerCaptureSheetState
Text(
attached == null ? 'Add customer' : 'Change customer',
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
color: AppColors.textPrimary,
height: 1.2,
),
),
const Text(
'Optional — for loyalty points and tier discounts',
'Optional — the bill can be sent to this number',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5,
@@ -211,357 +313,84 @@ class _CustomerCaptureSheetState
),
),
const SizedBox(width: AppSpacing.sm),
TextButton(
onPressed: () => _attachAndClose(null),
style: TextButton.styleFrom(
foregroundColor: AppColors.textSecondary,
),
child: const Text('Skip'),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close_rounded),
color: AppColors.textTertiary,
tooltip: 'Close',
),
],
),
);
Widget _entryColumn() => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_display(),
const SizedBox(height: AppSpacing.xl),
Center(
child: NumericKeypad(
maxWidth: 340,
onKey: _append,
onBackspace: _backspace,
onClear: _clear,
),
),
],
);
/// The number as it is keyed, grouped 5 + 5 the way it is read aloud.
Widget _display() {
final filled = _digits.isNotEmpty;
final head = _digits.length <= 5 ? _digits : _digits.substring(0, 5);
final tail = _digits.length <= 5 ? '' : _digits.substring(5);
Widget _display() => Container(
height: 68,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
return Container(
height: 64,
padding: const EdgeInsets.only(left: AppSpacing.md, right: AppSpacing.xs),
decoration: BoxDecoration(
color: AppColors.primarySurface,
color: filled ? AppColors.surface : AppColors.surfaceAlt,
borderRadius: AppRadius.brLg,
border: Border.all(color: AppColors.primaryBorder),
border: Border.all(
color: filled ? AppColors.primary : AppColors.border,
width: filled ? 1.4 : 1,
),
),
child: Row(
children: [
const Text(
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 3,
),
decoration: const BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: AppRadius.brXs,
),
child: const Text(
'+91',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textSecondary,
),
),
),
const SizedBox(width: AppSpacing.md),
// FittedBox guarantees ten digits fit at any sheet width.
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
_digits.isEmpty
? ' '
: _digits.split('').join(' '),
child: filled
? Text(
tail.isEmpty ? head : '$head $tail',
style: AppTypography.money(23).copyWith(
letterSpacing: 1.5,
color: AppColors.textPrimary,
),
)
: Text(
'Mobile number',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: _digits.isEmpty
? AppColors.textTertiary
: AppColors.textPrimary,
fontSize: 16,
color: AppColors.textTertiary.withValues(alpha: 0.9),
),
),
),
),
if (_digits.isNotEmpty)
if (filled)
IconButton(
onPressed: _clear,
icon: const Icon(Icons.close_rounded, size: 20),
icon: const Icon(Icons.backspace_outlined, size: 18),
color: AppColors.textTertiary,
tooltip: 'Clear',
),
],
),
);
Widget _resultColumn(CustomerLookupState state) => switch (state) {
LookupIdle() => _idle(),
LookupSearching() => const Padding(
padding: EdgeInsets.symmetric(vertical: AppSpacing.giant),
child: Center(
child: CircularProgressIndicator(color: AppColors.primary),
),
),
LookupFound(:final customer) => _found(customer),
LookupNotFound() => _notFound(),
LookupError(:final message) => _message(
Icons.error_outline_rounded,
AppColors.danger,
message,
),
};
Widget _idle() {
final recent = ref.watch(recentCustomersProvider).value ?? const [];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_message(
Icons.dialpad_rounded,
AppColors.textTertiary,
'Key in a 10-digit mobile number — the lookup runs automatically. '
'Both fields are optional.',
),
const SizedBox(height: AppSpacing.lg),
_nameField(),
if (recent.isNotEmpty) ...[
const SizedBox(height: AppSpacing.xl),
const Align(
alignment: Alignment.centerLeft,
child: Text(
'Recent',
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
),
const SizedBox(height: AppSpacing.sm),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
for (final c in recent.take(4))
ActionChip(
avatar: CircleAvatar(
radius: 11,
backgroundColor: AppColors.primarySurface,
child: Text(
Formatters.initials(c.name),
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
label: Text(
c.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12.5),
),
onPressed: () => _attachAndClose(c),
),
],
),
],
],
);
}
Widget _found(Customer c) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: const BoxDecoration(
color: AppColors.successSurface,
borderRadius: AppRadius.brLg,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
CircleAvatar(
radius: 22,
backgroundColor: AppColors.surface,
child: Text(
Formatters.initials(c.name),
style: const TextStyle(
color: AppColors.primary,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
c.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
Text(
Formatters.mobile(c.mobile),
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
),
),
],
),
),
const SizedBox(width: AppSpacing.sm),
StatusPill.tier(c.tier, dense: true),
],
),
const SizedBox(height: AppSpacing.md),
Row(
children: [
Expanded(
child: _miniStat(
'${c.loyaltyPoints}', 'points held',),
),
Expanded(
child: _miniStat(
Formatters.money(c.redeemableValue),
'redeemable',
),
),
if (c.tier.discountRate > 0)
Expanded(
child: _miniStat(
Formatters.percent(c.tier.discountRate),
'auto discount',
),
),
],
),
],
),
),
const SizedBox(height: AppSpacing.lg),
PrimaryButton(
label: 'Use this customer',
icon: Icons.check_rounded,
large: true,
onPressed: () => _attachAndClose(c),
),
],
);
Widget _notFound() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_message(
Icons.person_search_rounded,
AppColors.warning,
'New number. Add a name if you have it — the bill can be sent to '
'this number on WhatsApp either way.',
),
const SizedBox(height: AppSpacing.lg),
_nameField(),
if (_error != null) ...[
const SizedBox(height: AppSpacing.sm),
Text(
_error!,
style: const TextStyle(color: AppColors.danger, fontSize: 12.5),
),
],
const SizedBox(height: AppSpacing.lg),
PrimaryButton(
label: 'Save & use',
icon: Icons.person_add_alt_1_rounded,
large: true,
busy: _saving,
onPressed: _quickRegister,
),
const SizedBox(height: AppSpacing.sm),
PrimaryButton(
label: 'Continue without customer',
tone: ButtonTone.neutral,
onPressed: _saving ? null : () => _attachAndClose(null),
),
],
);
Widget _nameField() => TextField(
controller: _name,
textCapitalization: TextCapitalization.words,
enabled: !_saving,
onSubmitted: (_) => _quickRegister(),
inputFormatters: [LengthLimitingTextInputFormatter(60)],
decoration: const InputDecoration(
labelText: 'Customer name',
hintText: 'Optional',
prefixIcon: Icon(Icons.person_outline_rounded),
),
);
Widget _miniStat(String value, String label) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
value,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.success,
),
),
),
Text(
label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
),
),
],
);
Widget _message(IconData icon, Color color, String text) => Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: AppRadius.brMd,
border: Border.all(color: AppColors.border),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 19, color: color),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Text(
text,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
height: 1.5,
),
),
),
],
),
);
}

View File

@@ -15,6 +15,8 @@ class PrinterSettings {
this.printerName,
this.autoPrint = false,
this.openDrawer = true,
this.drawerHost,
this.drawerPort = 9100,
});
/// Target passed to `directPrintPdf`. Null means "use the system default".
@@ -31,7 +33,16 @@ class PrinterSettings {
final bool openDrawer;
/// IP address of the receipt printer the drawer is wired to.
///
/// Separate from [printerUrl] because that is an opaque platform handle for
/// the PDF driver, which cannot carry raw ESC/POS bytes. The drawer kick
/// needs a socket, so it needs an address.
final String? drawerHost;
final int drawerPort;
bool get hasPrinter => printerUrl != null;
bool get hasDrawer => (drawerHost ?? '').isNotEmpty;
PrinterSettings copyWith({
String? printerUrl,
@@ -39,12 +50,16 @@ class PrinterSettings {
bool clearPrinter = false,
bool? autoPrint,
bool? openDrawer,
String? drawerHost,
int? drawerPort,
}) {
return PrinterSettings(
printerUrl: clearPrinter ? null : (printerUrl ?? this.printerUrl),
printerName: clearPrinter ? null : (printerName ?? this.printerName),
autoPrint: autoPrint ?? this.autoPrint,
openDrawer: openDrawer ?? this.openDrawer,
drawerHost: drawerHost ?? this.drawerHost,
drawerPort: drawerPort ?? this.drawerPort,
);
}
}
@@ -61,11 +76,26 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
if (!store.isReady) return;
final dao = store.catalogue;
// Read every value first, then assign. Written inline the four awaits still
// run before the assignment, so leaving Settings mid-read threw
// "used after dispose" — which reaches the cashier as a red screen.
final url = await dao.meta(MetaKeys.printerUrl);
final name = await dao.meta(MetaKeys.printerName);
final autoPrint = await dao.meta(MetaKeys.autoPrint);
final openDrawer = await dao.meta(MetaKeys.openDrawer);
final drawerHost = await dao.meta(MetaKeys.drawerHost);
final drawerPort = await dao.meta(MetaKeys.drawerPort);
if (!mounted) return;
state = PrinterSettings(
printerUrl: await dao.meta(MetaKeys.printerUrl),
printerName: await dao.meta(MetaKeys.printerName),
autoPrint: (await dao.meta(MetaKeys.autoPrint)) == '1',
openDrawer: (await dao.meta(MetaKeys.openDrawer)) != '0',
printerUrl: url,
printerName: name,
autoPrint: autoPrint == '1',
openDrawer: openDrawer != '0',
drawerHost: drawerHost,
drawerPort: int.tryParse(drawerPort ?? '') ?? 9100,
);
}
@@ -75,12 +105,14 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
if (printer == null) {
await dao.setMeta(MetaKeys.printerUrl, '');
await dao.setMeta(MetaKeys.printerName, '');
if (!mounted) return;
state = state.copyWith(clearPrinter: true, autoPrint: false);
return;
}
await dao.setMeta(MetaKeys.printerUrl, printer.url);
await dao.setMeta(MetaKeys.printerName, printer.name);
if (!mounted) return;
state = state.copyWith(
printerUrl: printer.url,
printerName: printer.name,
@@ -95,6 +127,7 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
.read(localStoreProvider)
.catalogue
.setMeta(MetaKeys.autoPrint, value ? '1' : '0');
if (!mounted) return;
state = state.copyWith(autoPrint: value);
}
@@ -103,8 +136,21 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
.read(localStoreProvider)
.catalogue
.setMeta(MetaKeys.openDrawer, value ? '1' : '0');
if (!mounted) return;
state = state.copyWith(openDrawer: value);
}
/// Points the drawer kick at a printer.
///
/// A blank host disables it — which is the honest state for a USB printer,
/// since there is no raw path to one from Flutter.
Future<void> setDrawerAddress(String host, int port) async {
final dao = _ref.read(localStoreProvider).catalogue;
await dao.setMeta(MetaKeys.drawerHost, host.trim());
await dao.setMeta(MetaKeys.drawerPort, '$port');
if (!mounted) return;
state = state.copyWith(drawerHost: host.trim(), drawerPort: port);
}
}
final printerSettingsProvider =

View File

@@ -5,8 +5,8 @@ import '../../../app/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/status_pill.dart';
import '../../../domain/entities/customer.dart';
import '../../customer/widgets/customer_capture_sheet.dart';
import '../widgets/module_widgets.dart';
/// Full customer book, independent of the six shown during billing.
@@ -23,14 +23,6 @@ class CustomersView extends ConsumerStatefulWidget {
class _CustomersViewState extends ConsumerState<CustomersView> {
String _query = '';
MembershipTier? _tier;
Color _tierColor(MembershipTier t) => switch (t) {
MembershipTier.bronze => AppColors.tierBronze,
MembershipTier.silver => AppColors.tierSilver,
MembershipTier.gold => AppColors.tierGold,
MembershipTier.platinum => AppColors.tierPlatinum,
};
@override
Widget build(BuildContext context) {
@@ -38,10 +30,9 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
final filtered = all.where((c) {
final q = _query.trim().toLowerCase();
final matchesQuery = q.isEmpty ||
return q.isEmpty ||
c.name.toLowerCase().contains(q) ||
c.mobile.contains(q);
return matchesQuery && (_tier == null || c.tier == _tier);
}).toList();
final lifetime = all.fold<double>(0, (s, c) => s + c.lifetimeSpend);
@@ -84,31 +75,17 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
),
const SizedBox(height: AppSpacing.lg),
PanelCard(
title: 'Tier distribution',
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final t in MembershipTier.values)
ProgressRow(
label: '${t.label} · '
'${(t.discountRate * 100).toStringAsFixed(0)}% off',
value: '${all.where((c) => c.tier == t).length}',
fraction: all.isEmpty
? 0
: all.where((c) => c.tier == t).length / all.length,
color: _tierColor(t),
),
],
),
),
const SizedBox(height: AppSpacing.lg),
PanelCard(
title: 'Customer book',
subtitle: '${filtered.length} shown',
action: FilledButton.icon(
onPressed: () {},
// Reuses the sheet the till already opens at checkout, so a
// customer added here is validated the same way — including the
// duplicate-mobile guard.
onPressed: () async {
await showCustomerCaptureSheet(context);
ref.invalidate(allCustomersProvider);
},
icon: const Icon(Icons.person_add_alt_1_rounded, size: 17),
label: const Text('Add customer'),
style: FilledButton.styleFrom(
@@ -127,45 +104,11 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
isDense: true,
),
),
const SizedBox(height: AppSpacing.md),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
ChoiceChip(
label: const Text('All tiers'),
selected: _tier == null,
onSelected: (_) => setState(() => _tier = null),
labelStyle: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: _tier == null
? Colors.white
: AppColors.textSecondary,
),
),
for (final t in MembershipTier.values)
ChoiceChip(
label: Text(t.label),
selected: _tier == t,
onSelected: (_) =>
setState(() => _tier = _tier == t ? null : t),
labelStyle: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: _tier == t
? Colors.white
: AppColors.textSecondary,
),
),
],
),
const SizedBox(height: AppSpacing.lg),
ResponsiveTable(
columns: const [
TableCol('Customer', flex: 4),
TableCol('Mobile', flex: 3, priority: 1),
TableCol('Tier', flex: 2),
TableCol('Points', flex: 2, numeric: true, priority: 1),
TableCol('Lifetime', flex: 2, numeric: true),
TableCol('Visits', flex: 2, numeric: true, priority: 1),
@@ -192,7 +135,6 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
],
),
Cell(Formatters.mobile(c.mobile), mono: true),
StatusPill.tier(c.tier, dense: true),
Cell('${c.loyaltyPoints}', mono: true),
Cell(Formatters.moneyCompact(c.lifetimeSpend),
mono: true, bold: true,),

View File

@@ -1,20 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/transaction.dart';
import '../../../domain/repositories/sync_repository.dart';
import '../../../data/sync/sync_engine.dart';
import '../../sync/providers/sync_controller.dart';
import '../widgets/module_widgets.dart';
/// End-of-day sync.
/// What this terminal has traded, and what has reached the server.
///
/// Shows what the terminal produced today and uploads every bill still at
/// `sync_status = 0`. Accepted bills flip to 1; anything that fails stays at 0
/// and is retried on the next tap.
/// Read-only. Uploading is the sync engine's job — it pushes after every sale
/// and retries on its own — so this page reports rather than drives. The
/// warning banner at the top is the exception: a bill the engine has given up
/// on is the one thing here that needs a person to notice it.
class EventsView extends ConsumerWidget {
const EventsView({super.key});
@@ -23,13 +23,22 @@ class EventsView extends ConsumerWidget {
final report = ref.watch(todayReportProvider);
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
final rows = ref.watch(orderSyncRowsProvider).value ?? const [];
final syncState = ref.watch(orderSyncProvider);
final events = ref.watch(syncEventsProvider);
// The engine may not have emitted yet on a cold start, so fall back to
// its current value rather than showing nothing. This is the same state
// that drives the header pill — it is what actually knows whether the
// automatic push right after a sale succeeded, not just what the manual
// "Sync" button on this page last did.
final engine = ref.watch(syncEngineStateProvider).value ??
ref.watch(syncEngineProvider).state;
final r = report.value;
return ModulePage(
children: [
if (engine.lastError != null && pending > 0)
_EngineWarningBanner(engine: engine),
Wrap(
spacing: AppSpacing.lg,
runSpacing: AppSpacing.lg,
@@ -70,118 +79,30 @@ class EventsView extends ConsumerWidget {
),
const SizedBox(height: AppSpacing.lg),
PanelCard(
title: 'Upload bills to server',
subtitle: r == null
? 'Reading today\u2019s trading from SQLite\u2026'
: '${Formatters.date(r.businessDate)} \u00b7 ${r.cashierName} '
'\u00b7 ${r.terminalId}',
action: TagChip(
pending > 0 ? '$pending pending' : 'All synced',
color: pending > 0 ? AppColors.warning : AppColors.success,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (r != null && !r.isEmpty) ...[
_row('Bills', '${r.billCount}'),
_row('Items sold', r.itemCount.toStringAsFixed(0)),
_row('Gross sales', Formatters.money(r.grossSales)),
_row('GST collected', Formatters.money(r.taxCollected)),
_row('Discount given', Formatters.money(r.discountGiven)),
_row('Average basket', Formatters.money(r.averageBasket)),
if (r.paymentBreakdown.isNotEmpty) ...[
const Divider(height: AppSpacing.xxl),
for (final e in r.paymentBreakdown.entries)
ProgressRow(
label: '${e.key.emoji} ${e.key.label}',
value: Formatters.money(e.value),
fraction:
r.grossSales <= 0 ? 0 : e.value / r.grossSales,
color: _methodColor(e.key),
),
],
const SizedBox(height: AppSpacing.lg),
],
if (syncState is SyncRunning) ...[
Text(
syncState.stage,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
const SizedBox(height: AppSpacing.sm),
ClipRRect(
borderRadius: AppRadius.brPill,
child: LinearProgressIndicator(
value: syncState.progress,
minHeight: 8,
backgroundColor: AppColors.divider,
valueColor:
const AlwaysStoppedAnimation<Color>(AppColors.primary),
),
),
const SizedBox(height: AppSpacing.lg),
],
if (syncState is SyncFinished)
_outcomeBanner(syncState.outcome),
PrimaryButton(
label: pending > 0
? 'Sync $pending bill${pending == 1 ? '' : 's'}'
: 'Nothing to sync',
icon: Icons.cloud_upload_rounded,
large: true,
busy: syncState is SyncRunning,
onPressed: pending == 0
? null
: () => ref.read(orderSyncProvider.notifier).run(),
),
const SizedBox(height: AppSpacing.md),
const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.shield_outlined,
size: 15, color: AppColors.textTertiary,),
SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
'Bills are written to SQLite the moment a sale '
'completes. A failed upload changes nothing on disk — '
'every bill stays until the server confirms it.',
style: TextStyle(
fontSize: 12,
color: AppColors.textTertiary,
height: 1.5,
),
),
),
],
),
],
),
),
const SizedBox(height: AppSpacing.lg),
PanelCard(
title: 'Orders',
subtitle: '${rows.length} stored \u00b7 $pending awaiting upload',
child: ResponsiveTable(
columns: const [
TableCol('Invoice', flex: 3),
TableCol('Time', flex: 2, priority: 1),
TableCol('Date & time', flex: 3, priority: 1),
TableCol('Items', flex: 1, numeric: true, priority: 2),
TableCol('Total', flex: 2, numeric: true),
TableCol('Sync', flex: 2, numeric: true),
],
rows: rows
.map((o) => [
Cell(o.invoiceNumber, bold: true, mono: true),
Cell(Formatters.time(o.createdAt),
color: AppColors.textTertiary,),
// Date sits with the time because this table outlives the
// day it was rung on — bills stay on the terminal until
// they are purged, so a bare clock time is ambiguous the
// moment the shop opens again.
Cell(
'${Formatters.date(o.createdAt)} \u00b7 '
'${Formatters.time(o.createdAt)}',
color: AppColors.textTertiary,
),
Cell(_units(o.itemCount), mono: true),
Cell(Formatters.money(o.total), mono: true, bold: true),
TagChip(
o.isSynced ? 'Synced' : 'Pending',
@@ -192,121 +113,87 @@ class EventsView extends ConsumerWidget {
.toList(),
),
),
if (events.isNotEmpty) ...[
const SizedBox(height: AppSpacing.lg),
PanelCard(
title: 'Sync history',
subtitle: 'This session',
child: ResponsiveTable(
columns: const [
TableCol('Event', flex: 3),
TableCol('Detail', flex: 5, priority: 1),
TableCol('Time', flex: 2, numeric: true),
],
rows: events
.map((e) => [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
e.type.isInbound
? Icons.cloud_download_rounded
: Icons.cloud_upload_rounded,
size: 15,
color: AppColors.textSecondary,
),
const SizedBox(width: AppSpacing.sm),
Flexible(child: Cell(e.type.label, bold: true)),
],
),
Cell(
e.error ?? e.summary,
color: e.error != null
? AppColors.danger
: AppColors.textSecondary,
),
Cell(Formatters.time(e.createdAt),
color: AppColors.textTertiary,),
],)
.toList(),
),
),
],
],
);
}
Widget _outcomeBanner(SyncOutcome outcome) {
final ok = outcome.isSuccess;
final uploaded = outcome.uploaded;
final attempted = outcome.attempted;
/// Units on a bill. Whole where they are whole — loose goods are sold by
/// weight, so "2.5" is a real answer here and rounding it would be a lie.
static String _units(double count) =>
count % 1 == 0 ? count.toStringAsFixed(0) : count.toStringAsFixed(2);
}
/// Flags a bill that could not reach the server on its own.
///
/// Sits above everything else on the page because a bill stuck here is the
/// one thing on this screen that needs a person to notice it, rather than
/// just waiting for the next background retry.
class _EngineWarningBanner extends StatelessWidget {
const _EngineWarningBanner({required this.engine});
final SyncEngineState engine;
@override
Widget build(BuildContext context) {
final halted = engine.isHalted;
final color = halted ? AppColors.danger : AppColors.warning;
final surface = halted ? AppColors.dangerSurface : AppColors.warningSurface;
final retry = engine.nextAttemptAt;
final retryNote = halted
? 'Retrying will not help until this is fixed.'
: retry == null
? 'It will retry automatically.'
: 'It will retry automatically at ${Formatters.time(retry)}.';
return Container(
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: ok ? AppColors.successSurface : AppColors.dangerSurface,
borderRadius: AppRadius.brSm,
),
decoration: BoxDecoration(color: surface, borderRadius: AppRadius.brLg),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
ok ? Icons.check_circle_outline_rounded : Icons.wifi_off_rounded,
size: 18,
color: ok ? AppColors.success : AppColors.danger,
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
ok
? '$uploaded of $attempted bills uploaded and marked synced.'
: '${outcome.error}',
style: TextStyle(
fontSize: 13,
height: 1.45,
color: ok ? AppColors.success : AppColors.danger,
),
),
),
],
),
);
}
static Color _methodColor(PaymentMethod m) => switch (m) {
PaymentMethod.cash => AppColors.success,
PaymentMethod.card => AppColors.info,
PaymentMethod.upi => AppColors.primary,
PaymentMethod.wallet => AppColors.warning,
PaymentMethod.giftCard => AppColors.tierGold,
PaymentMethod.loyalty => AppColors.tierSilver,
};
Widget _row(String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
child: Row(
children: [
Expanded(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13.5,
color: AppColors.textSecondary,
),
),
),
Icon(Icons.wifi_off_rounded, size: 20, color: color),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
halted
? 'Sync halted — ${engine.pending} bill(s) not sent'
: "Couldn't reach the server — "
'${engine.pending} bill(s) not sent',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: color,
),
),
const SizedBox(height: 2),
Text(
engine.lastError ?? 'The last upload attempt failed.',
style: TextStyle(
fontSize: 12.5,
color: color,
height: 1.45,
),
),
const SizedBox(height: 4),
Text(
'$retryNote Every bill is still safe on this terminal — '
'nothing is lost while it waits.',
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
fontSize: 12,
color: AppColors.textSecondary,
height: 1.45,
),
),
],
),
),
],
),
);
}
}

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/formatters.dart';
@@ -25,14 +24,18 @@ class ProductImportView extends ConsumerWidget {
final ready = ref.watch(catalogueReadyProvider);
final lastImport = ref.watch(lastImportAtProvider);
final products = ref.watch(allProductsProvider).value ?? const <Product>[];
final revision = ref.watch(syncRepositoryProvider).catalogueRevision;
return ModulePage(
children: [
if (!ready) _NotImportedBanner(state: state),
if (ready) ...[
// Start-aligned, so the tiles begin at the same left edge as the
// panels below them rather than drifting with the run's width.
Wrap(
alignment: WrapAlignment.start,
runAlignment: WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.start,
spacing: AppSpacing.lg,
runSpacing: AppSpacing.lg,
children: [
@@ -43,13 +46,6 @@ class ProductImportView extends ConsumerWidget {
color: AppColors.success,
caption: 'available offline',
),
StatTile(
label: 'Catalogue Revision',
value: revision ?? '',
icon: Icons.tag_rounded,
color: AppColors.info,
caption: 'server version',
),
StatTile(
label: 'Last Imported',
value: lastImport == null
@@ -91,7 +87,7 @@ class ProductImportView extends ConsumerWidget {
child: ResponsiveTable(
columns: const [
TableCol('Product', flex: 4),
TableCol('SKU', flex: 3, priority: 1),
TableCol('Barcode', flex: 3, priority: 1),
TableCol('Category', flex: 2, priority: 1),
TableCol('Price', flex: 2, numeric: true),
TableCol('Stock', flex: 2, numeric: true),
@@ -107,7 +103,8 @@ class ProductImportView extends ConsumerWidget {
Flexible(child: Cell(p.name, bold: true)),
],
),
Cell(p.sku, color: AppColors.textTertiary),
Cell(p.barcode,
color: AppColors.textTertiary, mono: true,),
TagChip(p.category.label,
color: AppColors.textSecondary,),
Cell(Formatters.money(p.price), mono: true, bold: true),

View File

@@ -1,112 +1,150 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/formatters.dart';
import '../../../domain/entities/promo.dart';
import '../../../domain/entities/store_account.dart';
import '../../auth/providers/auth_controller.dart';
import '../../../core/widgets/empty_state.dart';
import '../widgets/module_widgets.dart';
import '../widgets/promo_editor_dialog.dart';
/// Discount rules and campaigns.
class PromosView extends StatefulWidget {
///
/// This was a mockup: three hardcoded rows with a toggle that changed nothing,
/// and no promo code anywhere in the domain or data layers. A cashier looking
/// at it would reasonably conclude promotions were running. They were not.
class PromosView extends ConsumerWidget {
const PromosView({super.key});
@override
State<PromosView> createState() => _PromosViewState();
}
Widget build(BuildContext context, WidgetRef ref) {
final promosAsync = ref.watch(promosProvider);
final isAdmin = ref.watch(currentUserProvider)?.role == StaffRole.admin;
class _PromosViewState extends State<PromosView> {
final Set<String> _enabled = {'WEEKEND10', 'DAIRY5', 'FESTIVE'};
static const _campaigns = [
(
'WEEKEND10',
'Weekend Saver',
'10% off bills above ₹500',
'SatSun',
412,
AppColors.primary,
),
(
'DAIRY5',
'Dairy Days',
'5% off all dairy products',
'Ends 31 Aug',
286,
AppColors.info,
),
(
'FESTIVE',
'Festive Bonus',
'Double loyalty points',
'Ends 15 Sep',
178,
AppColors.tierGold,
),
(
'NEWCUST',
'First Purchase',
'₹50 off the first bill',
'Always on',
94,
AppColors.success,
),
];
@override
Widget build(BuildContext context) {
return ModulePage(
children: [
Wrap(
spacing: AppSpacing.lg,
runSpacing: AppSpacing.lg,
promosAsync.when(
loading: () => const Padding(
padding: EdgeInsets.all(AppSpacing.xxl),
child: Center(child: CircularProgressIndicator()),
),
error: (e, _) => Text('Could not load campaigns: $e'),
// Stretch, not the Column default of centre. Centred, every card
// shrank to its own intrinsic width and floated in the middle of the
// page instead of starting at the left edge like every other module.
data: (promos) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
StatTile(
label: 'Active Campaigns',
value: '${_enabled.length}',
icon: Icons.campaign_rounded,
caption: 'of ${_campaigns.length} configured',
),
const StatTile(
label: 'Redemptions',
value: '970',
icon: Icons.confirmation_number_rounded,
color: AppColors.info,
caption: 'this month',
),
const StatTile(
label: 'Discount Given',
value: '₹48,240',
icon: Icons.local_offer_rounded,
color: AppColors.warning,
caption: '2.6% of sales',
),
const StatTile(
label: 'Incremental Sales',
value: '₹2.14L',
icon: Icons.trending_up_rounded,
color: AppColors.success,
delta: '+18%',
caption: 'attributed',
),
_summary(promos),
const SizedBox(height: AppSpacing.lg),
_campaignList(context, ref, promos, isAdmin: isAdmin),
],
),
const SizedBox(height: AppSpacing.lg),
),
],
);
}
PanelCard(
Widget _summary(List<Promo> promos) {
final live = promos.where((p) => p.isLiveAt(DateTime.now())).length;
final scheduled = promos
.where((p) =>
p.isActive &&
p.validFrom != null &&
p.validFrom!.isAfter(DateTime.now()),)
.length;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: StatTile(
label: 'Running now',
value: '$live',
icon: Icons.play_circle_outline_rounded,
color: AppColors.success,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: StatTile(
label: 'Scheduled',
value: '$scheduled',
icon: Icons.schedule_rounded,
color: AppColors.info,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: StatTile(
label: 'Paused',
value: '${promos.where((p) => !p.isActive).length}',
icon: Icons.pause_circle_outline_rounded,
color: AppColors.textTertiary,
),
),
],
);
}
Widget _campaignList(
BuildContext context,
WidgetRef ref,
List<Promo> promos, {
required bool isAdmin,
}) {
return PanelCard(
title: 'Campaigns',
subtitle: 'Toggle a rule to apply it at the till immediately',
action: FilledButton.icon(
onPressed: () {},
subtitle: promos.isEmpty
? 'Nothing running. Add a campaign and the till applies it '
'automatically.'
: 'Applied automatically at the till, in priority order',
action: isAdmin
? FilledButton.icon(
onPressed: () => showPromoEditor(context),
icon: const Icon(Icons.add_rounded, size: 18),
label: const Text('New campaign'),
style: FilledButton.styleFrom(
backgroundColor: AppColors.primary,
minimumSize: const Size(0, 40),
),
),
child: Column(
)
: null,
child: promos.isEmpty
? const EmptyState(
title: 'No campaigns yet',
message: 'A campaign here is applied to every bill that '
'qualifies, without the cashier doing anything.',
emoji: '🏷️',
compact: true,
)
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final c in _campaigns)
Container(
for (final promo in promos)
_PromoRow(promo: promo, isAdmin: isAdmin),
],
),
);
}
}
class _PromoRow extends ConsumerWidget {
const _PromoRow({required this.promo, required this.isAdmin});
final Promo promo;
final bool isAdmin;
@override
Widget build(BuildContext context, WidgetRef ref) {
final live = promo.isLiveAt(DateTime.now());
return Container(
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
@@ -122,18 +160,22 @@ class _PromosViewState extends State<PromosView> {
runSpacing: AppSpacing.sm,
children: [
SizedBox(
width: 320,
width: 360,
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: c.$6.withValues(alpha: 0.12),
color: (live ? AppColors.success : AppColors.textTertiary)
.withValues(alpha: 0.12),
borderRadius: AppRadius.brSm,
),
child: Icon(Icons.sell_rounded,
size: 18, color: c.$6,),
child: Icon(
Icons.sell_rounded,
size: 18,
color: live ? AppColors.success : AppColors.textTertiary,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
@@ -142,15 +184,12 @@ class _PromosViewState extends State<PromosView> {
mainAxisSize: MainAxisSize.min,
children: [
Text(
c.$2,
promo.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
style: const TextStyle(fontWeight: FontWeight.w700),
),
Text(
c.$3,
promo.summary,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12.5,
@@ -163,40 +202,109 @@ class _PromosViewState extends State<PromosView> {
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: AppSpacing.sm,
children: [
TagChip(c.$1, color: c.$6),
const SizedBox(width: AppSpacing.sm),
TagChip(c.$4, color: AppColors.textSecondary),
const SizedBox(width: AppSpacing.sm),
if (promo.stackable)
const TagChip('Stacks', color: AppColors.info),
// The distinction a shop actually needs: switched on, but out of
// its date range or wrong day, is not the same as switched off.
if (promo.isActive && !live)
const TagChip('Not today', color: AppColors.warning),
Text(
'${c.$5} used',
_window(promo),
style: const TextStyle(
fontSize: 12,
color: AppColors.textTertiary,
),
),
const SizedBox(width: AppSpacing.sm),
if (isAdmin) ...[
Switch(
value: _enabled.contains(c.$1),
onChanged: (v) => setState(() {
if (v) {
_enabled.add(c.$1);
} else {
_enabled.remove(c.$1);
}
}),
value: promo.isActive,
onChanged: (v) async {
await ref
.read(localStoreProvider)
.promos
.setActive(promo.id, active: v);
ref
..invalidate(promosProvider)
..invalidate(activePromosProvider);
},
),
IconButton(
tooltip: 'Edit',
icon: const Icon(Icons.edit_outlined, size: 17),
onPressed: () => showPromoEditor(context, existing: promo),
),
IconButton(
tooltip: 'Delete',
icon: const Icon(Icons.delete_outline_rounded, size: 17),
color: AppColors.danger,
onPressed: () => _confirmDelete(context, ref),
),
],
],
),
],
),
),
],
),
),
],
);
}
String _window(Promo promo) {
final from = promo.validFrom;
final to = promo.validTo;
if (from == null && to == null) {
return promo.daysOfWeek.isEmpty ? 'Always' : _days(promo.daysOfWeek);
}
final range = [
if (from != null) 'from ${Formatters.date(from)}',
if (to != null) 'to ${Formatters.date(to)}',
].join(' ');
return promo.daysOfWeek.isEmpty
? range
: '$range · ${_days(promo.daysOfWeek)}';
}
static String _days(Set<int> days) {
const names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
final sorted = days.toList()..sort();
return sorted.map((d) => names[d - 1]).join(', ');
}
Future<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text('Delete "${promo.name}"?'),
content: const Text(
'Bills already rung keep the discount they were given — a bill '
'stores the amount, not a link to the campaign. Only future sales '
'are affected.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: TextButton.styleFrom(foregroundColor: AppColors.danger),
child: const Text('Delete'),
),
],
),
);
if (confirmed != true) return;
await ref.read(localStoreProvider).promos.delete(promo.id);
ref
..invalidate(promosProvider)
..invalidate(activePromosProvider);
}
}

View File

@@ -2,15 +2,23 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/config/sync_config.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/services/cash_drawer_service.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/formatters.dart';
import '../../../data/local/order_dao.dart';
import '../../../data/local/void_pin_store.dart';
import '../../../domain/entities/store_account.dart';
import '../../auth/providers/auth_controller.dart';
import '../providers/printer_settings.dart';
import '../widgets/back_office_dialog.dart';
import '../widgets/staff_dialogs.dart';
import '../widgets/store_details_dialog.dart';
import '../../sync/providers/sync_controller.dart';
import '../widgets/module_widgets.dart';
import '../../../core/widgets/numeric_keypad.dart';
/// Terminal and store configuration.
class SettingsView extends ConsumerStatefulWidget {
@@ -21,15 +29,50 @@ class SettingsView extends ConsumerStatefulWidget {
}
class _SettingsViewState extends ConsumerState<SettingsView> {
final _drawerHost = TextEditingController();
final _drawerPort = TextEditingController(text: '9100');
bool _testingDrawer = false;
bool _loadedDrawerFields = false;
/// Null until the first read comes back from the meta table.
bool? _hasRemovalPin;
bool _loadedRemovalPin = false;
bool _scannerSound = true;
bool _roundOff = true;
bool _autoLoyalty = true;
@override
void dispose() {
_drawerHost.dispose();
_drawerPort.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final store = ref.watch(currentStoreProvider);
final user = ref.watch(currentUserProvider);
// Seeded once, from whatever was persisted. Assigning on every build would
// fight the cashier for the cursor while they type.
if (!_loadedRemovalPin) {
_loadedRemovalPin = true;
final store = ref.read(localStoreProvider);
if (store.isReady) {
store.voidPin.isConfigured.then((has) {
if (mounted) setState(() => _hasRemovalPin = has);
});
}
}
final printer = ref.watch(printerSettingsProvider);
if (!_loadedDrawerFields && printer.hasDrawer) {
_loadedDrawerFields = true;
_drawerHost.text = printer.drawerHost ?? '';
_drawerPort.text = '${printer.drawerPort}';
}
return ModulePage(
children: [
LayoutBuilder(
@@ -53,6 +96,8 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
const SizedBox(height: AppSpacing.lg),
_staffCard(store, user),
const SizedBox(height: AppSpacing.lg),
_removalPinCard(user),
const SizedBox(height: AppSpacing.lg),
_aboutCard(),
],
);
@@ -79,7 +124,10 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
Widget _storeCard(StoreAccount? store) => PanelCard(
title: 'Store details',
subtitle: 'Printed on every invoice',
action: TextButton(onPressed: () {}, child: const Text('Edit')),
action: TextButton(
onPressed: () => showStoreDetailsDialog(context),
child: const Text('Edit'),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -236,7 +284,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
.read(receiptServiceProvider)
.printTestPage(
printerUrl: settings.printerUrl,);
if (!context.mounted) return;
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
@@ -282,18 +330,102 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
),
_toggle(
'Open cash drawer on cash sales',
'Needs a raw ESC/POS link — see the notes in ReceiptService',
settings.hasDrawer
? 'Kicks the drawer on ${settings.drawerHost} after a cash '
'tender'
: 'Enter the printer\'s IP address below to enable this',
settings.openDrawer,
controller.setOpenDrawer,
settings.hasDrawer ? controller.setOpenDrawer : null,
),
const SizedBox(height: AppSpacing.sm),
_drawerAddressField(settings, controller),
],
),
);
}
/// The drawer needs a socket, not the print driver.
///
/// A PDF is rendered by the platform driver, which will not pass raw ESC/POS
/// bytes through to the device — so the printer's own address is the only way
/// to reach the drawer wired to its RJ11 port.
Widget _drawerAddressField(
PrinterSettings settings,
PrinterSettingsController controller,
) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: TextFormField(
controller: _drawerHost,
decoration: const InputDecoration(
labelText: 'Printer IP address',
hintText: '192.168.1.50',
helperText: 'Leave blank for a USB printer',
isDense: true,
),
),
),
const SizedBox(width: AppSpacing.sm),
SizedBox(
width: 84,
child: TextFormField(
controller: _drawerPort,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Port', isDense: true),
),
),
const SizedBox(width: AppSpacing.sm),
Padding(
padding: const EdgeInsets.only(top: AppSpacing.xs),
child: OutlinedButton(
onPressed: _testingDrawer
? null
: () => _saveAndTestDrawer(controller),
child: _testingDrawer
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Test'),
),
),
],
);
}
Future<void> _saveAndTestDrawer(PrinterSettingsController controller) async {
setState(() => _testingDrawer = true);
final port = int.tryParse(_drawerPort.text.trim()) ?? 9100;
await controller.setDrawerAddress(_drawerHost.text, port);
final result = await ref.read(receiptServiceProvider).openCashDrawer(
host: _drawerHost.text,
port: port,
);
if (!mounted) return;
setState(() => _testingDrawer = false);
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
backgroundColor:
result.isSuccess ? AppColors.success : AppColors.danger,
content: Text(result.message),
),);
}
Widget _staffCard(StoreAccount? store, StaffUser? current) => PanelCard(
title: 'Users & roles',
action: TextButton(onPressed: () {}, child: const Text('Manage')),
action: TextButton(
onPressed: () => showStaffDialog(context),
child: const Text('Manage'),
),
child: ResponsiveTable(
stackBelow: 360,
columns: const [
@@ -305,24 +437,122 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
.map((s) => [
Cell(s.name, bold: true),
Cell(s.role.label, color: AppColors.textSecondary),
s.id == current?.id
? const TagChip('Signed in',
color: AppColors.success,)
: const SizedBox.shrink(),
if (s.id == current?.id)
const TagChip('Signed in', color: AppColors.success)
else if (s.mustChangePin)
const TagChip('Default PIN', color: AppColors.warning)
else
const SizedBox.shrink(),
],)
.toList(),
),
);
// ------------------------------------------------------- Removal PIN
/// The PIN a cashier types to take a rung item back off a bill.
///
/// Admin-only, and deliberately not a staff PIN. A staff PIN identifies the
/// person a bill is stamped with; handing one out so the counter can void a
/// line would put the whole shift under the wrong name. This is a shared
/// secret for one specific action, and the admin's own staff PIN keeps
/// working whether or not it is set.
Widget _removalPinCard(StaffUser? user) {
final isAdmin = user?.role == StaffRole.admin;
return PanelCard(
title: 'Item removal PIN',
subtitle: 'Asked for when an item is taken off a bill',
action: isAdmin
? TextButton(
onPressed: () => _setRemovalPin(),
child: Text(_hasRemovalPin == true ? 'Change' : 'Set PIN'),
)
: null,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_row(
'Status',
_hasRemovalPin == null
? 'Checking…'
: (_hasRemovalPin! ? 'Set' : 'Not set'),
),
const SizedBox(height: AppSpacing.sm),
Text(
_hasRemovalPin == true
? 'A cashier can remove an item using this PIN. An admin PIN '
'still works too.'
: 'No PIN is set, so a removal currently needs an admin PIN. '
'Set one and a cashier can void a line without an admin '
'walking over.',
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
height: 1.5,
),
),
if (!isAdmin) ...[
const SizedBox(height: AppSpacing.sm),
const Text(
'Only an admin can change it.',
style: TextStyle(fontSize: 12, color: AppColors.textTertiary),
),
],
],
),
);
}
Future<void> _setRemovalPin() async {
final pin = await showDialog<String>(
context: context,
builder: (_) => const _RemovalPinDialog(),
);
if (pin == null) return;
final store = ref.read(localStoreProvider);
try {
if (pin.isEmpty) {
await store.voidPin.clearPin();
} else {
await store.voidPin.setPin(pin);
}
if (!mounted) return;
setState(() => _hasRemovalPin = pin.isNotEmpty);
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(pin.isEmpty
? 'Removal PIN cleared. Removals now need an admin PIN.'
: 'Removal PIN saved.',),
),);
} on VoidPinException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(e.message)));
}
}
Widget _connectivityCard() {
final ready = ref.watch(catalogueReadyProvider);
final lastImport = ref.watch(lastImportAtProvider);
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
final config = ref.watch(syncConfigProvider);
final terminal = ref.watch(terminalIdentityProvider);
final sync = ref.watch(syncEngineStateProvider).value ??
ref.watch(syncEngineProvider).state;
return PanelCard(
title: 'Connectivity & sync',
subtitle: 'This terminal only needs a connection to import the '
'catalogue and to push the shift report.',
subtitle: 'Bills are written to this terminal first and uploaded in the '
'background. Nothing is ever held up waiting for the network.',
action: TextButton(
onPressed: () => showBackOfficeDialog(context),
child: const Text('Configure'),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -331,7 +561,27 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
'Last import',
lastImport == null ? 'Never' : Formatters.dateTime(lastImport),
),
_row('Unsynced bills', '$outstanding'),
_row('Terminal', '${terminal.name} · ${terminal.code}'),
_row('Route', _transportLabel(config)),
_row('Waiting to upload', '$outstanding bill(s)'),
_row(
'Last upload',
sync.lastSuccessAt == null
? 'Never'
: Formatters.dateTime(sync.lastSuccessAt!),
),
if (sync.isHalted)
_row('Status', 'Halted — ${sync.lastError ?? 'refused'}')
else if (sync.nextAttemptAt != null)
_row(
'Next attempt',
'${Formatters.time(sync.nextAttemptAt!)} '
'(attempt ${sync.consecutiveFailures + 1})',
),
_row(
'Bills kept on device',
'${OrderDao.retentionWindow.inDays} days after upload',
),
_toggle(
'Simulate offline',
'Forces import and sync to fail, so you can confirm nothing is '
@@ -340,6 +590,9 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
ref.watch(simulateOfflineProvider),
(v) {
ref.read(simulateOfflineProvider.notifier).state = v;
// The pill and the drain both read connectivity, so they have to
// be told the switch moved.
ref.read(connectivityServiceProvider).refresh();
// Clear any stale failure banner left by the previous setting.
ref.read(catalogueImportProvider.notifier).reset();
ref.read(orderSyncProvider.notifier).reset();
@@ -350,26 +603,44 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
);
}
Widget _aboutCard() => PanelCard(
String _transportLabel(SyncConfig config) => switch (config.transport) {
TransportKind.simulated =>
'Simulated — no back office configured for this terminal',
TransportKind.http => 'HTTP · ${config.httpBaseUrl}',
TransportKind.mqtt =>
'MQTT · ${config.brokerHost}:${config.brokerPort}'
'${config.useTls ? ' (TLS)' : ''}',
};
Widget _aboutCard() {
final terminal = ref.watch(terminalIdentityProvider);
return PanelCard(
title: 'About',
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_row('Application', '${AppConstants.appName} 1.0.0'),
_row('Terminal', 'TERM-01'),
_row('Data store', 'SQLite (on device)'),
_row('Application',
'${AppConstants.appName} ${AppConstants.appVersion}',),
_row('Terminal', '${terminal.name} (${terminal.code})'),
// The identifier support asks for. Stable for the life of the
// device, and the only thing that ties this till to its history.
_row('Device ID', terminal.deviceId, mono: true),
_row('Store', terminal.storeId),
_row('Data store', 'SQLite (on device, WAL)'),
const SizedBox(height: AppSpacing.md),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {},
icon: const Icon(Icons.sync_rounded, size: 17),
label: const Text('Check for updates'),
onPressed: () => showBackOfficeDialog(context),
icon: const Icon(Icons.settings_ethernet_rounded, size: 17),
label: const Text('Back office connection'),
),
),
],
),
);
}
Widget _row(String label, String value, {bool mono = false}) => Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
@@ -444,3 +715,96 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
),
);
}
/// Four-to-eight digits, keyed on the same pad the till uses everywhere else.
class _RemovalPinDialog extends StatefulWidget {
const _RemovalPinDialog();
@override
State<_RemovalPinDialog> createState() => _RemovalPinDialogState();
}
class _RemovalPinDialogState extends State<_RemovalPinDialog> {
String _pin = '';
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Set removal PIN'),
content: SizedBox(
width: (MediaQuery.sizeOf(context).width - 96).clamp(260.0, 340.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Four digits or more. Give it to whoever is on the counter — it '
'authorises removing an item from a bill and nothing else.',
style: TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
height: 1.5,
),
),
const SizedBox(height: AppSpacing.lg),
SizedBox(
height: 22,
child: Center(
child: _pin.isEmpty
? const Text(
'Enter PIN',
style: TextStyle(
fontSize: 13,
color: AppColors.textTertiary,
),
)
: Wrap(
spacing: 10,
children: [
for (var i = 0; i < _pin.length; i++)
Container(
width: 12,
height: 12,
decoration: const BoxDecoration(
color: AppColors.primary,
shape: BoxShape.circle,
),
),
],
),
),
),
const SizedBox(height: AppSpacing.lg),
NumericKeypad(
onKey: (d) {
if (_pin.length >= 8) return;
setState(() => _pin += d);
},
onBackspace: () {
if (_pin.isEmpty) return;
setState(() => _pin = _pin.substring(0, _pin.length - 1));
},
onClear: () => setState(() => _pin = ''),
onSubmit: _pin.length >= 4
? () => Navigator.of(context).pop(_pin)
: null,
submitLabel: 'Save PIN',
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(''),
style: TextButton.styleFrom(foregroundColor: AppColors.danger),
child: const Text('Remove PIN'),
),
],
);
}
}

View File

@@ -0,0 +1,374 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/config/sync_config.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/primary_button.dart';
/// Points this terminal at a back office, and names it.
///
/// Until this existed a store was wired up by editing `syncConfigProvider` and
/// rebuilding — which is not something a shop can do, and made every terminal
/// in a fleet a separate build.
Future<void> showBackOfficeDialog(BuildContext context) => showDialog<void>(
context: context,
builder: (_) => const _BackOfficeDialog(),
);
class _BackOfficeDialog extends ConsumerStatefulWidget {
const _BackOfficeDialog();
@override
ConsumerState<_BackOfficeDialog> createState() => _BackOfficeDialogState();
}
class _BackOfficeDialogState extends ConsumerState<_BackOfficeDialog> {
final _formKey = GlobalKey<FormState>();
late TransportKind _kind;
late final TextEditingController _terminalName;
late final TextEditingController _storeId;
late final TextEditingController _host;
late final TextEditingController _port;
late final TextEditingController _username;
late final TextEditingController _password;
late final TextEditingController _httpUrl;
late final TextEditingController _apiKey;
late bool _useTls;
bool _saving = false;
@override
void initState() {
super.initState();
final config = ref.read(syncConfigProvider);
final terminal = ref.read(terminalIdentityProvider);
_kind = config.transport;
_useTls = config.useTls;
_terminalName = TextEditingController(text: terminal.name);
_storeId = TextEditingController(text: terminal.storeId);
_host = TextEditingController(text: config.brokerHost);
_port = TextEditingController(text: '${config.brokerPort}');
_username = TextEditingController(text: config.username ?? '');
_password = TextEditingController(text: config.password ?? '');
_httpUrl = TextEditingController(text: config.httpBaseUrl);
_apiKey = TextEditingController(text: config.apiKey ?? '');
}
@override
void dispose() {
for (final c in [
_terminalName,
_storeId,
_host,
_port,
_username,
_password,
_httpUrl,
_apiKey,
]) {
c.dispose();
}
super.dispose();
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() => _saving = true);
final store = ref.read(localStoreProvider);
// Name and store id are the terminal's own, and live in its database — a
// reinstall must not lose which shop this till belongs to.
await store.identityStore.rename(
name: _terminalName.text.trim(),
storeId: _storeId.text.trim(),
);
await store.hydrate();
if (!mounted) return;
// Deliberately left out of the identity store: credentials belong to the
// route, not to the machine, and re-pointing a terminal should not rewrite
// who it is.
final next = ref.read(syncConfigProvider).copyWith(
transport: _kind,
storeId: _storeId.text.trim(),
brokerHost: _host.text.trim(),
brokerPort: int.tryParse(_port.text.trim()) ?? 8883,
useTls: _useTls,
username: _username.text.trim().isEmpty ? null : _username.text.trim(),
password: _password.text.isEmpty ? null : _password.text,
httpBaseUrl: _httpUrl.text.trim(),
apiKey: _apiKey.text.trim().isEmpty ? null : _apiKey.text.trim(),
);
// Non-secret settings to the database, credentials to the OS keystore.
// Held only in memory they had to be retyped after every restart, which on
// a shop-floor terminal means they end up on a sticky note instead.
await store.syncConfig.save(next);
ref.read(syncConfigProvider.notifier).state = next;
if (mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final terminal = ref.watch(terminalIdentityProvider);
return AlertDialog(
title: const Text('Back office connection'),
content: SizedBox(
width: 520,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_IdentityBanner(code: terminal.code, deviceId: terminal.deviceId),
const SizedBox(height: AppSpacing.lg),
TextFormField(
controller: _terminalName,
decoration: const InputDecoration(
labelText: 'Terminal name',
helperText: 'What staff call this till, e.g. "Counter 2"',
),
),
const SizedBox(height: AppSpacing.md),
TextFormField(
controller: _storeId,
decoration: const InputDecoration(
labelText: 'Store ID',
helperText: 'Namespaces this shop on the broker',
),
validator: (v) => (v == null || v.trim().isEmpty)
? 'Every terminal must belong to a store'
: null,
),
const SizedBox(height: AppSpacing.lg),
SegmentedButton<TransportKind>(
segments: const [
ButtonSegment(
value: TransportKind.simulated,
label: Text('Offline demo'),
),
ButtonSegment(
value: TransportKind.http,
label: Text('HTTP'),
),
ButtonSegment(
value: TransportKind.mqtt,
label: Text('MQTT'),
),
],
selected: {_kind},
onSelectionChanged: (s) => setState(() => _kind = s.first),
),
const SizedBox(height: AppSpacing.lg),
if (_kind == TransportKind.mqtt) ..._mqttFields(),
if (_kind == TransportKind.http) ..._httpFields(),
if (_kind == TransportKind.simulated)
const _Note(
'Bills queue and drain against a local stub. Nothing '
'leaves this terminal — use it to rehearse a shift '
'without a server.',
),
],
),
),
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
PrimaryButton(
label: 'Save',
expanded: false,
busy: _saving,
onPressed: _save,
),
],
);
}
List<Widget> _mqttFields() => [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: TextFormField(
controller: _host,
decoration: const InputDecoration(
labelText: 'Broker host',
hintText: 'nats.example.com',
),
validator: (v) => (v == null || v.trim().isEmpty)
? 'A broker host is required'
: null,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: TextFormField(
controller: _port,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(labelText: 'Port'),
validator: (v) {
final port = int.tryParse(v?.trim() ?? '');
return (port == null || port < 1 || port > 65535)
? '165535'
: null;
},
),
),
],
),
const SizedBox(height: AppSpacing.md),
Row(
children: [
Expanded(
child: TextFormField(
controller: _username,
decoration: const InputDecoration(labelText: 'Username'),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: TextFormField(
controller: _password,
obscureText: true,
decoration: const InputDecoration(labelText: 'Password'),
),
),
],
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _useTls,
onChanged: (v) => setState(() => _useTls = v),
title: const Text('Use TLS'),
subtitle: const Text(
'Bills carry customer names and mobile numbers. Turn this off only '
'on a closed network you control.',
style: TextStyle(fontSize: 12),
),
),
const _Note(
'Works against NATS with its MQTT gateway enabled, or any MQTT 3.1.1 '
'broker. Topics arrive as NATS subjects with "/" replaced by "." — '
'see docs/sync-contract.md.',
),
];
List<Widget> _httpFields() => [
TextFormField(
controller: _httpUrl,
decoration: const InputDecoration(
labelText: 'Base URL',
hintText: 'https://api.example.com',
helperText: 'Bills are posted to {base}/orders',
),
validator: (v) {
final text = v?.trim() ?? '';
if (text.isEmpty) return 'A base URL is required';
final uri = Uri.tryParse(text);
if (uri == null || !uri.isAbsolute) return 'Not a valid URL';
return null;
},
),
const SizedBox(height: AppSpacing.md),
TextFormField(
controller: _apiKey,
obscureText: true,
decoration: const InputDecoration(
labelText: 'API key',
helperText: 'Sent as a bearer token',
),
),
];
}
/// The two identifiers a support call needs, and neither is editable.
class _IdentityBanner extends StatelessWidget {
const _IdentityBanner({required this.code, required this.deviceId});
final String code;
final String deviceId;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: const BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brSm,
),
child: Row(
children: [
const Icon(Icons.point_of_sale_rounded,
size: 18, color: AppColors.primary,),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Terminal $code',
style: const TextStyle(
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
Text(
'Device $deviceId',
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
),
),
],
),
),
IconButton(
tooltip: 'Copy device ID',
icon: const Icon(Icons.copy_rounded, size: 16),
onPressed: () => Clipboard.setData(ClipboardData(text: deviceId)),
),
],
),
);
}
}
class _Note extends StatelessWidget {
const _Note(this.text);
final String text;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(top: AppSpacing.sm),
child: Text(
text,
style: const TextStyle(
fontSize: 12,
color: AppColors.textTertiary,
height: 1.45,
),
),
);
}

View File

@@ -23,6 +23,12 @@ class ModulePage extends StatelessWidget {
return SingleChildScrollView(
padding: EdgeInsets.all(padding),
child: Column(
// Top-left is the resting position for every module. Both are spelled
// out rather than left to the defaults, because a Column's default
// cross-axis is centre — which is what had short pages drifting to the
// middle instead of starting at the left edge.
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),

View File

@@ -0,0 +1,464 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../data/local/promo_dao.dart';
import '../../../domain/entities/product.dart';
import '../../../domain/entities/promo.dart';
import '../../pos/providers/catalog_providers.dart';
/// Creates or edits a campaign.
Future<void> showPromoEditor(BuildContext context, {Promo? existing}) =>
showDialog<void>(
context: context,
builder: (_) => _PromoEditor(existing: existing),
);
class _PromoEditor extends ConsumerStatefulWidget {
const _PromoEditor({this.existing});
final Promo? existing;
@override
ConsumerState<_PromoEditor> createState() => _PromoEditorState();
}
class _PromoEditorState extends ConsumerState<_PromoEditor> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _name;
late final TextEditingController _value;
late final TextEditingController _minBill;
late final TextEditingController _maxDiscount;
late final TextEditingController _buy;
late final TextEditingController _free;
late PromoType _type;
String? _targetId;
String? _targetLabel;
DateTime? _from;
DateTime? _to;
late Set<int> _days;
late bool _stackable;
bool _saving = false;
String? _error;
bool get _isNew => widget.existing == null;
@override
void initState() {
super.initState();
final p = widget.existing;
_name = TextEditingController(text: p?.name ?? '');
_value = TextEditingController(text: p == null ? '' : _num(p.value));
_minBill = TextEditingController(
text: (p == null || p.minBillValue == 0) ? '' : _num(p.minBillValue),
);
_maxDiscount = TextEditingController(
text: p?.maxDiscount == null ? '' : _num(p!.maxDiscount!),
);
_buy = TextEditingController(text: '${p?.buyQuantity ?? 2}');
_free = TextEditingController(text: '${p?.freeQuantity ?? 1}');
_type = p?.type ?? PromoType.percentOffBill;
_targetId = p?.targetId;
_targetLabel = p?.targetLabel;
_from = p?.validFrom;
_to = p?.validTo;
_days = {...?p?.daysOfWeek};
_stackable = p?.stackable ?? false;
}
static String _num(double v) =>
v == v.roundToDouble() ? v.toStringAsFixed(0) : v.toStringAsFixed(2);
@override
void dispose() {
for (final c in [_name, _value, _minBill, _maxDiscount, _buy, _free]) {
c.dispose();
}
super.dispose();
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
if (_type.needsTarget && (_targetId ?? '').isEmpty) {
setState(() => _error = 'Choose what this campaign applies to.');
return;
}
setState(() {
_saving = true;
_error = null;
});
final promo = Promo(
id: widget.existing?.id ?? '',
name: _name.text,
type: _type,
value: double.tryParse(_value.text.trim()) ?? 0,
targetId: _targetId,
targetLabel: _targetLabel,
buyQuantity: int.tryParse(_buy.text.trim()) ?? 0,
freeQuantity: int.tryParse(_free.text.trim()) ?? 0,
minBillValue: double.tryParse(_minBill.text.trim()) ?? 0,
maxDiscount: double.tryParse(_maxDiscount.text.trim()),
validFrom: _from,
validTo: _to,
daysOfWeek: _days,
stackable: _stackable,
priority: widget.existing?.priority ?? 100,
isActive: widget.existing?.isActive ?? true,
);
try {
await ref.read(localStoreProvider).promos.save(promo);
ref
..invalidate(promosProvider)
..invalidate(activePromosProvider);
if (mounted) Navigator.of(context).pop();
} on PromoException catch (e) {
setState(() {
_saving = false;
_error = e.message;
});
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(_isNew ? 'New campaign' : 'Edit campaign'),
content: SizedBox(
width: 540,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _name,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Campaign name',
hintText: 'Weekend Saver',
helperText: 'Shown on the bill when it applies',
),
validator: (v) => (v == null || v.trim().isEmpty)
? 'A campaign needs a name'
: null,
),
const SizedBox(height: AppSpacing.md),
DropdownButtonFormField<PromoType>(
initialValue: _type,
isExpanded: true,
decoration: const InputDecoration(labelText: 'What it does'),
items: [
for (final type in PromoType.values)
DropdownMenuItem(
value: type,
child: Text(type.label, overflow: TextOverflow.ellipsis),
),
],
onChanged: (v) => setState(() {
_type = v ?? _type;
// The old target is meaningless under a different type — a
// category id on a product promo would silently never fire.
_targetId = null;
_targetLabel = null;
}),
),
const SizedBox(height: AppSpacing.md),
if (_type.needsTarget) ...[
_targetField(),
const SizedBox(height: AppSpacing.md),
],
if (_type == PromoType.buyXGetY)
_buyGetFields()
else
_valueField(),
const SizedBox(height: AppSpacing.md),
Row(
children: [
Expanded(
child: TextFormField(
controller: _minBill,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Minimum bill',
hintText: '0',
prefixText: '',
isDense: true,
),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: TextFormField(
controller: _maxDiscount,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Cap the discount',
hintText: 'No cap',
prefixText: '',
isDense: true,
),
),
),
],
),
if (_type.isPercentage)
const Padding(
padding: EdgeInsets.only(top: AppSpacing.xs),
child: Text(
'A cap is worth setting on a percentage: without one, an '
'unusually large trolley gives away more than the '
'campaign was costed for.',
style: TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
height: 1.4,
),
),
),
const SizedBox(height: AppSpacing.lg),
_dateRange(),
const SizedBox(height: AppSpacing.md),
_dayPicker(),
const SizedBox(height: AppSpacing.sm),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _stackable,
onChanged: (v) => setState(() => _stackable = v),
title: const Text('Can combine with other campaigns'),
subtitle: const Text(
'Off by default. Only the best non-combining campaign '
'applies to a bill — two percentages compounding produce a '
'discount nobody costed.',
style: TextStyle(fontSize: 11.5, height: 1.4),
),
),
if (_error != null) ...[
const SizedBox(height: AppSpacing.md),
Text(
_error!,
style: const TextStyle(
color: AppColors.danger,
fontSize: 12.5,
),
),
],
],
),
),
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
PrimaryButton(
label: _isNew ? 'Create' : 'Save',
expanded: false,
busy: _saving,
onPressed: _save,
),
],
);
}
Widget _valueField() => TextFormField(
controller: _value,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: _type.isPercentage ? 'Percentage off' : 'Amount off',
suffixText: _type.isPercentage ? '%' : null,
prefixText: _type.isPercentage ? null : '',
),
validator: (v) {
final parsed = double.tryParse((v ?? '').trim());
if (parsed == null || parsed <= 0) {
return 'A campaign must give something away';
}
if (_type.isPercentage && parsed > 100) {
// Over 100% is a refund with extra steps.
return 'A percentage cannot exceed 100';
}
return null;
},
);
Widget _buyGetFields() => Row(
children: [
Expanded(
child: TextFormField(
controller: _buy,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(labelText: 'Buy', isDense: true),
validator: (v) => (int.tryParse(v ?? '') ?? 0) < 1
? 'At least one'
: null,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: TextFormField(
controller: _free,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration:
const InputDecoration(labelText: 'Get free', isDense: true),
validator: (v) => (int.tryParse(v ?? '') ?? 0) < 1
? 'At least one'
: null,
),
),
],
);
Widget _targetField() {
if (_type == PromoType.percentOffCategory) {
return DropdownButtonFormField<String>(
initialValue: _targetId,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Category'),
items: [
for (final category in ProductCategory.values)
DropdownMenuItem(
value: category.name,
child: Text('${category.emoji} ${category.label}'),
),
],
onChanged: (v) => setState(() {
_targetId = v;
_targetLabel = ProductCategory.values
.where((c) => c.name == v)
.map((c) => c.label)
.firstOrNull;
}),
);
}
// Product picker, for both the product promo and buy-X-get-Y.
final products = ref.watch(allProductsProvider).value ?? const <Product>[];
return DropdownButtonFormField<String>(
initialValue:
products.any((p) => p.id == _targetId) ? _targetId : null,
isExpanded: true,
decoration: InputDecoration(
labelText: 'Product',
helperText: products.isEmpty
? 'Import the catalogue first — there is nothing to pick'
: null,
),
items: [
for (final product in products)
DropdownMenuItem(
value: product.id,
child: Text(product.name, overflow: TextOverflow.ellipsis),
),
],
onChanged: (v) => setState(() {
_targetId = v;
_targetLabel =
products.where((p) => p.id == v).map((p) => p.name).firstOrNull;
}),
);
}
Widget _dateRange() => Row(
children: [
Expanded(child: _dateButton('Starts', _from, (d) => _from = d)),
const SizedBox(width: AppSpacing.md),
Expanded(child: _dateButton('Ends', _to, (d) => _to = d)),
],
);
Widget _dateButton(String label, DateTime? value, void Function(DateTime?) set) {
return OutlinedButton(
onPressed: () async {
final picked = await showDatePicker(
context: context,
initialDate: value ?? DateTime.now(),
firstDate: DateTime(2024),
lastDate: DateTime(2100),
);
if (picked != null) setState(() => set(picked));
},
onLongPress: () => setState(() => set(null)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 11,
color: AppColors.textTertiary,
),
),
Text(
value == null
? 'Any time'
: '${value.day}/${value.month}/${value.year}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
],
),
);
}
Widget _dayPicker() {
const names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Days it runs',
style: TextStyle(fontSize: 12, color: AppColors.textSecondary),
),
const SizedBox(height: AppSpacing.xs),
Wrap(
spacing: AppSpacing.xs,
children: [
for (var day = 1; day <= 7; day++)
FilterChip(
label: Text(names[day - 1]),
selected: _days.contains(day),
onSelected: (on) => setState(() {
on ? _days.add(day) : _days.remove(day);
}),
),
],
),
const Padding(
padding: EdgeInsets.only(top: AppSpacing.xs),
child: Text(
'Pick none to run every day.',
style: TextStyle(fontSize: 11.5, color: AppColors.textTertiary),
),
),
],
);
}
}

View File

@@ -0,0 +1,538 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../data/local/staff_dao.dart';
import '../../../domain/entities/store_account.dart';
import '../../auth/providers/auth_controller.dart';
/// Manage who can sign in at this till.
Future<void> showStaffDialog(BuildContext context) => showDialog<void>(
context: context,
builder: (_) => const _StaffDialog(),
);
class _StaffDialog extends ConsumerWidget {
const _StaffDialog();
@override
Widget build(BuildContext context, WidgetRef ref) {
final me = ref.watch(currentUserProvider);
final storeAsync = ref.watch(storeAccountProvider);
// Only an admin can change who works here, or what they may do.
if (me?.role != StaffRole.admin) {
return AlertDialog(
title: const Text('Users & roles'),
content: const Text(
'Only an admin can add or change staff accounts. Ask a manager to '
'sign in first.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
);
}
return AlertDialog(
title: const Text('Users & roles'),
content: SizedBox(
width: 560,
child: storeAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Text('Could not load staff: $e'),
data: (store) => SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final user in store.staff)
_StaffRow(user: user, isMe: user.id == me?.id),
const SizedBox(height: AppSpacing.md),
OutlinedButton.icon(
onPressed: () => _openEditor(context, ref, null),
icon: const Icon(Icons.person_add_alt_1_rounded, size: 17),
label: const Text('Add staff member'),
),
],
),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Done'),
),
],
);
}
}
class _StaffRow extends ConsumerWidget {
const _StaffRow({required this.user, required this.isMe});
final StaffUser user;
final bool isMe;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
child: Row(
children: [
Expanded(
flex: 4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.name,
style: const TextStyle(fontWeight: FontWeight.w600),
),
Text(
user.role.label,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
if (user.mustChangePin)
const Tooltip(
message: 'Still on the PIN this terminal was set up with',
child: Icon(Icons.warning_amber_rounded,
size: 17, color: AppColors.warning,),
),
if (isMe)
const Padding(
padding: EdgeInsets.symmetric(horizontal: AppSpacing.sm),
child: Text(
'Signed in',
style: TextStyle(fontSize: 11, color: AppColors.success),
),
),
IconButton(
tooltip: 'Edit',
icon: const Icon(Icons.edit_outlined, size: 17),
onPressed: () => _openEditor(context, ref, user),
),
IconButton(
tooltip: 'Remove',
icon: const Icon(Icons.person_off_outlined, size: 17),
color: AppColors.danger,
onPressed: () => _confirmDeactivate(context, ref, user),
),
],
),
);
}
}
Future<void> _confirmDeactivate(
BuildContext context,
WidgetRef ref,
StaffUser user,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text('Remove ${user.name}?'),
content: const Text(
'They will no longer be able to sign in. Bills they have already rung '
'keep their name, so shift reports stay correct.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: TextButton.styleFrom(foregroundColor: AppColors.danger),
child: const Text('Remove'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
try {
await ref.read(localStoreProvider).staff.deactivate(user.id);
await ref.read(authControllerProvider.notifier).refreshStore();
} on StaffException catch (e) {
if (context.mounted) _showError(context, e.message);
}
}
Future<void> _openEditor(
BuildContext context,
WidgetRef ref,
StaffUser? existing,
) =>
showDialog<void>(
context: context,
builder: (_) => _StaffEditor(existing: existing),
);
void _showError(BuildContext context, String message) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
backgroundColor: AppColors.danger,
content: Text(message),
),);
}
/// Add a staff member, or change one's name, role or PIN.
class _StaffEditor extends ConsumerStatefulWidget {
const _StaffEditor({this.existing});
final StaffUser? existing;
@override
ConsumerState<_StaffEditor> createState() => _StaffEditorState();
}
class _StaffEditorState extends ConsumerState<_StaffEditor> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _name;
final _pin = TextEditingController();
final _confirmPin = TextEditingController();
late StaffRole _role;
bool _saving = false;
String? _error;
bool get _isNew => widget.existing == null;
@override
void initState() {
super.initState();
_name = TextEditingController(text: widget.existing?.name ?? '');
_role = widget.existing?.role ?? StaffRole.cashier;
}
@override
void dispose() {
_name.dispose();
_pin.dispose();
_confirmPin.dispose();
super.dispose();
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() {
_saving = true;
_error = null;
});
final staff = ref.read(localStoreProvider).staff;
try {
if (_isNew) {
await staff.create(
name: _name.text,
role: _role,
pin: _pin.text,
);
} else {
await staff.updateDetails(
id: widget.existing!.id,
name: _name.text,
role: _role,
);
// Blank means "leave it alone" — an admin editing a role should not be
// forced to know or reset someone's PIN.
if (_pin.text.isNotEmpty) {
await staff.setPin(
widget.existing!.id,
_pin.text,
// An admin setting someone else's PIN is a reset, so the person is
// asked to choose their own at next sign-in.
mustChangePin: widget.existing!.id !=
ref.read(currentUserProvider)?.id,
);
}
}
await ref.read(authControllerProvider.notifier).refreshStore();
if (mounted) Navigator.of(context).pop();
} on StaffException catch (e) {
setState(() {
_saving = false;
_error = e.message;
});
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(_isNew ? 'Add staff member' : 'Edit ${widget.existing!.name}'),
content: SizedBox(
width: 420,
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _name,
autofocus: true,
decoration: const InputDecoration(labelText: 'Name'),
validator: (v) => (v == null || v.trim().isEmpty)
? 'A staff member needs a name'
: null,
),
const SizedBox(height: AppSpacing.md),
DropdownButtonFormField<StaffRole>(
initialValue: _role,
// Without this the item is laid out at its natural width and
// "Manager — Sales, inventory and reports" runs 222px past the
// edge of the dialog.
isExpanded: true,
decoration: const InputDecoration(labelText: 'Role'),
items: [
for (final role in StaffRole.values)
DropdownMenuItem(
value: role,
child: Text(
'${role.label}${role.description}',
overflow: TextOverflow.ellipsis,
),
),
],
onChanged: (v) => setState(() => _role = v ?? _role),
),
// Spelled out below rather than squeezed into the dropdown, so
// the permissions being granted are actually readable.
Padding(
padding: const EdgeInsets.only(top: AppSpacing.xs),
child: Text(
_role.description,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
),
const SizedBox(height: AppSpacing.md),
TextFormField(
controller: _pin,
obscureText: true,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
decoration: InputDecoration(
labelText: _isNew ? 'PIN' : 'New PIN',
helperText: _isNew
? 'At least four digits, and not guessable across a '
'counter'
: 'Leave blank to keep the current PIN',
),
validator: (v) {
final text = v ?? '';
if (_isNew && text.isEmpty) return 'A PIN is required';
if (text.isNotEmpty && text.length < 4) {
return 'At least four digits';
}
return null;
},
),
const SizedBox(height: AppSpacing.md),
TextFormField(
controller: _confirmPin,
obscureText: true,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(labelText: 'Confirm PIN'),
validator: (v) {
// A mistyped PIN nobody can verify locks the account out —
// there is no email to reset it with.
if (_pin.text.isEmpty) return null;
return v != _pin.text ? 'The two PINs do not match' : null;
},
),
if (_error != null) ...[
const SizedBox(height: AppSpacing.md),
Text(
_error!,
style: const TextStyle(
color: AppColors.danger,
fontSize: 12.5,
),
),
],
],
),
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
PrimaryButton(
label: _isNew ? 'Add' : 'Save',
expanded: false,
busy: _saving,
onPressed: _save,
),
],
);
}
}
/// Forces someone off a PIN they did not choose.
///
/// Shown after sign-in while [StaffUser.mustChangePin] is set. Not dismissable:
/// the seeded PINs are in the source of an open-source build, so a shop still
/// running one is effectively unprotected.
Future<void> showForcedPinChange(BuildContext context) => showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => const _ForcedPinChange(),
);
class _ForcedPinChange extends ConsumerStatefulWidget {
const _ForcedPinChange();
@override
ConsumerState<_ForcedPinChange> createState() => _ForcedPinChangeState();
}
class _ForcedPinChangeState extends ConsumerState<_ForcedPinChange> {
final _formKey = GlobalKey<FormState>();
final _pin = TextEditingController();
final _confirm = TextEditingController();
bool _saving = false;
String? _error;
@override
void dispose() {
_pin.dispose();
_confirm.dispose();
super.dispose();
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() {
_saving = true;
_error = null;
});
final me = ref.read(currentUserProvider);
if (me == null) return;
try {
await ref.read(localStoreProvider).staff.setPin(me.id, _pin.text);
await ref.read(authControllerProvider.notifier).refreshStore();
if (mounted) Navigator.of(context).pop();
} on StaffException catch (e) {
setState(() {
_saving = false;
_error = e.message;
});
}
}
@override
Widget build(BuildContext context) {
final me = ref.watch(currentUserProvider);
return PopScope(
canPop: false,
child: AlertDialog(
title: const Text('Choose your PIN'),
content: SizedBox(
width: 420,
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'${me?.name ?? 'This account'} is still using the PIN this '
'terminal was set up with. Those are the same on every new '
'install, so please pick your own before ringing a sale.',
style: const TextStyle(
fontSize: 13,
height: 1.45,
color: AppColors.textSecondary,
),
),
const SizedBox(height: AppSpacing.lg),
TextFormField(
controller: _pin,
autofocus: true,
obscureText: true,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
decoration: const InputDecoration(labelText: 'New PIN'),
validator: (v) => (v == null || v.length < 4)
? 'At least four digits'
: null,
),
const SizedBox(height: AppSpacing.md),
TextFormField(
controller: _confirm,
obscureText: true,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(labelText: 'Confirm PIN'),
validator: (v) =>
v != _pin.text ? 'The two PINs do not match' : null,
),
if (_error != null) ...[
const SizedBox(height: AppSpacing.md),
Text(
_error!,
style: const TextStyle(
color: AppColors.danger,
fontSize: 12.5,
),
),
],
],
),
),
),
actions: [
PrimaryButton(
label: 'Set PIN',
expanded: false,
busy: _saving,
onPressed: _save,
),
],
),
);
}
}

View File

@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../data/repositories/store_repository_impl.dart';
import '../../../domain/entities/store_account.dart';
import '../../auth/providers/auth_controller.dart';
/// Edits what gets printed at the top of every invoice.
///
/// These were compile-time constants, so a shop could only correct its own
/// address or GSTIN by having the app rebuilt — on a GST invoice those fields
/// are a legal requirement, not decoration.
Future<void> showStoreDetailsDialog(BuildContext context) => showDialog<void>(
context: context,
builder: (_) => const _StoreDetailsDialog(),
);
class _StoreDetailsDialog extends ConsumerStatefulWidget {
const _StoreDetailsDialog();
@override
ConsumerState<_StoreDetailsDialog> createState() =>
_StoreDetailsDialogState();
}
class _StoreDetailsDialogState extends ConsumerState<_StoreDetailsDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _name;
late final TextEditingController _address;
late final TextEditingController _gstin;
late final TextEditingController _phone;
bool _saving = false;
@override
void initState() {
super.initState();
final store = ref.read(currentStoreProvider);
_name = TextEditingController(text: store?.name ?? '');
_address = TextEditingController(text: store?.address ?? '');
_gstin = TextEditingController(text: store?.gstin ?? '');
_phone = TextEditingController(text: store?.phone ?? '');
}
@override
void dispose() {
for (final c in [_name, _address, _gstin, _phone]) {
c.dispose();
}
super.dispose();
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() => _saving = true);
await ref.read(storeRepositoryProvider).save(
name: _name.text,
address: _address.text,
gstin: _gstin.text,
phone: _phone.text,
);
await ref.read(authControllerProvider.notifier).refreshStore();
if (mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final me = ref.watch(currentUserProvider);
// Changing the GSTIN on a live till changes what every future invoice
// claims about who collected the tax.
if (me?.role != StaffRole.admin) {
return AlertDialog(
title: const Text('Store details'),
content: const Text(
'Only an admin can change the details printed on invoices.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
);
}
return AlertDialog(
title: const Text('Store details'),
content: SizedBox(
width: 480,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Printed at the top of every invoice.',
style: TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
),
),
const SizedBox(height: AppSpacing.lg),
TextFormField(
controller: _name,
autofocus: true,
decoration: const InputDecoration(labelText: 'Store name'),
validator: (v) => (v == null || v.trim().isEmpty)
? 'An invoice must name the seller'
: null,
),
const SizedBox(height: AppSpacing.md),
TextFormField(
controller: _address,
maxLines: 2,
decoration: const InputDecoration(labelText: 'Address'),
validator: (v) => (v == null || v.trim().isEmpty)
? 'An invoice must carry the place of supply'
: null,
),
const SizedBox(height: AppSpacing.md),
TextFormField(
controller: _gstin,
textCapitalization: TextCapitalization.characters,
inputFormatters: [
LengthLimitingTextInputFormatter(15),
FilteringTextInputFormatter.allow(RegExp('[0-9a-zA-Z]')),
],
decoration: const InputDecoration(
labelText: 'GSTIN',
hintText: '33AABCU9603R1ZM',
),
validator: GstinValidator.validate,
),
const SizedBox(height: AppSpacing.md),
TextFormField(
controller: _phone,
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
decoration: const InputDecoration(labelText: 'Phone'),
validator: (v) => (v == null || v.trim().length != 10)
? 'Ten digits'
: null,
),
],
),
),
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
PrimaryButton(
label: 'Save',
expanded: false,
busy: _saving,
onPressed: _save,
),
],
);
}
}

View File

@@ -8,6 +8,7 @@ import '../../../domain/entities/transaction.dart';
import '../../../domain/usecases/checkout_sale.dart';
import '../../auth/providers/auth_controller.dart';
import '../../pos/providers/cart_controller.dart';
import '../../modules/providers/printer_settings.dart';
import '../../pos/providers/catalog_providers.dart';
import '../../sync/providers/sync_controller.dart';
@@ -78,29 +79,27 @@ class PaymentController extends StateNotifier<PaymentState> {
/// Whether Complete Sale should be enabled.
///
/// Cash is the strict case: the drawer cannot be reconciled and no change can
/// be calculated unless the cashier states what was handed over. Card, UPI
/// and wallet settle on the external terminal, so they need no amount here.
/// Every method now requires the cashier to state what was actually
/// received before the sale can complete — a tap on Exact for the common
/// case, or a typed amount for a partial tender. `cashTendered` is the
/// amount entered for whichever method is currently active, not literally
/// cash; the name stayed to keep this change out of the rest of the app.
bool get canConfirm {
if (_billTotal <= 0) return false;
// Staged splits already cover the bill.
if (balanceDue <= 0.01) return true;
if (state.activeMethod.needsChange) {
return state.cashTendered >= balanceDue;
}
return true;
}
/// Why the button is disabled, for display next to it.
String? get blockedReason {
if (_billTotal <= 0) return 'Add at least one item before charging.';
if (canConfirm) return null;
if (state.activeMethod.needsChange) {
return 'Enter the cash received, or tap Exact.';
}
return null;
return state.activeMethod.needsChange
? 'Enter the cash received, or tap Exact.'
: 'Enter the amount received, or tap Exact.';
}
void selectMethod(PaymentMethod method) {
@@ -190,7 +189,18 @@ class PaymentController extends StateNotifier<PaymentState> {
// No auto-print: with no roll printer attached this silently failed and
// looked like a bug. The receipt screen shows the bill on the terminal
// and offers Print, WhatsApp and Share explicitly.
unawaited(_ref.read(receiptServiceProvider).openCashDrawer());
// Only for cash. A card-only sale that pops the drawer is a shrinkage
// risk, and it is what a shop notices first.
final printer = _ref.read(printerSettingsProvider);
final tookCash = splits.any((p) => p.method == PaymentMethod.cash);
if (printer.openDrawer && printer.hasDrawer && tookCash) {
unawaited(
_ref.read(receiptServiceProvider).openCashDrawer(
host: printer.drawerHost,
port: printer.drawerPort,
),
);
}
unawaited(_ref.read(soundServiceProvider).saleComplete());
// Stock changed, so the grid must refresh; the new order changes the
@@ -199,6 +209,12 @@ class PaymentController extends StateNotifier<PaymentState> {
_ref.invalidate(visibleProductsProvider);
_ref.read(orderVersionProvider.notifier).state++;
// Deliberately NOT nudging the sync engine here. The bill sits on this
// terminal, unsynced, until the receipt screen's cancellation window
// closes — either it is pressed past early with New Sale, or the
// window runs out — or the sale is voided if the cashier cancels
// instead. See ReceiptScreen.
return result;
} on CheckoutFailure catch (e) {
state = state.copyWith(

View File

@@ -3,6 +3,7 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/providers.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
@@ -11,7 +12,7 @@ import '../../../core/utils/formatters.dart';
import '../../../core/widgets/glass_card.dart';
import '../../../core/widgets/numeric_keypad.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../core/widgets/status_pill.dart';
import '../../../domain/entities/promo.dart';
import '../../../domain/entities/transaction.dart';
import '../../customer/widgets/customer_capture_sheet.dart';
import '../../pos/providers/cart_controller.dart';
@@ -97,7 +98,15 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
const SizedBox(height: AppSpacing.md),
_customerCard(),
const SizedBox(height: AppSpacing.md),
// Only builds anything when a campaign is on the bill or one is
// within reach, so a shop running none sees no empty card.
_offersCard(),
_methodsCard(controller, state),
const SizedBox(height: AppSpacing.md),
// Carries the rest of the column's height, and answers the
// question a customer asks at the counter — what am I paying
// for — without going back to the bill.
_summaryCard(),
],
);
@@ -112,16 +121,37 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
);
}
return Padding(
// One scroll view over both columns, equal flex, centred and capped.
//
// Two independent scrollers with a 4:5 split were what made this read
// as lopsided: the columns started at different widths, ended at
// different heights, and the whole thing sat against the top-left of
// a much larger window. The minimum height fills the viewport so the
// pair sits in the middle of the screen instead of clinging to the
// top edge, and the cap stops the cards stretching into bands on a
// wide till display.
final minHeight = constraints.maxHeight.isFinite
? (constraints.maxHeight - pad * 2).clamp(0.0, double.infinity)
: 0.0;
return SingleChildScrollView(
padding: EdgeInsets.all(pad),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: minHeight),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1340),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 4, child: SingleChildScrollView(child: left)),
Expanded(child: left),
const SizedBox(width: AppSpacing.lg),
Expanded(flex: 5, child: SingleChildScrollView(child: right)),
Expanded(child: right),
],
),
),
),
),
);
},
),
@@ -224,10 +254,6 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
),
),
),
if (customer != null) ...[
const SizedBox(width: AppSpacing.sm),
StatusPill.tier(customer.tier, dense: true),
],
],
),
const SizedBox(height: 1),
@@ -289,57 +315,6 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
),
],
),
if (state.splits.isNotEmpty) ...[
const Divider(height: AppSpacing.xxl),
Row(
children: [
const Expanded(
child: Text(
'Split tenders',
style:
TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600),
),
),
TextButton(
onPressed: controller.clearSplits,
style: TextButton.styleFrom(
foregroundColor: AppColors.danger,
minimumSize: const Size(0, 32),
),
child: const Text('Clear'),
),
],
),
for (final e in state.splits.asMap().entries)
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
child: Row(
children: [
Text(e.value.method.emoji,
style: const TextStyle(fontSize: 15),),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
e.value.method.label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13.5),
),
),
Text(Formatters.money(e.value.amount),
style: AppTypography.money(13.5),),
IconButton(
onPressed: () => controller.removeSplit(e.key),
icon: const Icon(Icons.close_rounded, size: 16),
color: AppColors.textTertiary,
constraints:
const BoxConstraints(minWidth: 30, minHeight: 30),
padding: EdgeInsets.zero,
tooltip: 'Remove tender',
),
],
),
),
],
],
),
);
@@ -350,23 +325,115 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
return GlassCard(
padding: const EdgeInsets.all(AppSpacing.lg),
radius: AppRadius.xl,
child: state.activeMethod.needsChange
? _cashTender(controller)
: _referenceTender(controller, state),
child: _amountTender(controller, state),
);
}
Widget _cashTender(PaymentController controller) {
/// Amount entry for whichever method is active.
///
/// Every method works the same way — a keypad, an Exact shortcut and an
/// explicit amount — rather than cash alone asking what was received while
/// card/UPI/wallet silently assumed the full balance. Cash additionally gets
/// denomination shortcuts and a change-due row, since only cash can be
/// over-tendered; a method that captures a reference (card, UPI, gift card)
/// gets that field below the keypad.
Widget _amountTender(PaymentController controller, PaymentState state) {
final cash = state.activeMethod.needsChange;
return LayoutBuilder(
builder: (context, box) {
// Wide enough to stand the shortcuts beside the keypad instead of
// above it. A centred 330px keypad in a 560px column was the other
// half of the lopsided look — the space beside it did nothing.
final sideBySide = cash && box.maxWidth >= 500;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Cash received',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
Row(
children: [
Icon(methodIcon(state.activeMethod),
size: 20, color: AppColors.primary,),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
cash
? 'Cash received'
: '${state.activeMethod.label} amount received',
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: AppSpacing.md),
Container(
_amountField(),
const SizedBox(height: AppSpacing.md),
if (!sideBySide) ...[
_shortcutWrap(controller, cash),
const SizedBox(height: AppSpacing.md),
],
if (cash) ...[
_changeRow(controller.changeDue),
const SizedBox(height: AppSpacing.md),
],
if (sideBySide)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _shortcutColumn(controller)),
const SizedBox(width: AppSpacing.lg),
SizedBox(width: 296, child: _keypad()),
],
)
else
Center(child: _keypad()),
if (state.activeMethod.needsReference) ...[
const SizedBox(height: AppSpacing.md),
TextField(
onChanged: controller.setReference,
decoration: InputDecoration(
labelText: switch (state.activeMethod) {
PaymentMethod.card => 'Approval code',
PaymentMethod.upi => 'UPI transaction ID',
PaymentMethod.giftCard => 'Gift card number',
_ => 'Reference',
},
prefixIcon: const Icon(Icons.tag_rounded),
),
),
],
_partPaymentAction(controller, state),
_receivedSoFar(controller, state),
],
);
},
);
}
Widget _keypad() => NumericKeypad(
allowDecimal: true,
maxWidth: 330,
onKey: _appendCash,
onBackspace: _backspaceCash,
);
/// The typed amount.
///
/// The symbol sits in the same run as the digits and in the same style. It
/// used to be a separate, smaller, grey glyph, which rendered as a mismatched
/// mark floating beside the number rather than part of it.
Widget _amountField() {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.md,
@@ -378,25 +445,26 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
),
child: Row(
children: [
const Text('',
style:
TextStyle(fontSize: 22, color: AppColors.textTertiary),),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
_cashBuffer.isEmpty ? '0' : _cashBuffer,
style: AppTypography.money(28),
'${_cashBuffer.isEmpty ? '0' : _cashBuffer}',
style: AppTypography.money(30),
),
),
),
],
),
),
const SizedBox(height: AppSpacing.md),
Wrap(
);
}
static const List<int> _notes = [50, 100, 200, 500, 2000];
/// Shortcuts above the keypad, for the narrow layout.
Widget _shortcutWrap(PaymentController controller, bool cash) {
return Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
@@ -405,27 +473,47 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
label: const Text('Exact'),
onPressed: () => _setCash(controller.balanceDue),
),
for (final note in const [50, 100, 200, 500, 2000])
// Denomination shortcuts only make sense for physical notes.
if (cash)
for (final note in _notes)
ActionChip(
label: Text('$note'),
onPressed: () =>
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
),
],
),
const SizedBox(height: AppSpacing.md),
_changeRow(controller.changeDue),
const SizedBox(height: AppSpacing.md),
Center(
child: NumericKeypad(
allowDecimal: true,
maxWidth: 330,
onKey: _appendCash,
onBackspace: _backspaceCash,
);
}
/// Shortcuts beside the keypad, for the wide layout.
Widget _shortcutColumn(PaymentController controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
FilledButton.tonalIcon(
onPressed: () => _setCash(controller.balanceDue),
icon: const Icon(Icons.done_all_rounded, size: 17),
label: Text('Exact ${Formatters.money(controller.balanceDue)}'),
style: FilledButton.styleFrom(
minimumSize: const Size(0, 48),
backgroundColor: AppColors.primarySurface,
foregroundColor: AppColors.primary,
),
),
const SizedBox(height: AppSpacing.md),
_splitButton(controller, amount: double.tryParse(_cashBuffer) ?? 0),
const SizedBox(height: AppSpacing.sm),
for (final note in _notes) ...[
OutlinedButton(
onPressed: () =>
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 48),
foregroundColor: AppColors.textPrimary,
),
child: Text('+ ₹$note'),
),
if (note != _notes.last) const SizedBox(height: AppSpacing.sm),
],
],
);
}
@@ -473,87 +561,380 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
);
}
Widget _referenceTender(PaymentController controller, PaymentState state) {
return Column(
/// Takes part of the bill on the current method.
///
/// This is the old "Add as split payment" button, and it stages exactly the
/// same tender — but it no longer asks the cashier to know what a split is,
/// or to press it for a payment that is not one. It appears only when the
/// typed amount is genuinely short of the balance, and says what it will do
/// in the customer's terms: take this much now, leave that much to pay.
Widget _partPaymentAction(
PaymentController controller,
PaymentState state,
) {
final entered = double.tryParse(_cashBuffer) ?? 0;
final due = controller.balanceDue;
final short = entered > 0.009 && entered < due - 0.009;
if (!short) return const SizedBox.shrink();
final rest = due - entered;
return Padding(
padding: const EdgeInsets.only(top: AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
FilledButton.tonalIcon(
onPressed: () {
controller.addSplit(amount: entered);
setState(() => _cashBuffer = '');
},
icon: const Icon(Icons.add_rounded, size: 18),
label: Text(
'Take ${Formatters.money(entered)} by '
'${state.activeMethod.label}',
overflow: TextOverflow.ellipsis,
),
style: FilledButton.styleFrom(minimumSize: const Size(0, 48)),
),
const SizedBox(height: AppSpacing.xs),
Text(
'${Formatters.money(rest)} left to pay — pick another method for '
'the rest.',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
height: 1.4,
),
),
],
),
);
}
/// Tenders already staged against this bill, and what is still outstanding.
Widget _receivedSoFar(PaymentController controller, PaymentState state) {
if (state.splits.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
const Divider(height: 1),
const SizedBox(height: AppSpacing.md),
Row(
children: [
const Expanded(
child: Text(
'Received so far',
style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600),
),
),
TextButton(
onPressed: controller.clearSplits,
style: TextButton.styleFrom(
foregroundColor: AppColors.danger,
minimumSize: const Size(0, 32),
),
child: const Text('Clear'),
),
],
),
for (final e in state.splits.asMap().entries)
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
child: Row(
children: [
Icon(methodIcon(e.value.method),
size: 17, color: AppColors.textSecondary,),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
e.value.method.label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13.5),
),
),
Text(Formatters.money(e.value.amount),
style: AppTypography.money(13.5),),
IconButton(
onPressed: () => controller.removeSplit(e.key),
icon: const Icon(Icons.close_rounded, size: 16),
color: AppColors.textTertiary,
constraints:
const BoxConstraints(minWidth: 30, minHeight: 30),
padding: EdgeInsets.zero,
tooltip: 'Remove tender',
),
],
),
),
const SizedBox(height: AppSpacing.xs),
Row(
children: [
const Expanded(
child: Text(
'Still to pay',
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
),
Text(
Formatters.money(controller.balanceDue),
style: AppTypography.money(
15,
color: controller.balanceDue > 0
? AppColors.warning
: AppColors.success,
),
),
],
),
],
),
);
}
// --------------------------------------------------------------- Offers
/// Campaigns on this bill, and the nearest one that is not on it yet.
///
/// The engine already applies everything that qualifies, silently — the
/// shopper only ever saw a discount line. Two things were missing at the
/// counter: a cashier could not answer "did the weekend offer come off?"
/// without opening the promo module, and nobody could see that a bill was a
/// few rupees short of one. The near-miss rows are the point of this card:
/// a minimum-bill campaign is worth nothing if the person paying is never
/// told they are close to it.
///
/// Read-only. Nothing here applies or removes a campaign — that stays with
/// [PromoEngine], so the till cannot be talked into a discount by hand.
Widget _offersCard() {
final cart = ref.watch(cartControllerProvider);
final promos = ref.watch(activePromosProvider).value ?? const <Promo>[];
final applied = cart.appliedPromos;
final appliedIds = applied.map((a) => a.promo.id).toSet();
final now = DateTime.now();
// Live today, not already firing, and gated only by a bill minimum this
// cart has not reached. A campaign that fails for any other reason —
// wrong category, wrong product, wrong day — is not "nearly earned" and
// saying so would be a false promise.
final withinReach = promos
.where((p) =>
p.isLiveAt(now) &&
!appliedIds.contains(p.id) &&
p.minBillValue > 0 &&
cart.subtotal < p.minBillValue,)
.toList()
..sort((a, b) => a.minBillValue.compareTo(b.minBillValue));
if (applied.isEmpty && withinReach.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.md),
child: GlassCard(
padding: const EdgeInsets.all(AppSpacing.lg),
radius: AppRadius.xl,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(state.activeMethod.emoji,
style: const TextStyle(fontSize: 20),),
const Icon(Icons.sell_outlined,
size: 18, color: AppColors.primary,),
const SizedBox(width: AppSpacing.sm),
Expanded(
const Expanded(
child: Text(
'${state.activeMethod.label} payment',
overflow: TextOverflow.ellipsis,
'Offers',
style:
const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
),
],
),
const SizedBox(height: AppSpacing.xxl),
Center(
child: Container(
width: 104,
height: 104,
decoration: const BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brXl,
),
alignment: Alignment.center,
child: Text(state.activeMethod.emoji,
style: const TextStyle(fontSize: 46),),
),
),
const SizedBox(height: AppSpacing.lg),
if (applied.isNotEmpty)
Text(
'Charge ${Formatters.money(controller.balanceDue)} on the '
'${state.activeMethod.label.toLowerCase()} terminal',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13.5,
color: AppColors.textSecondary,
height: 1.5,
' ${Formatters.money(
applied.fold<double>(0, (sum, a) => sum + a.amount),
)}',
style: AppTypography.money(14, color: AppColors.success),
),
),
const SizedBox(height: AppSpacing.xxl),
if (state.activeMethod.needsReference)
TextField(
onChanged: controller.setReference,
decoration: InputDecoration(
labelText: switch (state.activeMethod) {
PaymentMethod.card => 'Approval code',
PaymentMethod.upi => 'UPI transaction ID',
PaymentMethod.giftCard => 'Gift card number',
_ => 'Reference',
},
prefixIcon: const Icon(Icons.tag_rounded),
),
),
const SizedBox(height: AppSpacing.lg),
_splitButton(controller),
],
),
const SizedBox(height: AppSpacing.md),
for (final a in applied)
_offerRow(
icon: Icons.check_circle_rounded,
tone: AppColors.success,
title: a.promo.name,
subtitle: a.promo.summary,
trailing: ' ${Formatters.money(a.amount)}',
),
// Two is the useful number: the next one to reach and the one
// after it. A full list turns a payment screen into a catalogue
// of things the shopper is not getting.
for (final p in withinReach.take(2))
_offerRow(
icon: Icons.lock_open_rounded,
tone: AppColors.warning,
title: p.name,
subtitle: '${p.summary} \u00b7 add '
'${Formatters.money(p.minBillValue - cart.subtotal)} more '
'to reach ${Formatters.money(p.minBillValue)}',
),
],
),
),
);
}
Widget _splitButton(PaymentController controller, {double? amount}) {
return OutlinedButton.icon(
onPressed: controller.balanceDue > 0
? () {
controller.addSplit(
amount: amount?.clamp(0, controller.balanceDue).toDouble(),
Widget _offerRow({
required IconData icon,
required Color tone,
required String title,
required String subtitle,
String? trailing,
}) =>
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 17, color: tone),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
),
),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
height: 1.4,
),
),
],
),
),
if (trailing != null) ...[
const SizedBox(width: AppSpacing.sm),
Text(trailing, style: AppTypography.money(13.5, color: tone)),
],
],
),
);
setState(() => _cashBuffer = '');
}
: null,
icon: const Icon(Icons.call_split_rounded, size: 17),
label: const Text('Add as split payment'),
style: OutlinedButton.styleFrom(minimumSize: const Size(0, 44)),
// --------------------------------------------------------- Bill summary
/// What the amount due is made of.
Widget _summaryCard() {
final cart = ref.watch(cartControllerProvider);
final discounts = cart.lineDiscountTotal + cart.billDiscountTotal;
return GlassCard(
padding: const EdgeInsets.all(AppSpacing.lg),
radius: AppRadius.xl,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Bill summary',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: AppSpacing.md),
_summaryRow('Subtotal', Formatters.money(cart.subtotal)),
if (discounts > 0)
_summaryRow(
'Discounts',
' ${Formatters.money(discounts)}',
tone: AppColors.success,
),
if (cart.loyaltyRedemptionValue > 0)
_summaryRow(
'Points redeemed',
' ${Formatters.money(cart.loyaltyRedemptionValue)}',
tone: AppColors.success,
),
if (cart.roundOff != 0)
_summaryRow('Round off', Formatters.money(cart.roundOff)),
const Divider(height: AppSpacing.xl),
_summaryRow(
'Total',
Formatters.money(cart.grandTotal),
strong: true,
),
const SizedBox(height: AppSpacing.xs),
// Prices are GST-inclusive, so this is a breakdown of the total
// rather than another line added to it — said plainly, because a
// customer reading a tax figure will otherwise try to add it on.
Text(
'Includes GST ${Formatters.money(cart.taxAmount)} '
'(CGST ${Formatters.money(cart.cgst)} + '
'SGST ${Formatters.money(cart.sgst)})',
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
height: 1.45,
),
),
],
),
);
}
Widget _summaryRow(
String label,
String value, {
Color? tone,
bool strong = false,
}) =>
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: strong ? 14.5 : 13.5,
fontWeight: strong ? FontWeight.w600 : FontWeight.w400,
color: strong
? AppColors.textPrimary
: (tone ?? AppColors.textSecondary),
),
),
),
Text(
value,
style: AppTypography.money(
strong ? 17 : 13.5,
color: tone ?? (strong ? AppColors.primary : null),
),
),
],
),
);
// ------------------------------------------------------------ Bottom bar
Widget _bottomBar(
PaymentController controller,
@@ -622,7 +1003,6 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
),
),
),
if (state.activeMethod.needsChange)
TextButton(
onPressed: () => _setCash(controller.balanceDue),
style: TextButton.styleFrom(
@@ -705,7 +1085,11 @@ class _MethodTile extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(method.emoji, style: const TextStyle(fontSize: 22)),
Icon(
methodIcon(method),
size: 22,
color: selected ? Colors.white : AppColors.textSecondary,
),
const SizedBox(height: AppSpacing.xs),
Text(
method.label,
@@ -725,3 +1109,19 @@ class _MethodTile extends StatelessWidget {
);
}
}
/// A flat icon per tender type.
///
/// These were emoji — 💵 for cash, 💳 for card — which render as small
/// photographic pictures on most platforms and as a fallback box on some. Next
/// to Material iconography everywhere else on the screen they read as clip
/// art pasted into the UI rather than part of it, and the cash one in
/// particular looked like a picture of American banknotes on a rupee till.
IconData methodIcon(PaymentMethod method) => switch (method) {
PaymentMethod.cash => Icons.payments_outlined,
PaymentMethod.card => Icons.credit_card_rounded,
PaymentMethod.upi => Icons.qr_code_2_rounded,
PaymentMethod.wallet => Icons.account_balance_wallet_outlined,
PaymentMethod.giftCard => Icons.card_giftcard_rounded,
PaymentMethod.loyalty => Icons.stars_rounded,
};

View File

@@ -9,9 +9,11 @@ import '../../../core/services/sound_service.dart';
import '../../../domain/entities/cart.dart';
import '../../../domain/entities/customer.dart';
import '../../../domain/entities/product.dart';
import '../../../domain/entities/promo.dart';
import '../../../domain/entities/transaction.dart';
import '../../../domain/repositories/product_repository.dart';
import '../../../domain/repositories/transaction_repository.dart';
import '../../../domain/services/promo_engine.dart';
/// Transient feedback for the scan toast — never a blocking dialog.
enum ScanOutcome { added, incremented, notFound, outOfStock }
@@ -43,9 +45,13 @@ class CartController extends StateNotifier<Cart> {
required TransactionRepository transactions,
required SoundService sound,
required this.onFeedback,
List<Promo> promos = const [],
DateTime Function()? clock,
}) : _products = products,
_transactions = transactions,
_sound = sound,
_promos = promos,
_now = clock ?? DateTime.now,
super(Cart.empty);
final ProductRepository _products;
@@ -53,8 +59,29 @@ class CartController extends StateNotifier<Cart> {
final SoundService _sound;
final void Function(ScanFeedback) onFeedback;
/// Campaigns live right now. Re-evaluated after every change to the bill,
/// because whether one fires depends on what is in it.
final List<Promo> _promos;
final DateTime Function() _now;
static const _uuid = Uuid();
/// Applies the campaign rules to [next] and stores the result.
///
/// Every mutation goes through here rather than assigning `state` directly,
/// so a promo cannot be left applied after the line that earned it is
/// removed — which is how a shopper gets a discount for an item they put
/// back.
void _commit(Cart next) {
state = next.copyWith(
appliedPromos: PromoEngine.evaluate(
cart: next,
promos: _promos,
at: _now(),
),
);
}
/// Snapshots for undo — capped so memory can't grow unbounded on a terminal
/// that runs for days.
final List<Cart> _undoStack = [];
@@ -105,18 +132,18 @@ class CartController extends StateNotifier<Cart> {
}
if (existing == null) {
state = state.copyWith(lines: [
_commit(state.copyWith(lines: [
...state.lines,
CartLine(
product: product,
quantity: quantity,
addedAt: DateTime.now(),
),
],);
],),);
} else {
state = state.copyWith(
_commit(state.copyWith(
lines: _replace(existing.copyWith(quantity: requested)),
);
),);
}
_clampRedemption();
@@ -171,7 +198,7 @@ class CartController extends StateNotifier<Cart> {
}
_push();
state = state.copyWith(lines: _replace(line.copyWith(quantity: capped)));
_commit(state.copyWith(lines: _replace(line.copyWith(quantity: capped))));
_clampRedemption();
}
@@ -181,18 +208,27 @@ class CartController extends StateNotifier<Cart> {
setQuantity(productId, line.quantity + by);
}
/// Steps a line down, never off the bill.
///
/// Floors at one deliberately. Taking the last unit away is a removal, and
/// removals go through the PIN gate on the close button — a stepper that
/// quietly reached zero was a way around it.
void decrement(String productId, {double by = 1}) {
final line = state.lineFor(productId);
if (line == null) return;
setQuantity(productId, line.quantity - by);
final next = line.quantity - by;
if (next < 1) return;
setQuantity(productId, next);
}
void removeLine(String productId) {
if (!state.contains(productId)) return;
_push();
state = state.copyWith(
_commit(state.copyWith(
lines: state.lines.where((l) => l.product.id != productId).toList(),
);
),);
_clampRedemption();
}
@@ -200,14 +236,14 @@ class CartController extends StateNotifier<Cart> {
final line = state.lineFor(productId);
if (line == null) return;
_push();
state = state.copyWith(lines: _replace(line.copyWith(discount: discount)));
_commit(state.copyWith(lines: _replace(line.copyWith(discount: discount))));
_clampRedemption();
}
// ------------------------------------------------------------ Bill level
void applyBillDiscount(Discount discount) {
_push();
state = state.copyWith(billDiscount: discount);
_commit(state.copyWith(billDiscount: discount));
_clampRedemption();
}
@@ -215,9 +251,11 @@ class CartController extends StateNotifier<Cart> {
void attachCustomer(Customer? customer) {
_push();
state = customer == null
_commit(
customer == null
? state.copyWith(clearCustomer: true, pointsRedeemed: 0)
: state.copyWith(customer: customer);
: state.copyWith(customer: customer),
);
_clampRedemption();
}
@@ -273,9 +311,46 @@ class CartController extends StateNotifier<Cart> {
}
Future<void> resume(ParkedBill bill) async {
// Resuming a bill on top of an already-active cart would otherwise
// silently overwrite whatever was already there — picking a second
// parked bill while the first one's items are still sitting in the
// cart, unsaved. Same hole startNewSale closes for the header button;
// this closes it here by parking what's active first.
if (state.isNotEmpty) {
await park(label: 'Auto-parked — replaced by resuming another bill');
}
await _transactions.removeParked(bill.id);
_undoStack.clear();
state = bill.cart;
// Re-evaluated rather than restored: a campaign that has since ended must
// not be honoured just because the bill was parked while it was running.
_commit(bill.cart);
}
/// What the header's "New Sale" button actually calls.
///
/// A cashier can always start over — that's the whole point of the escape
/// hatch — but silently wiping a non-empty cart here would be the exact
/// same hole the removal PIN closes: scan an item, then abandon the cart
/// instead of removing that one line, and it disappears just the same,
/// with nobody having approved anything. So a non-empty cart is parked,
/// not discarded — visible and resumable from Parked bills — and only an
/// already-empty cart takes the plain reset path.
Future<void> startNewSale() async {
if (state.isNotEmpty) {
await park(label: 'Auto-parked — new sale started with items still in cart');
return;
}
reset();
}
/// Puts a cart back exactly as it was just before a sale that has since
/// been voided, so cancelling a completed bill doesn't make the cashier
/// re-scan everything. Promos are re-evaluated for the same reason
/// [resume] re-evaluates them, not restored verbatim.
void restore(Cart cart) {
_undoStack.clear();
_commit(cart);
}
List<CartLine> _replace(CartLine updated) => [
@@ -293,6 +368,10 @@ final cartControllerProvider =
products: ref.watch(productRepositoryProvider),
transactions: ref.watch(transactionRepositoryProvider),
sound: ref.watch(soundServiceProvider),
// Watched, so editing a campaign in Settings takes effect at the till
// without a restart. An empty list until they load is correct — no promo
// is safer than a stale one.
promos: ref.watch(activePromosProvider).value ?? const [],
onFeedback: (feedback) =>
ref.read(scanFeedbackProvider.notifier).state = feedback,
);

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../auth/providers/auth_controller.dart';
/// The modules a cashier needs. Deliberately excludes analytics — this
/// terminal is for billing, not back-office reporting.
enum PosModule {
@@ -41,4 +43,37 @@ enum NavSection {
PosModule.values.where((m) => m.section == this).toList();
}
/// What a cashier session may open.
///
/// Billing, and nothing else — not the catalogue, the promos, the sync log or
/// the terminal's configuration. The sidebar is hidden in cashier mode anyway,
/// so this is the belt to that braces: a module reached some other way (a scan
/// handler, a deep link, a stale value left in [activeModuleProvider] from the
/// admin's session) still cannot render.
const cashierModules = <PosModule>[PosModule.pos];
final activeModuleProvider = StateProvider<PosModule>((ref) => PosModule.pos);
/// The modules the current session is allowed to reach.
final visibleModulesProvider = Provider<List<PosModule>>((ref) {
return ref.watch(isCashierModeProvider) ? cashierModules : PosModule.values;
});
/// [activeModuleProvider], clamped to what this session may open.
///
/// Read this rather than the raw value anywhere a module decides what gets
/// built. An admin who leaves the shell on Settings and hands the till to a
/// cashier would otherwise reopen it on Settings.
final resolvedModuleProvider = Provider<PosModule>((ref) {
final active = ref.watch(activeModuleProvider);
final visible = ref.watch(visibleModulesProvider);
return visible.contains(active) ? active : PosModule.pos;
});
/// Sections that still have at least one module this session may open.
final visibleSectionsProvider = Provider<List<NavSection>>((ref) {
final visible = ref.watch(visibleModulesProvider);
return NavSection.values
.where((s) => s.modules.any(visible.contains))
.toList();
});

View File

@@ -6,11 +6,13 @@ import '../../../core/services/barcode_service.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_layout.dart';
import '../../auth/providers/auth_controller.dart';
import '../../modules/screens/customers_view.dart';
import '../../modules/screens/events_view.dart';
import '../../modules/screens/product_import_view.dart';
import '../../modules/screens/promos_view.dart';
import '../../modules/screens/settings_view.dart';
import '../../modules/widgets/staff_dialogs.dart';
import '../../sync/providers/sync_controller.dart';
import '../providers/cart_controller.dart';
import '../providers/catalog_providers.dart';
@@ -33,6 +35,11 @@ import 'pos_view.dart';
/// * `11201300` sidebar as an icon rail, docked bill
/// * `9201120` icon rail, bill becomes a bottom sheet
/// * `< 920` sidebar goes off-canvas behind a menu button
///
/// In cashier mode the sidebar is not rendered at any width. That session has
/// exactly two destinations, and both are reachable from the header — a rail
/// holding one live tile is chrome for its own sake. Sign-out and the sync log
/// move up with it, since the sidebar was the only place they lived.
class PosDashboardScreen extends ConsumerStatefulWidget {
const PosDashboardScreen({super.key});
@@ -45,10 +52,18 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
final FocusNode _searchFocus = FocusNode();
late final BarcodeService _barcode;
/// Guards against re-opening the PIN dialog on every rebuild.
bool _promptedForPin = false;
@override
void initState() {
super.initState();
// Anyone still on a PIN this build shipped with is asked to choose their
// own before ringing a sale. Deferred to the first frame because it opens
// a dialog, which needs a Navigator that exists.
WidgetsBinding.instance.addPostFrameCallback((_) => _maybePromptForPin());
// The scanner behaves like a keyboard, so listen globally rather than
// depending on any one field holding focus. A scan from another module
// jumps back to billing, which is what a cashier expects.
@@ -65,6 +80,14 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
)..attach();
}
Future<void> _maybePromptForPin() async {
if (_promptedForPin || !mounted) return;
if (!ref.read(mustChangePinProvider)) return;
_promptedForPin = true;
await showForcedPinChange(context);
}
@override
void dispose() {
_barcode.dispose();
@@ -101,10 +124,15 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
@override
Widget build(BuildContext context) {
final layout = PosLayout.of(context);
final module = ref.watch(activeModuleProvider);
// Clamped, not raw: a module the admin left active must not carry into a
// cashier session.
final module = ref.watch(resolvedModuleProvider);
final ready = ref.watch(catalogueReadyProvider);
final isPos = module == PosModule.pos && ready;
final cashierMode = ref.watch(isCashierModeProvider);
final showSidebar = !cashierMode;
// Only the terminal itself needs the bill docked beside it.
final showDockedBill = isPos && !layout.billingIsSheet;
final showCartFab = isPos && layout.billingIsSheet;
@@ -112,7 +140,7 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
return Scaffold(
key: _scaffoldKey,
backgroundColor: AppColors.background,
drawer: layout.sidebarIsDrawer
drawer: showSidebar && layout.sidebarIsDrawer
? Drawer(
width: PosLayout.expandedWidth,
backgroundColor: AppColors.surface,
@@ -139,17 +167,29 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!layout.sidebarIsDrawer) AppSidebar(mode: layout.sidebar),
if (showSidebar && !layout.sidebarIsDrawer)
AppSidebar(mode: layout.sidebar),
Expanded(
child: Column(
children: [
PageHeader(
layout: layout,
onMenuTap: () => _scaffoldKey.currentState?.openDrawer(),
// Nothing to open in cashier mode, so the button is not
// offered rather than opening an empty drawer.
onMenuTap: showSidebar
? () => _scaffoldKey.currentState?.openDrawer()
: null,
),
Expanded(
child: AnimatedSwitcher(
duration: AppMotion.fast,
// Top, not the default centre. The switcher stacks its
// children with loose constraints, so a module page
// shorter than the viewport — Promotions with two
// campaigns, Product Import before anything is pulled —
// sized itself to its content and then floated in the
// middle of the screen with dead space above it.
child: KeyedSubtree(
key: ValueKey(module),
child: _body(module, layout),

View File

@@ -5,6 +5,7 @@ import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_layout.dart';
import '../../../core/widgets/primary_button.dart';
import '../../auth/providers/auth_controller.dart';
import '../../sync/providers/sync_controller.dart';
import '../providers/navigation_provider.dart';
import '../widgets/category_chips.dart';
@@ -41,8 +42,8 @@ class PosView extends ConsumerWidget {
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const CustomerBar(),
const Divider(height: 1),
Padding(
padding:
EdgeInsets.fromLTRB(pad, AppSpacing.lg, pad, AppSpacing.md),
@@ -82,6 +83,11 @@ class _CatalogueRequired extends ConsumerWidget {
final state = ref.watch(catalogueImportProvider);
final running = state is ImportRunning;
// Pulling the catalogue is an admin job, and a cashier has no Product
// Import module to be sent to. Offering them a button that opens a screen
// they cannot reach is worse than telling them who to ask.
final cashier = ref.watch(isCashierModeProvider);
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.xxl),
@@ -102,17 +108,23 @@ class _CatalogueRequired extends ConsumerWidget {
),
const SizedBox(height: AppSpacing.xxl),
Text(
'Import products to start billing',
cashier
? 'No products on this terminal'
: 'Import products to start billing',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: AppSpacing.sm),
const Text(
'This terminal has no catalogue yet. Pull the current products '
'once at the start of your shift — after that everything runs '
'offline.',
Text(
cashier
? 'Nothing has been imported for this shift yet. Ask an '
'admin to sign in and pull the catalogue — once they '
'have, sign in again and everything runs offline.'
: 'This terminal has no catalogue yet. Pull the current '
'products once at the start of your shift — after that '
'everything runs offline.',
textAlign: TextAlign.center,
style: TextStyle(
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
height: 1.6,
@@ -120,7 +132,7 @@ class _CatalogueRequired extends ConsumerWidget {
),
const SizedBox(height: AppSpacing.xxl),
if (running) ...[
if (!cashier && state is ImportRunning) ...[
Text(
state.stage,
style: const TextStyle(
@@ -142,7 +154,7 @@ class _CatalogueRequired extends ConsumerWidget {
const SizedBox(height: AppSpacing.lg),
],
if (state is ImportFailed) ...[
if (!cashier && state is ImportFailed) ...[
Container(
padding: const EdgeInsets.all(AppSpacing.md),
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
@@ -171,6 +183,34 @@ class _CatalogueRequired extends ConsumerWidget {
),
],
if (cashier)
Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: const BoxDecoration(
color: AppColors.infoSurface,
borderRadius: AppRadius.brSm,
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.admin_panel_settings_outlined,
size: 18, color: AppColors.info,),
SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
'Importing the catalogue is an admin job. Nothing '
'can be billed here until it has been done.',
style: TextStyle(
color: AppColors.info,
fontSize: 13,
height: 1.45,
),
),
),
],
),
)
else ...[
PrimaryButton(
label: 'Import catalogue now',
icon: Icons.cloud_download_rounded,
@@ -189,6 +229,7 @@ class _CatalogueRequired extends ConsumerWidget {
label: const Text('Open Product Import'),
),
],
],
),
),
),

View File

@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/numeric_keypad.dart';
/// Prompts for the removal PIN before taking a rung item back off a bill, and
/// resolves `true` only once it verifies.
///
/// The PIN is the one an admin sets in Settings, and an admin's own staff PIN
/// always works too — so a cashier can void a line at the counter without an
/// admin walking over, and the owner is never locked out of their own till.
///
/// A cashier can always start a brand new sale; what this exists to stop is
/// quietly taking something back out of a bill a customer has already been
/// shown, after it was rung up.
Future<bool> requireVoidPin(
BuildContext context,
WidgetRef ref, {
required String reason,
}) async {
final ok = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (_) => _AdminPinDialog(reason: reason),
);
return ok ?? false;
}
class _AdminPinDialog extends ConsumerStatefulWidget {
const _AdminPinDialog({required this.reason});
final String reason;
@override
ConsumerState<_AdminPinDialog> createState() => _AdminPinDialogState();
}
class _AdminPinDialogState extends ConsumerState<_AdminPinDialog> {
String _pin = '';
String? _error;
bool _checking = false;
void _key(String digit) {
if (_checking || _pin.length >= 8) return;
setState(() {
_pin += digit;
_error = null;
});
}
void _backspace() {
if (_checking || _pin.isEmpty) return;
setState(() => _pin = _pin.substring(0, _pin.length - 1));
}
void _clear() {
if (_checking) return;
setState(() => _pin = '');
}
Future<void> _submit() async {
if (_pin.isEmpty || _checking) return;
setState(() {
_checking = true;
_error = null;
});
final store = ref.read(localStoreProvider);
final ok = await store.voidPin.verify(_pin);
if (!mounted) return;
if (!ok) {
setState(() {
_checking = false;
_error = 'Incorrect PIN.';
_pin = '';
});
return;
}
Navigator.of(context).pop(true);
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: const RoundedRectangleBorder(borderRadius: AppRadius.brLg),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.xl),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(Icons.lock_outline_rounded,
color: AppColors.danger, size: 20,),
const SizedBox(width: AppSpacing.sm),
const Expanded(
child: Text(
'Removal PIN required',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
InkWell(
onTap: () => Navigator.of(context).pop(false),
borderRadius: AppRadius.brSm,
child: const Padding(
padding: EdgeInsets.all(4),
child: Icon(Icons.close_rounded,
size: 20, color: AppColors.textSecondary,),
),
),
],
),
const SizedBox(height: AppSpacing.xs),
Text(
widget.reason,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
height: 1.4,
),
),
const SizedBox(height: AppSpacing.lg),
SizedBox(
height: 20,
child: _pin.isEmpty
? const Center(
child: Text(
'Enter PIN',
style: TextStyle(
fontSize: 13,
color: AppColors.textTertiary,
),
),
)
: Wrap(
alignment: WrapAlignment.center,
spacing: 10,
children: [
for (var i = 0; i < _pin.length; i++)
Container(
width: 12,
height: 12,
decoration: const BoxDecoration(
color: AppColors.primary,
shape: BoxShape.circle,
),
),
],
),
),
if (_error != null) ...[
const SizedBox(height: AppSpacing.sm),
Text(
_error!,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.danger,
fontWeight: FontWeight.w600,
),
),
],
const SizedBox(height: AppSpacing.lg),
NumericKeypad(
onKey: _key,
onBackspace: _backspace,
onClear: _clear,
onSubmit: _checking ? null : _submit,
submitLabel: _checking ? 'Checking…' : 'Approve',
),
],
),
),
),
);
}
}

View File

@@ -6,9 +6,9 @@ import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_layout.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/widgets/brand_mark.dart';
import '../../../core/utils/formatters.dart';
import '../../auth/providers/auth_controller.dart';
import '../../sync/widgets/sign_out_dialog.dart';
import '../providers/cart_controller.dart';
import '../../sync/providers/sync_controller.dart';
import '../providers/navigation_provider.dart';
@@ -59,7 +59,9 @@ class AppSidebar extends ConsumerWidget {
padding: const EdgeInsets.symmetric(vertical: AppSpacing.md),
child: Column(
children: [
for (final section in NavSection.values)
// Sections with nothing this session may open are not
// rendered as empty headings.
for (final section in ref.watch(visibleSectionsProvider))
_Section(
section: section,
expanded: expanded,
@@ -69,8 +71,6 @@ class AppSidebar extends ConsumerWidget {
),
),
),
const Divider(height: 1),
_LogoutTile(expanded: expanded),
],
),
),
@@ -94,23 +94,7 @@ class _Brand extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(10),
),
alignment: Alignment.center,
child: const Text(
'N',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
),
const BrandMark(size: 36),
if (expanded) ...[
const SizedBox(width: AppSpacing.md),
Flexible(
@@ -228,7 +212,8 @@ class _Section extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final active = ref.watch(activeModuleProvider);
final active = ref.watch(resolvedModuleProvider);
final visible = ref.watch(visibleModulesProvider);
final cartCount = ref.watch(cartItemCountProvider);
final ready = ref.watch(catalogueReadyProvider);
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
@@ -255,7 +240,7 @@ class _Section extends ConsumerWidget {
),
child: Divider(height: 1),
),
for (final module in section.modules)
for (final module in section.modules.where(visible.contains))
_NavTile(
module: module,
expanded: expanded,
@@ -434,48 +419,3 @@ class _Badge extends StatelessWidget {
);
}
}
class _LogoutTile extends ConsumerWidget {
const _LogoutTile({required this.expanded});
final bool expanded;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => showSignOutDialog(context, ref),
borderRadius: AppRadius.brSm,
child: Container(
height: AppSizes.navItemHeight,
padding: EdgeInsets.symmetric(
horizontal: expanded ? AppSpacing.md : 0,
),
alignment: expanded ? Alignment.centerLeft : Alignment.center,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.logout_rounded,
size: 19, color: AppColors.danger,),
if (expanded) ...[
const SizedBox(width: AppSpacing.md),
const Text(
'Logout',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.danger,
),
),
],
],
),
),
),
),
);
}
}

View File

@@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_layout.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/extensions.dart';
import '../../../core/utils/formatters.dart';
@@ -15,8 +16,8 @@ import '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/cart.dart';
import '../../customer/widgets/customer_capture_sheet.dart';
import '../providers/cart_controller.dart';
import 'admin_pin_dialog.dart';
import 'cart_line_tile.dart';
import 'discount_sheet.dart';
/// Always-visible bill on the right of the dashboard.
class BillingPanel extends ConsumerWidget {
@@ -58,9 +59,12 @@ class BillingPanel extends ConsumerWidget {
line: line,
onIncrement: () => controller.increment(line.product.id),
onDecrement: () => controller.decrement(line.product.id),
onRemove: () => controller.removeLine(line.product.id),
onDiscount: () =>
showLineDiscountSheet(context, ref, line),
onRemove: () => _removeLine(
context,
ref,
controller,
line.product.id,
),
);
},
),
@@ -72,6 +76,24 @@ class BillingPanel extends ConsumerWidget {
}
}
/// Once an item is on the bill, taking it back off needs the removal PIN an
/// admin sets in Settings — a cashier can always start an entirely new sale
/// instead. This is the one gate every removal path goes through, so the
/// close button and the swipe can never drift apart.
Future<void> _removeLine(
BuildContext context,
WidgetRef ref,
CartController controller,
String productId,
) async {
final ok = await requireVoidPin(
context,
ref,
reason: 'Removing a scanned item from the bill needs the removal PIN.',
);
if (ok) controller.removeLine(productId);
}
class _Header extends ConsumerWidget {
const _Header({required this.cart, required this.inSheet});
@@ -80,17 +102,17 @@ class _Header extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.read(cartControllerProvider.notifier);
// Same fixed height as the page header on the left, so the two bars
// line up on one visual line instead of the cart title floating lower.
final contentPadding = PosLayout.of(context).contentPadding;
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.lg,
AppSpacing.md,
AppSpacing.sm,
AppSpacing.md,
),
return Container(
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
padding: EdgeInsets.symmetric(horizontal: contentPadding),
child: Row(
children: [
// Title and count read as one label, so they sit together rather
// than being pushed to opposite ends by a space-between row.
Flexible(
child: Text(
'Cart',
@@ -101,7 +123,10 @@ class _Header extends ConsumerWidget {
if (cart.isNotEmpty) ...[
const SizedBox(width: AppSpacing.sm),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 2,
),
decoration: const BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brPill,
@@ -116,33 +141,14 @@ class _Header extends ConsumerWidget {
),
),
],
const Spacer(),
// Icon-only actions: labelled buttons overflowed the 380px panel.
if (controller.canUndo)
_IconAction(
icon: Icons.undo_rounded,
tooltip: 'Undo (F8)',
color: AppColors.textSecondary,
onTap: controller.undo,
),
if (cart.isNotEmpty) ...[
_IconAction(
icon: Icons.pause_circle_outline_rounded,
tooltip: 'Park bill',
color: AppColors.warning,
onTap: () async {
await controller.park();
ref.invalidate(parkedBillsProvider);
if (context.mounted) context.showSnack('Bill parked');
},
),
_IconAction(
icon: Icons.delete_outline_rounded,
tooltip: 'Clear bill',
color: AppColors.danger,
onTap: controller.clear,
),
],
// Undo, Park and Clear used to sit here as three icon buttons. They
// are the least-pressed controls on the panel and they were the
// first thing the eye landed on, above the bill itself. Undo is on
// F8; Park and Clear moved down beside the total, next to the button
// a cashier is already reaching for.
if (inSheet)
_IconAction(
icon: Icons.close_rounded,
@@ -242,6 +248,17 @@ class _Summary extends ConsumerWidget {
valueColor: AppColors.success,
),
// Named individually rather than lumped into one "Promotions" line:
// a shopper who came in for a specific offer needs to see it applied,
// and a cashier being asked "did the weekend deal come off?" needs to
// answer without opening a report.
for (final applied in cart.appliedPromos)
_Row(
label: applied.promo.name,
value: '-${Formatters.money(applied.amount)}',
valueColor: AppColors.success,
),
_Row(
label: 'GST',
value: Formatters.money(cart.taxAmount),
@@ -252,21 +269,6 @@ class _Summary extends ConsumerWidget {
.join(', '),
),
InkWell(
onTap: () => showBillDiscountSheet(context, ref),
borderRadius: AppRadius.brXs,
child: _Row(
label: 'Discount',
value: cart.manualBillDiscountAmount > 0
? '-${Formatters.money(cart.manualBillDiscountAmount)}'
: '-${Formatters.money(0)}',
valueColor: cart.manualBillDiscountAmount > 0
? AppColors.success
: null,
trailingIcon: Icons.edit_outlined,
),
),
if (cart.maxRedeemablePoints > 0 || cart.pointsRedeemed > 0)
InkWell(
onTap: () => cart.pointsRedeemed > 0
@@ -349,7 +351,13 @@ class _Row extends StatelessWidget {
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
child: Row(children: [
child: Row(
// Start, not space-between. With space-between *and* a Spacer, the
// free width was shared out between every child — which pushed the
// slab hint away from the label it belongs to, so "GST" and "(5%, 12%)"
// read as two unrelated columns. The Spacer alone puts all the slack in
// one place, between the label group and the amount.
children: [
Flexible(
child: Text(
label,
@@ -362,13 +370,14 @@ class _Row extends StatelessWidget {
),
if (hint != null) ...[
const SizedBox(width: AppSpacing.xs),
Text('($hint)',
Text(
'($hint)',
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
),),
),
),
],
const SizedBox(width: AppSpacing.sm),
const Spacer(),
Text(
value,
@@ -382,7 +391,8 @@ class _Row extends StatelessWidget {
const SizedBox(width: AppSpacing.xs),
Icon(trailingIcon, size: 14, color: AppColors.textTertiary),
],
],),
],
),
);
}
}
@@ -403,18 +413,23 @@ class _Actions extends ConsumerWidget {
AppSpacing.xl,
AppSpacing.xl,
),
child: PrimaryButton(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
PrimaryButton(
label: 'CHARGE',
large: true,
onPressed: enabled
? () async {
// Ask once per bill, before payment. Skipping is one tap and
// leaves the sale as walk-in.
// Ask once per bill, before payment. Skipping is one tap
// and leaves the sale as walk-in.
if (ref.read(cartControllerProvider).customer == null) {
await showCustomerCaptureSheet(context);
}
// Navigation result is not needed here.
if (context.mounted) unawaited(context.push(AppRoutes.payment));
if (context.mounted) {
unawaited(context.push(AppRoutes.payment));
}
}
: null,
trailing: enabled
@@ -424,6 +439,8 @@ class _Actions extends ConsumerWidget {
)
: null,
),
],
),
);
}
}

View File

@@ -14,14 +14,12 @@ class CartLineTile extends StatelessWidget {
required this.onIncrement,
required this.onDecrement,
required this.onRemove,
this.onDiscount,
});
final CartLine line;
final VoidCallback onIncrement;
final VoidCallback onDecrement;
final VoidCallback onRemove;
final VoidCallback? onDiscount;
@override
Widget build(BuildContext context) {
@@ -30,7 +28,16 @@ class CartLineTile extends StatelessWidget {
return Dismissible(
key: ValueKey('dismiss_${p.id}'),
direction: DismissDirection.endToStart,
onDismissed: (_) => onRemove(),
// Confirm rather than dismiss: [onRemove] opens the PIN dialog, and a
// refused PIN must leave the line exactly where it was. Dismissing first
// and asking after left the row gone from the screen but still in the
// cart — and Flutter asserting about a dismissed widget still in the
// tree. Returning false always is correct: when the PIN is accepted the
// line disappears because the cart changed, not because of the swipe.
confirmDismiss: (_) async {
onRemove();
return false;
},
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: AppSpacing.xl),
@@ -119,7 +126,7 @@ class CartLineTile extends StatelessWidget {
color: AppColors.textTertiary,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
padding: EdgeInsets.zero,
tooltip: 'Remove',
tooltip: 'Remove from bill (needs the removal PIN)',
),
],),
@@ -132,17 +139,6 @@ class CartLineTile extends StatelessWidget {
onIncrement: onIncrement,
onDecrement: onDecrement,
),
if (onDiscount != null) ...[
const SizedBox(width: AppSpacing.sm),
IconButton(
onPressed: onDiscount,
icon: const Icon(Icons.local_offer_outlined, size: 17),
color: AppColors.textSecondary,
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
padding: EdgeInsets.zero,
tooltip: 'Line discount',
),
],
const Spacer(),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
@@ -209,8 +205,12 @@ class _Stepper extends StatelessWidget {
borderRadius: AppRadius.brSm,
border: Border.all(color: AppColors.border),
),
// Minus stops at one rather than emptying the line. Dropping to zero
// was a silent removal that skipped the PIN the close button asks for —
// two taps of a stepper should not be a way around the till's only
// theft control.
child: Row(mainAxisSize: MainAxisSize.min, children: [
_btn(Icons.remove_rounded, onDecrement),
_btn(Icons.remove_rounded, quantity > 1 ? onDecrement : null),
Container(
constraints: const BoxConstraints(minWidth: 42),
alignment: Alignment.center,
@@ -226,7 +226,7 @@ class _Stepper extends StatelessWidget {
);
}
Widget _btn(IconData icon, VoidCallback onTap) => Material(
Widget _btn(IconData icon, VoidCallback? onTap) => Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
@@ -234,7 +234,13 @@ class _Stepper extends StatelessWidget {
child: SizedBox(
width: 34,
height: 34,
child: Icon(icon, size: 17, color: AppColors.primary),
child: Icon(
icon,
size: 17,
color: onTap == null
? AppColors.textTertiary.withValues(alpha: 0.5)
: AppColors.primary,
),
),
),
);

View File

@@ -1,110 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/extensions.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/status_pill.dart';
import '../../customer/widgets/customer_capture_sheet.dart';
import '../providers/cart_controller.dart';
/// Strip above the product grid showing who the sale belongs to.
class CustomerBar extends ConsumerWidget {
const CustomerBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final customer = ref.watch(
cartControllerProvider.select((cart) => cart.customer),
);
return Container(
// A hard height clipped the subtitle once it wrapped. Minimum height
// keeps the strip its usual size but lets it grow if it must.
constraints: const BoxConstraints(
minHeight: AppSizes.customerBarHeight,
),
color: AppColors.surface,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xl,
vertical: AppSpacing.sm,
),
child: Row(children: [
CircleAvatar(
radius: 20,
backgroundColor: customer == null
? AppColors.border
: AppColors.primarySurface,
child: customer == null
? const Icon(Icons.directions_walk_rounded,
size: 20, color: AppColors.textSecondary,)
: Text(
Formatters.initials(customer.name),
style: const TextStyle(
color: AppColors.primary,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: AppSpacing.md),
Flexible(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(mainAxisSize: MainAxisSize.min, children: [
Flexible(
child: Text(
customer?.name ?? 'Walk-in Customer',
style: context.text.titleMedium,
overflow: TextOverflow.ellipsis,
),
),
if (customer != null) ...[
const SizedBox(width: AppSpacing.sm),
StatusPill.tier(customer.tier, dense: true),
],
],),
if (customer != null)
Text(
'${Formatters.mobile(customer.mobile)} · '
'${customer.loyaltyPoints} pts',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.text.bodySmall,
)
else
Text(
'No loyalty tracking for this sale',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.text.bodySmall,
),
],
),
),
const Spacer(),
if (customer != null)
TextButton.icon(
onPressed: () =>
ref.read(cartControllerProvider.notifier).attachCustomer(null),
icon: const Icon(Icons.person_off_outlined, size: 17),
label: const Text('Detach'),
style: TextButton.styleFrom(
foregroundColor: AppColors.textSecondary,),
),
const SizedBox(width: AppSpacing.sm),
OutlinedButton.icon(
onPressed: () => showCustomerCaptureSheet(context),
icon: const Icon(Icons.sync_alt_rounded, size: 17),
label: Text(customer == null ? 'Add Customer' : 'Change'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 44),
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
),
),
],),
);
}
}

View File

@@ -1,213 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/cart.dart';
import '../providers/cart_controller.dart';
Future<void> showLineDiscountSheet(
BuildContext context,
WidgetRef ref,
CartLine line,
) {
return _show(
context: context,
title: line.product.name,
subtitle: 'Line value ${Formatters.money(line.grossAmount)}',
current: line.discount,
onApply: (d) => ref
.read(cartControllerProvider.notifier)
.applyLineDiscount(line.product.id, d),
);
}
Future<void> showBillDiscountSheet(BuildContext context, WidgetRef ref) {
final cart = ref.read(cartControllerProvider);
return _show(
context: context,
title: 'Bill discount',
subtitle: 'Subtotal ${Formatters.money(cart.subtotal)}',
current: cart.billDiscount,
onApply: (d) =>
ref.read(cartControllerProvider.notifier).applyBillDiscount(d),
);
}
Future<void> _show({
required BuildContext context,
required String title,
required String subtitle,
required Discount current,
required ValueChanged<Discount> onApply,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _DiscountSheet(
title: title,
subtitle: subtitle,
current: current,
onApply: onApply,
),
);
}
class _DiscountSheet extends StatefulWidget {
const _DiscountSheet({
required this.title,
required this.subtitle,
required this.current,
required this.onApply,
});
final String title;
final String subtitle;
final Discount current;
final ValueChanged<Discount> onApply;
@override
State<_DiscountSheet> createState() => _DiscountSheetState();
}
class _DiscountSheetState extends State<_DiscountSheet> {
late DiscountType _type =
widget.current.type == DiscountType.none
? DiscountType.percentage
: widget.current.type;
late final TextEditingController _value = TextEditingController(
text: widget.current.isActive
? widget.current.value.toStringAsFixed(0)
: '',
);
@override
void dispose() {
_value.dispose();
super.dispose();
}
void _apply() {
final v = double.tryParse(_value.text.trim()) ?? 0;
widget.onApply(
v <= 0 ? Discount.none : Discount(type: _type, value: v),
);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom,
),
child: Container(
padding: const EdgeInsets.all(AppSpacing.xxl),
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius:
BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 40,
height: 4,
decoration: const BoxDecoration(
color: AppColors.border,
borderRadius: AppRadius.brPill,
),
),
const SizedBox(height: AppSpacing.xl),
Text(widget.title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),),
const SizedBox(height: 2),
Text(widget.subtitle,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),),
const SizedBox(height: AppSpacing.xxl),
SegmentedButton<DiscountType>(
segments: const [
ButtonSegment(
value: DiscountType.percentage,
label: Text('Percent'),
icon: Icon(Icons.percent_rounded, size: 17),
),
ButtonSegment(
value: DiscountType.flat,
label: Text('Flat'),
icon: Icon(Icons.currency_rupee_rounded, size: 17),
),
],
selected: {_type},
onSelectionChanged: (s) => setState(() => _type = s.first),
),
const SizedBox(height: AppSpacing.xl),
TextField(
controller: _value,
autofocus: true,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}')),
],
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700),
textAlign: TextAlign.center,
decoration: InputDecoration(
hintText: '0',
prefixText: _type == DiscountType.flat ? '' : null,
suffixText: _type == DiscountType.percentage ? '%' : null,
),
onSubmitted: (_) => _apply(),
),
const SizedBox(height: AppSpacing.lg),
Wrap(
spacing: AppSpacing.sm,
children: (_type == DiscountType.percentage
? const [5, 10, 15, 20, 25]
: const [10, 20, 50, 100, 200])
.map((v) => ActionChip(
label: Text(_type == DiscountType.percentage
? '$v%'
: '$v',),
onPressed: () =>
setState(() => _value.text = v.toString()),
),)
.toList(),
),
const SizedBox(height: AppSpacing.xxl),
Row(children: [
Expanded(
child: PrimaryButton(
label: 'Remove',
tone: ButtonTone.neutral,
onPressed: () {
widget.onApply(Discount.none);
Navigator.of(context).pop();
},
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
flex: 2,
child: PrimaryButton(
label: 'Apply discount',
icon: Icons.check_rounded,
onPressed: _apply,
),
),
],),
],),
),
);
}
}

View File

@@ -6,6 +6,9 @@ import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_layout.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/brand_mark.dart';
import '../../auth/providers/auth_controller.dart';
import '../../shift/widgets/session_end_sheet.dart';
import '../providers/cart_controller.dart';
import '../providers/navigation_provider.dart';
@@ -32,7 +35,10 @@ class PageHeader extends ConsumerWidget {
return LayoutBuilder(
builder: (context, box) {
final showStatus = box.maxWidth >= 720;
return _bar(context, ref, compact, showStatus);
// The brand block only earns its space once the bar is genuinely wide;
// below that it would push the actions off the end.
final showBrand = box.maxWidth >= 1000;
return _bar(context, ref, compact, showStatus, showBrand);
},
);
}
@@ -42,10 +48,14 @@ class PageHeader extends ConsumerWidget {
WidgetRef ref,
bool compact,
bool showStatus,
bool showBrand,
) {
final module = ref.watch(activeModuleProvider);
final now = ref.watch(clockProvider).value ?? DateTime.now();
// With no sidebar there is nothing else on screen carrying the brand, the
// sync log or the way out — so all three are promoted into this bar.
final cashierMode = ref.watch(isCashierModeProvider);
return Container(
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
padding: EdgeInsets.symmetric(
@@ -58,7 +68,7 @@ class PageHeader extends ConsumerWidget {
),
child: Row(
children: [
if (compact) ...[
if (compact && onMenuTap != null) ...[
IconButton(
onPressed: onMenuTap,
icon: const Icon(Icons.menu_rounded),
@@ -68,28 +78,12 @@ class PageHeader extends ConsumerWidget {
const SizedBox(width: AppSpacing.xs),
],
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
module.title,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 19,
fontWeight: FontWeight.w600,
letterSpacing: -0.4,
color: AppColors.textPrimary,
height: 1.2,
),
),
if (!compact) _Breadcrumb(module: module),
// Dropped first when the bar gets tight: the actions are what the
// counter actually presses.
if (cashierMode && showBrand) ...[
const _CashierBrand(),
const SizedBox(width: AppSpacing.lg),
],
),
),
const Spacer(),
if (showStatus) ...[
_LivePill(offline: ref.watch(simulateOfflineProvider)),
@@ -103,67 +97,46 @@ class PageHeader extends ConsumerWidget {
fontFeatures: [FontFeature.tabularFigures()],
),
),
const SizedBox(width: AppSpacing.lg),
Container(width: 1, height: 26, color: AppColors.border),
const SizedBox(width: AppSpacing.lg),
],
// The bar is always the same shape: status on the left, actions
// pinned to the right, for admin and cashier alike. Packed left with
// a divider between them, the actions landed in a different place on
// every screen — mid-bar on a wide admin window, hard left on a
// narrow one — and the two roles never agreed with each other.
const Spacer(),
_ParkedBillsButton(compact: compact),
const SizedBox(width: AppSpacing.sm),
_NewSaleButton(compact: compact),
// Every role signs out from here. It used to sit at the foot of the
// sidebar for admins and up here for cashiers, which meant the same
// action lived in two places depending on who was holding the till.
const SizedBox(width: AppSpacing.md),
Container(width: 1, height: 26, color: AppColors.border),
const SizedBox(width: AppSpacing.md),
const _LogoutButton(),
],
),
);
}
}
class _Breadcrumb extends StatelessWidget {
const _Breadcrumb({required this.module});
final PosModule module;
/// What the pill is saying, in the order it takes precedence.
enum _Liveness { offlineSim, halted, syncing, queued, live }
@override
Widget build(BuildContext context) {
const style = TextStyle(fontSize: 12, color: AppColors.textTertiary);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Home', style: style),
const Padding(
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs + 2),
child: Icon(Icons.chevron_right_rounded,
size: 13, color: AppColors.textTertiary,),
),
Text(module.section.label, style: style),
const Padding(
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs + 2),
child: Icon(Icons.chevron_right_rounded,
size: 13, color: AppColors.textTertiary,),
),
Text(
module.label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
],
);
}
}
class _LivePill extends StatefulWidget {
class _LivePill extends ConsumerStatefulWidget {
const _LivePill({required this.offline});
final bool offline;
@override
State<_LivePill> createState() => _LivePillState();
ConsumerState<_LivePill> createState() => _LivePillState();
}
class _LivePillState extends State<_LivePill>
class _LivePillState extends ConsumerState<_LivePill>
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
@@ -178,16 +151,62 @@ class _LivePillState extends State<_LivePill>
@override
Widget build(BuildContext context) {
final offline = widget.offline;
final tone = offline ? AppColors.warning : AppColors.success;
final surface =
offline ? AppColors.warningSurface : AppColors.successSurface;
// The engine may not have emitted yet on a cold start, so fall back to its
// current value rather than showing nothing.
final sync = ref.watch(syncEngineStateProvider).value ??
ref.watch(syncEngineProvider).state;
final liveness = switch (0) {
_ when widget.offline => _Liveness.offlineSim,
_ when sync.isHalted => _Liveness.halted,
_ when sync.isSyncing => _Liveness.syncing,
// Bills waiting is normal for a few seconds after a sale; it is only
// worth flagging once they are visibly piling up.
_ when sync.pending > 0 => _Liveness.queued,
_ => _Liveness.live,
};
final (label, tone, surface, message) = switch (liveness) {
_Liveness.offlineSim => (
'OFFLINE (SIM)',
AppColors.warning,
AppColors.warningSurface,
'Simulate offline is ON in Settings — imports and syncs are being '
'failed deliberately.',
),
_Liveness.halted => (
'SYNC HALTED',
AppColors.danger,
AppColors.dangerSurface,
'Uploading stopped because retrying will not help: '
'${sync.lastError ?? 'the back office refused the batch'}. '
'Every bill is still safe on this terminal. Press Sync to try '
'again once it is sorted.',
),
_Liveness.syncing => (
'SYNCING',
AppColors.primary,
AppColors.primarySurface,
'Uploading bills to the back office.',
),
_Liveness.queued => (
'${sync.pending} QUEUED',
AppColors.warning,
AppColors.warningSurface,
'${sync.pending} bill(s) are stored on this terminal and waiting to '
'upload. They are safe; nothing is lost while the line is down.',
),
_Liveness.live => (
'LIVE',
AppColors.success,
AppColors.successSurface,
'Terminal is operating normally and everything rung has been '
'uploaded.',
),
};
return Tooltip(
message: offline
? 'Simulate offline is ON in Settings — imports and syncs are being '
'failed deliberately.'
: 'Terminal is operating normally.',
message: message,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
@@ -210,7 +229,7 @@ class _LivePillState extends State<_LivePill>
),
const SizedBox(width: AppSpacing.xs + 2),
Text(
offline ? 'OFFLINE (SIM)' : 'LIVE',
label,
style: TextStyle(
color: tone,
fontSize: 10.5,
@@ -293,11 +312,24 @@ class _ParkedBillsButton extends ConsumerWidget {
'${Formatters.time(bill.parkedAt)}',
),
onTap: () async {
final hadItems =
ref.read(cartControllerProvider).isNotEmpty;
await ref
.read(cartControllerProvider.notifier)
.resume(bill);
ref.invalidate(parkedBillsProvider);
if (context.mounted) Navigator.of(context).pop();
if (!context.mounted) return;
Navigator.of(context).pop();
if (hadItems) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(const SnackBar(
content: Text(
'The cart you were on was saved back to Parked '
'bills.',
),
),);
}
},
);
},
@@ -321,12 +353,22 @@ class _NewSaleButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
void start() {
ref.read(cartControllerProvider.notifier).reset();
Future<void> start() async {
final hadItems = ref.read(cartControllerProvider).isNotEmpty;
await ref.read(cartControllerProvider.notifier).startNewSale();
if (hadItems) ref.invalidate(parkedBillsProvider);
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
if (!context.mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(const SnackBar(content: Text('Started a new sale.')));
..showSnackBar(SnackBar(
content: Text(
hadItems
? 'Previous cart saved to Parked bills. Started a new sale.'
: 'Started a new sale.',
),
),);
}
if (compact) {
@@ -351,3 +393,77 @@ class _NewSaleButton extends ConsumerWidget {
);
}
}
/// Brand and outlet name, shown only in cashier mode.
///
/// The sidebar normally carries these; without it the bar reads as a fragment
/// of an app rather than the top of one.
class _CashierBrand extends ConsumerWidget {
const _CashierBrand();
@override
Widget build(BuildContext context, WidgetRef ref) {
final store = ref.watch(currentStoreProvider);
final user = ref.watch(currentUserProvider);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const BrandMark(size: 32),
const SizedBox(width: AppSpacing.sm),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 170),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
store?.name ?? 'Nearle POS',
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: AppColors.textPrimary,
height: 1.15,
),
),
Text(
user == null ? 'Cashier' : '${user.name} · Cashier',
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
height: 1.25,
),
),
],
),
),
],
);
}
}
/// Signing out, for both roles.
///
/// Opens the session-end chooser rather than signing out directly: a cashier
/// stepping away for ten minutes and a cashier finishing for the day want two
/// very different things to happen to the drawer and the catalogue.
class _LogoutButton extends ConsumerWidget {
const _LogoutButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
return IconButton(
tooltip: 'Sign out',
onPressed: () => showSessionEndSheet(context, ref),
style: IconButton.styleFrom(
foregroundColor: AppColors.danger,
backgroundColor: AppColors.dangerSurface,
),
icon: const Icon(Icons.logout_rounded),
);
}
}

View File

@@ -84,9 +84,10 @@ class _ProductCardState extends State<ProductCard> {
children: [
Opacity(
opacity: disabled ? 0.4 : 1,
child: Text(
p.emoji,
style: TextStyle(fontSize: emoji),
child: _ProductVisual(
imageUrl: p.imageUrl,
emoji: p.emoji,
size: emoji,
),
),
SizedBox(height: tight ? 2 : AppSpacing.sm),
@@ -252,3 +253,62 @@ class _ProductCardState extends State<ProductCard> {
);
}
}
/// Shows the catalogue's product photo when the imported record has one,
/// otherwise falls back to the emoji.
///
/// Today's imports don't carry `image_url` yet, so the emoji path is still
/// the common case — this quietly takes over per product once the back
/// office starts sending photos, with nothing else on the card changing.
class _ProductVisual extends StatelessWidget {
const _ProductVisual({
required this.imageUrl,
required this.emoji,
required this.size,
});
final String? imageUrl;
final String emoji;
/// Matches the emoji font size the caller computed for this tile, so the
/// two are visually interchangeable.
final double size;
@override
Widget build(BuildContext context) {
final url = imageUrl;
if (url == null || url.isEmpty) {
return Text(emoji, style: TextStyle(fontSize: size));
}
final box = size * 1.7;
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
url,
width: box,
height: box,
fit: BoxFit.cover,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return SizedBox(
width: box,
height: box,
child: const Center(
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
},
// A missing or unreachable photo falls back to the emoji rather than
// Flutter's default broken-image icon, so one bad URL in a catalogue
// of thousands never leaves a tile looking broken.
errorBuilder: (context, error, stackTrace) =>
Text(emoji, style: TextStyle(fontSize: size)),
),
);
}
}

View File

@@ -15,13 +15,22 @@ import '../../../core/utils/extensions.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/glass_card.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../data/sync/sync_engine.dart';
import '../../../domain/entities/transaction.dart';
import '../../modules/providers/printer_settings.dart';
import '../../pos/providers/cart_controller.dart';
import '../../pos/providers/catalog_providers.dart';
import '../../sync/providers/sync_controller.dart';
import '../widgets/receipt_preview.dart';
/// Confirmation screen. Counts down and starts the next sale on its own so an
/// unattended terminal never sits on a finished bill.
/// Confirmation screen.
///
/// The bill is written to SQLite the instant checkout completes, but is
/// deliberately held back from the server for [AppConstants.postSaleResetDelay]
/// — long enough to catch a mistake. Counts down and starts the next sale (and
/// releases the bill to the sync engine) on its own so an unattended terminal
/// never sits on a finished bill forever; "Cancel sale" inside the window
/// voids it instead and hands the cart straight back.
class ReceiptScreen extends ConsumerStatefulWidget {
const ReceiptScreen({super.key, required this.transaction});
@@ -32,9 +41,13 @@ class ReceiptScreen extends ConsumerStatefulWidget {
}
class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
late int _seconds = AppConstants.postSaleResetDelay.inSeconds + 5;
late int _seconds = AppConstants.postSaleResetDelay.inSeconds;
Timer? _timer;
/// True while a cancellation is being written to disk — guards against a
/// second tap voiding a sale that is already half-reversed.
bool _voiding = false;
@override
void initState() {
super.initState();
@@ -82,13 +95,36 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
void _newSale() {
_timer?.cancel();
// This is the one moment the bill is actually released to the server —
// whether the window ran out on its own or New Sale was pressed early,
// the cashier has let this bill stand.
ref.read(syncEngineProvider).nudge(SyncTrigger.saleCommitted);
ref.read(cartControllerProvider.notifier).reset();
if (mounted) context.go(AppRoutes.pos);
}
void _continueBilling() {
/// Undoes the sale: deletes the order, puts the stock back, restores an
/// attached shopper's loyalty balance, and hands the exact same cart back
/// to the billing screen. Never touches the sync engine — this bill must
/// never reach the server.
Future<void> _cancelSale() async {
if (_voiding) return;
setState(() => _voiding = true);
_timer?.cancel();
ref.read(cartControllerProvider.notifier).reset();
final txn = widget.transaction;
await ref.read(transactionRepositoryProvider).voidSale(
transaction: txn,
stockMovements: {
for (final line in txn.cart.lines) line.product.id: line.quantity,
},
);
ref.invalidate(allProductsProvider);
ref.invalidate(visibleProductsProvider);
ref.read(orderVersionProvider.notifier).state++;
ref.read(cartControllerProvider.notifier).restore(txn.cart);
if (mounted) context.go(AppRoutes.pos);
}
@@ -249,7 +285,7 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
final sent = await ref
.read(receiptServiceProvider)
.sendToWhatsApp(txn);
if (!context.mounted || sent) return;
if (!mounted || sent) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(const SnackBar(
@@ -293,12 +329,23 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
: 'New Sale',
icon: Icons.add_shopping_cart_rounded,
large: true,
onPressed: _newSale,
onPressed: _voiding ? null : _newSale,
),
const SizedBox(height: AppSpacing.sm),
TextButton(
onPressed: _continueBilling,
child: const Text('Back to billing screen'),
TextButton.icon(
onPressed: _voiding ? null : _cancelSale,
icon: _voiding
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.danger,
),
)
: const Icon(Icons.undo_rounded, size: 16),
label: Text(_voiding ? 'Cancelling…' : 'Cancel sale'),
style: TextButton.styleFrom(foregroundColor: AppColors.danger),
),
],
),

View File

@@ -0,0 +1,821 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/shift_report.dart';
import '../../../domain/entities/transaction.dart';
import '../../../domain/repositories/sync_repository.dart';
import '../../auth/providers/auth_controller.dart';
import '../../payment/screens/payment_screen.dart' show methodIcon;
import '../../pos/providers/cart_controller.dart';
import '../../pos/providers/catalog_providers.dart';
import '../../pos/providers/navigation_provider.dart';
import '../../sync/providers/sync_controller.dart';
/// Closing the till.
///
/// Three things have to happen at the end of a shift and they have to happen
/// in this order: count what is physically in the drawer, compare it against
/// what the terminal says was taken in cash, then push the day up and hand the
/// terminal back. Doing it as a dialog meant the count was a single guessed
/// number typed into a box; a shift is worth its own screen.
///
/// The variance is the whole point. A till that is short is worth knowing
/// about while the person who worked it is still standing there.
class EndShiftScreen extends ConsumerStatefulWidget {
const EndShiftScreen({super.key});
/// Below this the two columns stack.
static const double twoColumnAbove = 1000;
@override
ConsumerState<EndShiftScreen> createState() => _EndShiftScreenState();
}
/// What a till drawer actually holds, largest first.
const _denominations = <int>[2000, 500, 200, 100, 50, 20, 10, 5, 2, 1];
class _EndShiftScreenState extends ConsumerState<EndShiftScreen> {
/// Note or coin value → how many were counted.
final Map<int, int> _counted = {};
final _openingFloat = TextEditingController(text: '0');
bool _pushing = false;
String? _error;
@override
void dispose() {
_openingFloat.dispose();
super.dispose();
}
double get _countedTotal => _counted.entries
.fold(0.0, (sum, e) => sum + e.key * e.value);
double get _float => double.tryParse(_openingFloat.text.trim()) ?? 0;
int get _noteCount => _counted.values.fold(0, (sum, n) => sum + n);
void _set(int denomination, int count) {
setState(() {
if (count <= 0) {
_counted.remove(denomination);
} else {
_counted[denomination] = count;
}
});
}
/// Cash the terminal believes was taken, from the tender records — not from
/// the bill totals, which include card and UPI.
double _cashTaken(ShiftReport? report) =>
report?.paymentBreakdown[PaymentMethod.cash] ?? 0;
/// Pushes what is still held, then ends the session and clears the terminal.
Future<void> _finish({required bool sync}) async {
setState(() {
_pushing = true;
_error = null;
});
if (sync) {
final outcome = await ref.read(orderSyncProvider.notifier).run();
if (!mounted) return;
if (!outcome.isSuccess) {
setState(() {
_pushing = false;
_error = outcome.error ??
'Upload failed. Every bill is still stored on this terminal.';
});
return;
}
}
ref.read(cartControllerProvider.notifier).reset();
// The real end of shift: the catalogue goes with it, so the next person
// bills against a fresh import rather than this morning's prices.
await ref.read(authControllerProvider.notifier).signOut();
ref.read(catalogueVersionProvider.notifier).state++;
ref.invalidate(allProductsProvider);
ref.invalidate(visibleProductsProvider);
ref.invalidate(categoryCountsProvider);
ref.invalidate(lowStockProductsProvider);
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
ref.read(searchQueryProvider.notifier).state = '';
ref.read(selectedCategoryProvider.notifier).state = null;
if (!mounted) return;
context.go(AppRoutes.login);
}
@override
Widget build(BuildContext context) {
final report = ref.watch(myShiftReportProvider).value;
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
final user = ref.watch(currentUserProvider);
final cashTaken = _cashTaken(report);
final expected = _float + cashTaken;
final variance = _countedTotal - expected;
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
title: const Text('End shift'),
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: _pushing ? null : () => context.pop(),
tooltip: 'Back to the till',
),
),
body: LayoutBuilder(
builder: (context, box) {
final twoColumn = box.maxWidth >= EndShiftScreen.twoColumnAbove;
final pad = box.maxWidth < 700 ? AppSpacing.lg : AppSpacing.xxl;
final count = _CountPanel(
counted: _counted,
openingFloat: _openingFloat,
enabled: !_pushing,
total: _countedTotal,
noteCount: _noteCount,
onChanged: _set,
onFloatChanged: () => setState(() {}),
);
final review = _ReviewPanel(
report: report,
user: user?.name,
openingFloat: _float,
cashTaken: cashTaken,
expected: expected,
counted: _countedTotal,
variance: variance,
pending: pending,
error: _error,
);
final minHeight = box.maxHeight.isFinite
? (box.maxHeight - pad * 2).clamp(0.0, double.infinity)
: 0.0;
return SingleChildScrollView(
padding: EdgeInsets.all(pad),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: minHeight),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1240),
child: twoColumn
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: count),
const SizedBox(width: AppSpacing.lg),
Expanded(child: review),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
count,
const SizedBox(height: AppSpacing.lg),
review,
],
),
),
),
),
);
},
),
bottomNavigationBar: _bottomBar(pending, expected, variance),
);
}
/// Whether the drawer has been counted and agrees with what was rung.
///
/// Signing out is gated on this. A shift that ends with the cash unaccounted
/// for is a shift nobody can settle afterwards — the person who worked it has
/// gone home, and the difference becomes an argument rather than a number. To
/// the rupee, because that is the smallest note anyone hands over.
bool _balances(double expected) => (_countedTotal - expected).abs() < 0.5;
Widget _bottomBar(int pending, double expected, double variance) {
final counted = _noteCount > 0;
final short = variance < -0.5;
final over = variance > 0.5;
final balanced = _balances(expected);
final (icon, tone, message) = switch (0) {
_ when !counted => (
Icons.info_outline_rounded,
AppColors.textTertiary,
'Count the drawer to finish. Signing out needs the count to match '
'${Formatters.money(expected)}.',
),
_ when short => (
Icons.error_outline_rounded,
AppColors.danger,
'The drawer is ${Formatters.money(variance.abs())} short. Recount, '
'or find the difference before signing out.',
),
_ when over => (
Icons.error_outline_rounded,
AppColors.warning,
'The drawer is ${Formatters.money(variance)} over. Recount, or find '
'the difference before signing out.',
),
_ => (
Icons.check_circle_outline_rounded,
AppColors.success,
'The drawer matches what was rung. You can sign out.',
),
};
return SafeArea(
child: Container(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xxl,
AppSpacing.md,
AppSpacing.xxl,
AppSpacing.lg,
),
decoration: const BoxDecoration(
color: AppColors.surface,
border: Border(top: BorderSide(color: AppColors.border)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(icon, size: 16, color: tone),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
message,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
height: 1.4,
),
),
),
],
),
const SizedBox(height: AppSpacing.sm),
PrimaryButton(
label: pending > 0
? 'Upload $pending bill(s) & end shift'
: 'End shift',
icon: Icons.logout_rounded,
large: true,
busy: _pushing,
// Disabled until the count agrees. There is deliberately no way
// past this on the screen: an override that a tired cashier can
// press at the end of a long day is not a control.
onPressed: (_pushing || !balanced)
? null
: () => _finish(sync: pending > 0),
),
if (pending > 0 && balanced) ...[
const SizedBox(height: AppSpacing.xs),
TextButton(
onPressed: _pushing ? null : () => _finish(sync: false),
style: TextButton.styleFrom(
foregroundColor: AppColors.textSecondary,
),
child: const Text('End shift without uploading'),
),
],
],
),
),
);
}
}
// ------------------------------------------------------------------- Count
class _CountPanel extends StatelessWidget {
const _CountPanel({
required this.counted,
required this.openingFloat,
required this.enabled,
required this.total,
required this.noteCount,
required this.onChanged,
required this.onFloatChanged,
});
final Map<int, int> counted;
final TextEditingController openingFloat;
final bool enabled;
final double total;
final int noteCount;
final void Function(int denomination, int count) onChanged;
final VoidCallback onFloatChanged;
@override
Widget build(BuildContext context) {
return _Panel(
title: 'Count the drawer',
subtitle: 'Tap the notes and coins you are holding. Nothing is '
'submitted until you end the shift.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: openingFloat,
enabled: enabled,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (_) => onFloatChanged(),
decoration: const InputDecoration(
labelText: 'Opening float',
helperText: 'What was in the drawer before trading started',
prefixText: '',
isDense: true,
),
),
const SizedBox(height: AppSpacing.lg),
const Divider(height: 1),
const SizedBox(height: AppSpacing.sm),
for (final value in _denominations)
_DenominationRow(
value: value,
count: counted[value] ?? 0,
enabled: enabled,
onChanged: (n) => onChanged(value, n),
),
const SizedBox(height: AppSpacing.sm),
const Divider(height: 1),
const SizedBox(height: AppSpacing.md),
Row(
children: [
Expanded(
child: Text(
noteCount == 0
? 'Counted so far'
: 'Counted so far · $noteCount piece(s)',
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
),
Text(
Formatters.money(total),
style: AppTypography.money(20, color: AppColors.textPrimary),
),
],
),
],
),
);
}
}
class _DenominationRow extends StatelessWidget {
const _DenominationRow({
required this.value,
required this.count,
required this.enabled,
required this.onChanged,
});
final int value;
final int count;
final bool enabled;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
final subtotal = value * count;
final active = count > 0;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
SizedBox(
width: 74,
child: Text(
'$value',
style: AppTypography.money(
15,
color: active ? AppColors.textPrimary : AppColors.textTertiary,
),
),
),
const Text(
'×',
style: TextStyle(fontSize: 12, color: AppColors.textTertiary),
),
const SizedBox(width: AppSpacing.sm),
_Stepper(
count: count,
enabled: enabled,
onChanged: onChanged,
),
const Spacer(),
Text(
active ? Formatters.money(subtotal.toDouble()) : '',
style: AppTypography.money(
14,
color: active ? AppColors.textPrimary : AppColors.textTertiary,
),
),
],
),
);
}
}
class _Stepper extends StatelessWidget {
const _Stepper({
required this.count,
required this.enabled,
required this.onChanged,
});
final int count;
final bool enabled;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: AppRadius.brSm,
border: Border.all(color: AppColors.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_btn(
Icons.remove_rounded,
enabled && count > 0 ? () => onChanged(count - 1) : null,
),
Container(
constraints: const BoxConstraints(minWidth: 38),
alignment: Alignment.center,
child: Text('$count', style: AppTypography.money(14.5)),
),
_btn(
Icons.add_rounded,
enabled ? () => onChanged(count + 1) : null,
),
],
),
);
}
Widget _btn(IconData icon, VoidCallback? onTap) => Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: AppRadius.brSm,
child: SizedBox(
width: 32,
height: 32,
child: Icon(
icon,
size: 16,
color: onTap == null
? AppColors.textTertiary.withValues(alpha: 0.5)
: AppColors.primary,
),
),
),
);
}
// ------------------------------------------------------------------ Review
class _ReviewPanel extends StatelessWidget {
const _ReviewPanel({
required this.report,
required this.user,
required this.openingFloat,
required this.cashTaken,
required this.expected,
required this.counted,
required this.variance,
required this.pending,
required this.error,
});
final ShiftReport? report;
final String? user;
final double openingFloat;
final double cashTaken;
final double expected;
final double counted;
final double variance;
final int pending;
final String? error;
@override
Widget build(BuildContext context) {
final short = variance < -0.5;
final over = variance > 0.5;
final tone = counted == 0
? AppColors.textTertiary
: (short ? AppColors.danger : (over ? AppColors.warning
: AppColors.success));
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_Panel(
title: 'Cash drawer',
subtitle: 'What the terminal expects, against what you counted.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_row('Opening float', Formatters.money(openingFloat)),
_row('Cash sales today', Formatters.money(cashTaken)),
const Divider(height: AppSpacing.xl),
_row(
'Expected in drawer',
Formatters.money(expected),
strong: true,
),
_row('You counted', Formatters.money(counted)),
const SizedBox(height: AppSpacing.md),
Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: counted == 0
? AppColors.surfaceAlt
: (short
? AppColors.dangerSurface
: (over
? AppColors.warningSurface
: AppColors.successSurface)),
borderRadius: AppRadius.brMd,
),
child: Row(
children: [
Expanded(
child: Text(
counted == 0
? 'Not counted yet'
: (short
? 'Short'
: (over ? 'Over' : 'Balanced')),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: tone,
),
),
),
Text(
counted == 0
? ''
: '${variance >= 0 ? '+' : ''}'
'${Formatters.money(variance.abs())}',
style: AppTypography.money(20, color: tone),
),
],
),
),
],
),
),
const SizedBox(height: AppSpacing.lg),
_Panel(
title: 'Today at this till',
subtitle: user == null ? null : 'Rung by $user',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_row('Bills', '${report?.billCount ?? 0}'),
_row('Items sold',
(report?.itemCount ?? 0).toStringAsFixed(0),),
_row('Gross sales',
Formatters.money(report?.grossSales ?? 0),),
_row('GST collected',
Formatters.money(report?.taxCollected ?? 0),),
if ((report?.paymentBreakdown ?? const {}).isNotEmpty) ...[
const Divider(height: AppSpacing.xl),
for (final e in report!.paymentBreakdown.entries)
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Icon(methodIcon(e.key),
size: 16, color: AppColors.textSecondary,),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
e.key.label,
style: const TextStyle(
fontSize: 13.5,
color: AppColors.textSecondary,
),
),
),
Text(Formatters.money(e.value),
style: AppTypography.money(13.5),),
],
),
),
],
],
),
),
const SizedBox(height: AppSpacing.lg),
_Panel(
title: 'Before you hand it over',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_checkRow(
pending == 0,
pending == 0
? 'Every bill has been uploaded.'
: '$pending bill(s) still on this terminal — they upload '
'when you end the shift.',
),
_checkRow(
counted > 0,
counted > 0
? 'Drawer counted.'
: 'Drawer not counted yet.',
),
_checkRow(
false,
'Products are removed from this terminal at the end of a '
'shift. An admin imports them again tomorrow.',
neutral: true,
),
if (error != null) ...[
const SizedBox(height: AppSpacing.md),
Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: const BoxDecoration(
color: AppColors.dangerSurface,
borderRadius: AppRadius.brSm,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.wifi_off_rounded,
size: 18, color: AppColors.danger,),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
error!,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.danger,
height: 1.45,
),
),
),
],
),
),
],
],
),
),
],
);
}
Widget _row(String label, String value, {bool strong = false}) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: strong ? 14 : 13.5,
fontWeight: strong ? FontWeight.w600 : FontWeight.w400,
color: strong
? AppColors.textPrimary
: AppColors.textSecondary,
),
),
),
Text(
value,
style: AppTypography.money(
strong ? 16 : 13.5,
color: strong ? AppColors.primary : null,
),
),
],
),
);
Widget _checkRow(bool done, String text, {bool neutral = false}) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
neutral
? Icons.info_outline_rounded
: (done
? Icons.check_circle_outline_rounded
: Icons.radio_button_unchecked_rounded),
size: 16,
color: neutral
? AppColors.textTertiary
: (done ? AppColors.success : AppColors.textTertiary),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
text,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
height: 1.5,
),
),
),
],
),
);
}
/// One card shape for every block on this screen, so the two columns line up
/// row for row instead of each panel inventing its own padding.
class _Panel extends StatelessWidget {
const _Panel({
required this.title,
required this.child,
this.subtitle,
});
final String title;
final String? subtitle;
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppSpacing.xl),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: AppRadius.brXl,
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
color: AppColors.textPrimary,
),
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
height: 1.45,
),
),
],
const SizedBox(height: AppSpacing.lg),
child,
],
),
);
}
}

View File

@@ -0,0 +1,235 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../auth/providers/auth_controller.dart';
import '../../pos/providers/cart_controller.dart';
import '../../pos/providers/catalog_providers.dart';
import '../../pos/providers/navigation_provider.dart';
import '../../sync/providers/sync_controller.dart';
import '../../sync/widgets/sign_out_dialog.dart';
/// Asks what "signing out" means before doing it.
///
/// Both cashier paths clear the products — the till never keeps a catalogue
/// across a sign-out, whatever the reason. What they differ on is the drawer:
/// a temporary logout locks the screen and leaves the money alone, while
/// ending the shift counts it and reconciles it against what was rung.
/// Treating both as one button meant either the drawer was never settled, or a
/// cashier stepping away for ten minutes had to count it first.
///
/// Admins see the plain sign-out dialog: they have no drawer to settle, and
/// their sign-out deliberately leaves the catalogue in place for whoever picks
/// the terminal up.
Future<void> showSessionEndSheet(BuildContext context, WidgetRef ref) async {
if (!ref.read(isCashierModeProvider)) {
return showSignOutDialog(context, ref);
}
return showDialog<void>(
context: context,
barrierDismissible: true,
builder: (_) => const _SessionEndDialog(),
);
}
class _SessionEndDialog extends ConsumerWidget {
const _SessionEndDialog();
/// Locks the screen and returns to the login screen.
///
/// Explicitly *not* a shift end — the drawer is left alone, unsynced bills
/// stay queued, and today's totals keep accumulating against the same day.
/// The catalogue still goes: a terminal sitting unattended at a login screen
/// must not be holding a shop's prices and stock, and an admin re-imports in
/// seconds.
Future<void> _temporaryLogout(BuildContext context, WidgetRef ref) async {
ref.read(cartControllerProvider.notifier).reset();
await ref.read(authControllerProvider.notifier).signOut();
ref.read(catalogueVersionProvider.notifier).state++;
ref.invalidate(allProductsProvider);
ref.invalidate(visibleProductsProvider);
ref.invalidate(categoryCountsProvider);
ref.invalidate(lowStockProductsProvider);
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
ref.read(searchQueryProvider.notifier).state = '';
ref.read(selectedCategoryProvider.notifier).state = null;
if (!context.mounted) return;
Navigator.of(context).pop();
context.go(AppRoutes.login);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
final cart = ref.watch(cartControllerProvider);
return AlertDialog(
title: const Text('Leaving the till'),
contentPadding: const EdgeInsets.fromLTRB(
AppSpacing.xxl,
AppSpacing.lg,
AppSpacing.xxl,
AppSpacing.sm,
),
content: SizedBox(
width: (MediaQuery.sizeOf(context).width - 96).clamp(280.0, 460.0),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (cart.isNotEmpty)
Container(
margin: const EdgeInsets.only(bottom: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: const BoxDecoration(
color: AppColors.warningSurface,
borderRadius: AppRadius.brSm,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.warning_amber_rounded,
size: 18, color: AppColors.warning,),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
'The current bill has ${cart.lineCount} item(s), and '
'the imported products are cleared either way.',
style: const TextStyle(
fontSize: 12.5,
color: AppColors.warning,
height: 1.45,
),
),
),
],
),
),
_Choice(
icon: Icons.lock_outline_rounded,
tone: AppColors.info,
title: 'Temporary logout',
body: 'Locks the screen. The drawer is left as it is and the '
'day keeps running — an admin re-imports the products when '
'you come back.',
onTap: () => _temporaryLogout(context, ref),
),
const SizedBox(height: AppSpacing.md),
_Choice(
icon: Icons.point_of_sale_rounded,
tone: AppColors.primary,
title: 'End shift',
body: pending == 0
? 'Count the drawer. It has to match what was rung before '
'the till can be handed over.'
: 'Count the drawer and upload the $pending bill(s) still '
'held here. The count has to match before you can sign '
'out.',
onTap: () {
Navigator.of(context).pop();
context.push(AppRoutes.endShift);
},
emphasised: true,
),
],
),
),
),
actionsPadding: const EdgeInsets.fromLTRB(
AppSpacing.xxl,
0,
AppSpacing.xxl,
AppSpacing.lg,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Stay signed in'),
),
],
);
}
}
class _Choice extends StatelessWidget {
const _Choice({
required this.icon,
required this.tone,
required this.title,
required this.body,
required this.onTap,
this.emphasised = false,
});
final IconData icon;
final Color tone;
final String title;
final String body;
final VoidCallback onTap;
final bool emphasised;
@override
Widget build(BuildContext context) {
return Material(
color: emphasised ? AppColors.primarySurface : AppColors.surfaceAlt,
borderRadius: AppRadius.brLg,
child: InkWell(
onTap: onTap,
borderRadius: AppRadius.brLg,
child: Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
borderRadius: AppRadius.brLg,
border: Border.all(
color: emphasised ? AppColors.primaryBorder : AppColors.border,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 22, color: tone),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
body,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
height: 1.5,
),
),
],
),
),
const SizedBox(width: AppSpacing.sm),
const Icon(Icons.chevron_right_rounded,
size: 20, color: AppColors.textTertiary,),
],
),
),
),
);
}
}

View File

@@ -1,6 +1,11 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/constants/app_constants.dart';
import '../../../data/remote/mqtt_order_transport.dart';
import '../../../data/sync/health_reporter.dart';
import '../../../data/sync/presence_reporter.dart';
import '../../modules/providers/printer_settings.dart';
import '../../../domain/entities/shift_report.dart';
import '../../../domain/entities/sync_event.dart';
import '../../../domain/repositories/sync_repository.dart';
@@ -167,6 +172,12 @@ class OrderSyncController extends StateNotifier<OrderSyncState> {
bool get isRunning => state is SyncRunning;
/// Uploads every bill at sync_status = 0 and flips the accepted ones to 1.
///
/// Goes through the engine rather than straight to the repository, so a
/// cashier pressing sync while a background drain is already mid-flight
/// joins it instead of starting a second pass over the same rows. It also
/// clears a halt: pressing the button is how you retry after the back office
/// has been fixed.
Future<SyncOutcome> run() async {
if (isRunning) {
return const SyncOutcome(attempted: 0, uploaded: 0);
@@ -174,7 +185,7 @@ class OrderSyncController extends StateNotifier<OrderSyncState> {
state = const SyncRunning(0, 'Starting…');
final outcome = await _ref.read(syncRepositoryProvider).syncOrders(
final outcome = await _ref.read(syncEngineProvider).syncNow(
onProgress: (progress, stage) {
if (mounted) state = SyncRunning(progress, stage);
},
@@ -192,3 +203,88 @@ final orderSyncProvider =
StateNotifierProvider<OrderSyncController, OrderSyncState>(
(ref) => OrderSyncController(ref),
);
// ------------------------------------------------------- Background drain
/// Brings the queue-and-drain machinery up, once, when the shell mounts.
///
/// Deliberately not gated on sign-in: a terminal that boots holding yesterday's
/// bills should be emptying its queue before anyone reaches the till.
///
/// Overridden to a no-op in widget tests, which have no network stack and
/// cannot drive real disk I/O on a fake clock.
final syncBootstrapProvider = FutureProvider<void>((ref) async {
// Restore the route this terminal was pointed at. Without this the settings
// are written on Save and then silently ignored on the next launch, which
// reads exactly like they never saved.
final store = ref.read(localStoreProvider);
if (store.isReady) {
ref.read(syncConfigProvider.notifier).state =
await store.syncConfig.load(ref.read(syncConfigProvider));
}
await ref.read(connectivityServiceProvider).start();
final engine = ref.read(syncEngineProvider);
// A background drain moves bills out of the pending set, so the tallies and
// shift totals on screen are stale the moment one finishes.
var wasSyncing = false;
final subscription = engine.states.listen((state) {
if (wasSyncing && !state.isSyncing) {
ref.read(orderVersionProvider.notifier).state++;
}
wasSyncing = state.isSyncing;
});
ref.onDispose(subscription.cancel);
await engine.start();
// The retained presence record needs a Last Will to pair with, so it genuinely
// only exists on the broker. The heartbeat below does not, and is started for
// every route.
final transport = ref.read(orderTransportProvider);
if (transport is MqttOrderTransport) {
final reporter = PresenceReporter(
transport: transport,
terminal: ref.read(terminalIdentityProvider),
config: ref.read(syncConfigProvider),
engine: engine,
appVersion: AppConstants.appVersion,
catalogueRevision: () async =>
ref.read(syncRepositoryProvider).catalogueRevision,
);
ref.onDispose(reporter.dispose);
await reporter.start();
}
// The 30-second heartbeat the head-office board reads. Separate from the
// retained presence record above: that one is paired with the Last Will and
// answers "is this till alive", while this carries queue depth, today's
// trading and hardware state — what tells a till that is merely quiet from
// one that has stopped uploading.
//
// Outside the MQTT check on purpose. It used to be inside, which meant a shop
// on the HTTP route uploaded every bill correctly and never appeared on the
// board at all — with nothing logged, because nothing had failed. Both routes
// can carry a heartbeat now, and the transport decides how.
{
final health = HealthReporter(
transport: transport,
terminal: ref.read(terminalIdentityProvider),
config: ref.read(syncConfigProvider),
engine: engine,
repository: ref.read(syncRepositoryProvider),
appVersion: AppConstants.appVersion,
// Read on each beat, so re-pointing the printer in Settings takes effect
// without a restart.
printerEndpoint: () {
final printer = ref.read(printerSettingsProvider);
final host = printer.drawerHost;
if (host == null || host.isEmpty) return null;
return (host: host, port: printer.drawerPort);
},
);
ref.onDispose(health.dispose);
await health.start();
}
});

View File

@@ -9,6 +9,8 @@ import '../../../core/utils/formatters.dart';
import '../../../core/widgets/primary_button.dart';
import '../../auth/providers/auth_controller.dart';
import '../../pos/providers/cart_controller.dart';
import '../../pos/providers/catalog_providers.dart';
import '../../pos/providers/navigation_provider.dart';
import '../../../domain/repositories/sync_repository.dart';
import '../providers/sync_controller.dart';
@@ -35,11 +37,54 @@ class _SignOutDialog extends ConsumerStatefulWidget {
class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
SyncOutcome? _result;
void _finish() {
Future<void> _finish() async {
ref.read(cartControllerProvider.notifier).reset();
ref.read(authControllerProvider.notifier).signOut();
// Read before signing out — the session that decides this is gone by the
// time signOut returns.
final cleared =
ref.read(authControllerProvider.notifier).clearsCatalogueOnSignOut;
await ref.read(authControllerProvider.notifier).signOut();
// Mirrors what a successful import does on the way in: bump the version so
// catalogueReadyProvider re-reads hasCatalogue, and drop the cached product
// lists so the next session's grid doesn't flash this session's data before
// it re-fetches.
//
// Run either way. After an admin sign-out the catalogue is still there and
// these simply re-read it — which is the point: the next session must see
// what is on disk now, not what this one had in memory.
ref.read(catalogueVersionProvider.notifier).state++;
ref.invalidate(allProductsProvider);
ref.invalidate(visibleProductsProvider);
ref.invalidate(categoryCountsProvider);
ref.invalidate(lowStockProductsProvider);
// The next session starts on the till, never wherever this one left off.
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
ref.read(searchQueryProvider.notifier).state = '';
ref.read(selectedCategoryProvider.notifier).state = null;
if (!mounted) return;
// Resolved while this context is still mounted. The messenger itself lives
// above the router, so the bar survives the route change below.
final messenger = ScaffoldMessenger.of(context);
Navigator.of(context).pop();
context.go(AppRoutes.login);
if (cleared) {
messenger
..hideCurrentSnackBar()
..showSnackBar(const SnackBar(
content: Text(
'Signed out. The product catalogue has been removed from this '
'terminal.',
),
),);
}
}
Future<void> _pushThenFinish() async {
@@ -63,8 +108,10 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
final pushing = ref.watch(orderSyncProvider) is SyncRunning;
final failed = _result != null && !_result!.isSuccess;
final cashier = ref.watch(isCashierModeProvider);
return AlertDialog(
title: const Text('End shift'),
title: Text(cashier ? 'End shift' : 'Sign out'),
contentPadding: const EdgeInsets.fromLTRB(
AppSpacing.xxl,
AppSpacing.lg,
@@ -79,6 +126,26 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// What happens to the products is the difference between the two
// sign-outs, so it is said plainly rather than left to be
// discovered at the next login.
_Banner(
icon: cashier
? Icons.delete_sweep_outlined
: Icons.inventory_2_outlined,
color: cashier ? AppColors.warning : AppColors.info,
background: cashier
? AppColors.warningSurface
: AppColors.infoSurface,
message: cashier
? 'The product catalogue will be removed from this '
'terminal. An admin imports it again for the next '
'shift.'
: 'The imported products stay on this terminal, so a '
'cashier can sign in and start billing without a '
'connection.',
),
if (cart.isNotEmpty)
_Banner(
icon: Icons.warning_amber_rounded,

View File

@@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <printing/printing_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
@@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) printing_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin");
printing_plugin_register_with_registrar(printing_registrar);

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
flutter_secure_storage_linux
printing
url_launcher_linux
)

View File

@@ -6,12 +6,16 @@ import FlutterMacOS
import Foundation
import audioplayers_darwin
import connectivity_plus
import flutter_secure_storage_darwin
import printing
import sqflite_darwin
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))

View File

@@ -6,7 +6,11 @@
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.print</key>
<true/>
</dict>
</plist>

View File

@@ -4,5 +4,7 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.print</key>
<true/>
</dict>
</plist>

View File

@@ -137,14 +137,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
crypto:
connectivity_plus:
dependency: "direct main"
description:
name: connectivity_plus
sha256: "762c99f890ca8bf87f7337236f99edd42793843bc6c3631da294a76653a54bd0"
url: "https://pub.dev"
source: hosted
version: "7.3.1"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
crypto:
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
dbus:
dependency: transitive
description:
name: dbus
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
url: "https://pub.dev"
source: hosted
version: "0.7.13"
equatable:
dependency: "direct main"
description:
@@ -153,6 +177,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
event_bus:
dependency: transitive
description:
name: event_bus
sha256: "1a55e97923769c286d295240048fc180e7b0768902c3c2e869fe059aafa15304"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
fake_async:
dependency: transitive
description:
@@ -169,6 +201,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file:
dependency: transitive
description:
@@ -214,6 +254,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
url: "https://pub.dev"
source: hosted
version: "10.3.1"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4"
url: "https://pub.dev"
source: hosted
version: "2.0.3"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
url: "https://pub.dev"
source: hosted
version: "4.2.2"
flutter_shaders:
dependency: transitive
description:
@@ -265,7 +353,7 @@ packages:
source: hosted
version: "2.0.2"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@@ -384,6 +472,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.18.0"
mqtt_client:
dependency: "direct main"
description:
name: mqtt_client
sha256: "41c8edd3bc8efc80c1c8ebfb40081c24d12d13085faca96b9280a624eca2d893"
url: "https://pub.dev"
source: hosted
version: "10.11.11"
native_toolchain_c:
dependency: transitive
description:
@@ -392,6 +488,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.19.2"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
objective_c:
dependency: transitive
description:
@@ -625,10 +729,10 @@ packages:
dependency: transitive
description:
name: sqlite3
sha256: c73fd75df1332d76a6257f4823ae4df9c791f522b97e4a60cbcad214de1becf4
sha256: "64b2c63c8232dd20d14b34105a81ebfd74320442e8451f836179ec89986aa478"
url: "https://pub.dev"
source: hosted
version: "3.5.0"
version: "3.5.1"
stack_trace:
dependency: transitive
description:
@@ -789,6 +893,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
url: "https://pub.dev"
source: hosted
version: "6.4.0"
xdg_directories:
dependency: transitive
description:

View File

@@ -39,6 +39,11 @@ dependencies:
audioplayers: ^6.0.0
pdf: ^3.11.0
printing: ^5.13.0
mqtt_client: ^10.11.11
connectivity_plus: ^7.3.1
http: ^1.6.0
crypto: ^3.0.7
flutter_secure_storage: ^10.3.1
dev_dependencies:
flutter_test:
@@ -49,3 +54,4 @@ flutter:
uses-material-design: true
assets:
- assets/sounds/
- assets/images/

View File

@@ -0,0 +1,533 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:nearle_pos/core/config/sync_config.dart';
import 'package:nearle_pos/data/datasources/local_store.dart';
import 'package:nearle_pos/data/datasources/seed_data.dart';
import 'package:nearle_pos/data/local/app_database.dart';
import 'package:nearle_pos/data/remote/catalogue_source.dart';
import 'package:nearle_pos/data/remote/catalogue_wire.dart';
import 'package:nearle_pos/data/remote/http_catalogue_source.dart';
import 'package:nearle_pos/data/repositories/product_repository_impl.dart';
import 'package:nearle_pos/data/repositories/customer_repository_impl.dart';
import 'package:nearle_pos/data/repositories/transaction_repository_impl.dart';
import 'package:nearle_pos/domain/entities/cart.dart';
import 'package:nearle_pos/domain/entities/customer.dart';
import 'package:nearle_pos/domain/entities/product.dart';
import 'package:nearle_pos/domain/entities/transaction.dart';
import 'package:nearle_pos/domain/usecases/checkout_sale.dart';
const _config = SyncConfig(
transport: TransportKind.http,
httpBaseUrl: 'https://api.example.com',
apiKey: 'k',
storeId: 'store-01',
terminalId: 'T4A9',
);
Map<String, Object?> _product(String id, {double price = 50}) => {
'id': id,
'name': 'Product $id',
'barcode': 'bc-$id',
'sku': 'sku-$id',
'category': 'grocery',
'price': price,
'stock': 20,
'gst_rate': 0.05,
};
void main() {
group('wire format', () {
test('a full record round-trips', () {
final product = CatalogueWire.productFromJson({
..._product('p1'),
'mrp': 60.0,
'emoji': '🍞',
'unit': 'kilogram',
'hsn_code': '1905',
'brand': 'Nearle',
'is_active': false,
});
expect(product.id, 'p1');
expect(product.mrp, 60);
expect(product.unit, UnitOfMeasure.kilogram);
expect(product.hsnCode, '1905');
expect(product.isActive, isFalse);
final json = CatalogueWire.productToJson(product);
expect(CatalogueWire.productFromJson(json), product);
});
test('missing optional fields take sensible defaults', () {
// A catalogue of 4,000 products must not fail to import over one absent
// emoji.
final product = CatalogueWire.productFromJson({
'id': 'p1',
'name': 'Bread',
'barcode': '890',
'price': 40.0,
});
expect(product.sku, 'p1', reason: 'falls back to the id');
expect(product.emoji, '📦');
expect(product.unit, UnitOfMeasure.piece);
expect(product.isActive, isTrue,
reason: 'an omitted flag is not a withdrawn catalogue',);
});
test('a record with no price or barcode is refused, not dropped', () {
// A silently dropped product is a shelf item that scans to nothing,
// discovered with a queue waiting.
expect(
() => CatalogueWire.productFromJson({'id': 'p1', 'name': 'X'}),
throwsA(isA<CatalogueFormatException>()),
);
expect(
() => CatalogueWire.productFromJson({
'id': 'p1',
'name': 'X',
'barcode': '890',
}),
throwsA(isA<CatalogueFormatException>()),
);
});
test('a GST rate is read the same whether sent as 18 or 0.18', () {
// Back offices disagree about which they mean, and getting it wrong
// silently changes the tax on every line.
double rate(Object? raw) => CatalogueWire.productFromJson({
..._product('p1'),
'gst_rate': raw,
}).gstRate;
expect(rate(18), 0.18);
expect(rate(0.18), 0.18);
expect(rate(5), 0.05);
expect(rate(0), 0);
});
test('an unknown category files under Grocery rather than failing', () {
// The item still scans, still prices, still bills. Refusing the whole
// import over a display detail would be far worse.
final product = CatalogueWire.productFromJson({
..._product('p1'),
'category': 'frozen-desserts',
});
expect(product.category, ProductCategory.grocery);
});
test('a category is matched by name or by label', () {
ProductCategory of(String raw) => CatalogueWire.productFromJson({
..._product('p1'),
'category': raw,
}).category;
expect(of('personalCare'), ProductCategory.personalCare);
expect(of('Personal Care'), ProductCategory.personalCare);
expect(of('BEVERAGES'), ProductCategory.beverages);
});
test('customers carry their loyalty balance across', () {
final customer = CatalogueWire.customerFromJson({
'id': 'c1',
'name': 'Meena',
'mobile': '9840000001',
'loyalty_points': 420,
'lifetime_spend': 61000.0,
'last_visit_at': '2026-07-30T10:00:00.000Z',
});
expect(customer.loyaltyPoints, 420);
expect(customer.tier, MembershipTier.gold);
expect(customer.lastVisitAt, isNotNull);
});
test('a date is accepted as ISO text or epoch milliseconds', () {
DateTime? born(Object? raw) => CatalogueWire.customerFromJson({
'id': 'c1',
'name': 'M',
'mobile': '98',
'date_of_birth': raw,
}).dateOfBirth;
expect(born('1990-05-02'), DateTime(1990, 5, 2));
expect(born(641606400000), isNotNull);
expect(born('not a date'), isNull);
expect(born(null), isNull);
});
});
group('http source', () {
HttpCatalogueSource sourceReturning(
List<Map<String, Object?>> pages, {
List<Uri>? record,
}) {
var index = 0;
return HttpCatalogueSource(
config: _config,
client: _FakeClient((request) {
record?.add(request.url);
final body = pages[index < pages.length ? index : pages.length - 1];
index++;
return http.Response(jsonEncode(body), 200);
}),
);
}
test('a single page is read straight through', () async {
final source = sourceReturning([
{
'revision': 'rev-1',
'is_delta': false,
'has_more': false,
'products': [_product('p1'), _product('p2')],
'customers': [
{'id': 'c1', 'name': 'Meena', 'mobile': '9840000001'},
],
},
]);
final snapshot = await source.fetch();
expect(snapshot.products, hasLength(2));
expect(snapshot.customers, hasLength(1));
expect(snapshot.revision, 'rev-1');
expect(snapshot.isDelta, isFalse);
});
test('every page is followed until has_more stops', () async {
// A supermarket catalogue is tens of thousands of rows; a single response
// times out on a shop's line.
final source = sourceReturning([
{
'revision': 'rev-2',
'has_more': true,
'products': [_product('p1')],
},
{
'revision': 'rev-2',
'has_more': true,
'products': [_product('p2')],
},
{
'revision': 'rev-2',
'has_more': false,
'products': [_product('p3')],
},
]);
final snapshot = await source.fetch();
expect(snapshot.products.map((p) => p.id), ['p1', 'p2', 'p3']);
});
test('the revision already held is sent as `since`', () async {
final urls = <Uri>[];
final source = sourceReturning(
[
{'revision': 'rev-9', 'is_delta': true, 'has_more': false},
],
record: urls,
);
await source.fetch(since: 'rev-8');
expect(urls.single.queryParameters['since'], 'rev-8');
expect(urls.single.queryParameters['terminal_id'], 'T4A9');
});
test('a delta is flagged as one, and carries its withdrawals', () async {
final source = sourceReturning([
{
'revision': 'rev-9',
'is_delta': true,
'has_more': false,
'products': [_product('p1', price: 55)],
'retired_product_ids': ['p7', 'p8'],
},
]);
final snapshot = await source.fetch(since: 'rev-8');
expect(snapshot.isDelta, isTrue);
expect(snapshot.retiredProductIds, ['p7', 'p8']);
expect(snapshot.changeCount, 3);
});
test('an empty delta means already up to date', () async {
final source = sourceReturning([
{'revision': 'rev-8', 'is_delta': true, 'has_more': false},
]);
final snapshot = await source.fetch(since: 'rev-8');
expect(snapshot.isEmpty, isTrue);
});
test('a bad credential is not retryable', () async {
final source = HttpCatalogueSource(
config: _config,
client: _FakeClient((_) => http.Response('nope', 401)),
);
await expectLater(
source.fetch(),
throwsA(isA<CatalogueSyncException>()
.having((e) => e.retryable, 'retryable', isFalse),),
);
});
test('a server error is retryable', () async {
final source = HttpCatalogueSource(
config: _config,
client: _FakeClient((_) => http.Response('boom', 503)),
);
await expectLater(
source.fetch(),
throwsA(isA<CatalogueSyncException>()
.having((e) => e.retryable, 'retryable', isTrue),),
);
});
test('an unconfigured endpoint fails fast', () async {
final source = HttpCatalogueSource(
config: const SyncConfig(transport: TransportKind.http),
client: _FakeClient((_) => http.Response('{}', 200)),
);
await expectLater(
source.fetch(),
throwsA(isA<CatalogueSyncException>()
.having((e) => e.retryable, 'retryable', isFalse),),
);
});
test('a server that never stops paging is cut off', () async {
// A bad deployment must not become an infinite request loop against a
// shop's connection.
var calls = 0;
final source = HttpCatalogueSource(
config: _config,
client: _FakeClient((_) {
calls++;
return http.Response(
jsonEncode({'revision': 'r', 'has_more': true, 'products': []}),
200,
);
}),
);
await expectLater(source.fetch(), throwsA(isA<CatalogueSyncException>()));
expect(calls, lessThanOrEqualTo(HttpCatalogueSource.maxPages + 1));
});
test('one malformed product fails the import rather than vanishing',
() async {
final source = sourceReturning([
{
'revision': 'rev-1',
'has_more': false,
'products': [
_product('p1'),
{'id': 'p2', 'name': 'No barcode'},
],
},
]);
await expectLater(
source.fetch(),
throwsA(isA<CatalogueFormatException>()),
);
});
});
group('applying to the terminal', () {
late LocalStore store;
setUpAll(() {
LocalStore.registerSeed(
products: SeedData.products,
customers: SeedData.customers,
);
});
setUp(() async {
store = LocalStore.instance;
await store.reset(withCatalogue: true);
});
test('a delta leaves everything it does not mention alone', () async {
// Applied as a full snapshot, the first morning price change would empty
// the shelf.
final before = store.products.length;
final milk = store.products.firstWhere((p) => p.barcode == '8901234500011');
await store.importCatalogue(
products: [milk.copyWith(price: 71)],
customers: const [],
revision: 'rev-2',
at: DateTime.now(),
isDelta: true,
);
expect(store.products, hasLength(before));
expect(store.productById(milk.id)!.price, 71);
});
test('a full snapshot withdraws what it omits', () async {
final milk = store.products.firstWhere((p) => p.barcode == '8901234500011');
await store.importCatalogue(
products: [milk],
customers: const [],
revision: 'rev-3',
at: DateTime.now(),
);
expect(store.products, hasLength(1));
});
test('a retired product is withdrawn, not deleted', () async {
// An order line already recorded points at it; a hard delete would orphan
// a bill's history.
final milk = store.products.firstWhere((p) => p.barcode == '8901234500011');
await store.importCatalogue(
products: const [],
customers: const [],
revision: 'rev-4',
at: DateTime.now(),
isDelta: true,
retiredProductIds: [milk.id],
);
expect(store.productByBarcode('8901234500011'), isNull,
reason: 'a withdrawn product must not scan',);
// The cache holds only what is sellable, so the surviving row has to be
// checked on disk — which is the point: an order line already recorded
// still resolves to a real product.
final rows = await AppDatabase.instance.db.query(
'products',
where: 'id = ?',
whereArgs: [milk.id],
);
expect(rows, hasLength(1));
expect(rows.single['is_active'], 0);
});
test('a locally added shopper survives a pull', () async {
final customers = CustomerRepositoryImpl(store);
final walkIn = await customers.create(const Customer(
id: 'ignored',
name: 'Local Only',
mobile: '9000000077',
),);
await store.importCatalogue(
products: const [],
customers: const [],
revision: 'rev-5',
at: DateTime.now(),
isDelta: true,
);
expect(await customers.findById(walkIn.id), isNotNull);
});
test('a delta does not subtract already-sold stock a second time',
() async {
// The replay exists because a *full* pull overwrites stock with a server
// figure that predates local sales. Running it over a delta that never
// carried that product would quietly empty a shelf that is full.
final products = ProductRepositoryImpl(store);
final checkout = CheckoutSale(
productRepository: products,
customerRepository: CustomerRepositoryImpl(store),
transactionRepository: TransactionRepositoryImpl(store),
);
final milk = (await products.findByBarcode('8901234500011'))!;
final opening = milk.stock;
await checkout(
cart: Cart(lines: [CartLine(product: milk, quantity: 4)]),
payments: const [
PaymentSplit(method: PaymentMethod.cash, amount: 248, tendered: 250),
],
cashierName: 'Divya',
terminalId: 'T0TEST',
);
expect((await products.findById(milk.id))!.stock, opening - 4);
// A delta about a *different* product must not touch the milk count.
final other = store.products.firstWhere((p) => p.id != milk.id);
await store.importCatalogue(
products: [other.copyWith(price: other.price + 1)],
customers: const [],
revision: 'rev-6',
at: DateTime.now(),
isDelta: true,
);
expect((await products.findById(milk.id))!.stock, opening - 4,
reason: 'the sold units must not be subtracted again',);
});
test('a delta carrying the sold product replays its committed stock',
() async {
// The other half of the same rule: when the server *does* send a fresh
// count for a product, that figure predates the local sale and the units
// would otherwise reappear on the shelf.
final products = ProductRepositoryImpl(store);
final checkout = CheckoutSale(
productRepository: products,
customerRepository: CustomerRepositoryImpl(store),
transactionRepository: TransactionRepositoryImpl(store),
);
final milk = (await products.findByBarcode('8901234500011'))!;
final opening = milk.stock;
await checkout(
cart: Cart(lines: [CartLine(product: milk, quantity: 4)]),
payments: const [
PaymentSplit(method: PaymentMethod.cash, amount: 248, tendered: 250),
],
cashierName: 'Divya',
terminalId: 'T0TEST',
);
await store.importCatalogue(
products: [milk],
customers: const [],
revision: 'rev-7',
at: DateTime.now(),
isDelta: true,
);
expect((await products.findById(milk.id))!.stock, opening - 4,
reason: 'the server count is stale by exactly the unsynced sales',);
});
});
}
class _FakeClient extends http.BaseClient {
_FakeClient(this.respond);
final http.Response Function(http.BaseRequest) respond;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final response = respond(request);
return http.StreamedResponse(
Stream.value(utf8.encode(response.body)),
response.statusCode,
request: request,
);
}
}

View File

@@ -62,6 +62,7 @@ void main() {
PaymentSplit(method: PaymentMethod.cash, amount: 124, tendered: 200),
],
cashierName: 'Suriya',
terminalId: 'T0TEST',
);
final txn = result.transaction;
@@ -83,6 +84,7 @@ void main() {
PaymentSplit(method: PaymentMethod.cash, amount: 124, tendered: 124),
],
cashierName: 'Suriya',
terminalId: 'T0TEST',
);
final milk = await products.findByBarcode('8901234500011');
@@ -98,6 +100,7 @@ void main() {
PaymentSplit(method: PaymentMethod.cash, amount: 124, tendered: 124),
],
cashierName: 'Suriya',
terminalId: 'T0TEST',
);
final history = await transactions.history();
@@ -119,6 +122,7 @@ void main() {
),
],
cashierName: 'Suriya',
terminalId: 'T0TEST',
);
expect(result.transaction.isSplit, isTrue);
@@ -140,6 +144,7 @@ void main() {
PaymentSplit(method: PaymentMethod.cash, amount: 122, tendered: 200),
],
cashierName: 'Suriya',
terminalId: 'T0TEST',
);
final updated = result.updatedCustomer!;
@@ -158,6 +163,7 @@ void main() {
PaymentSplit(method: PaymentMethod.cash, amount: 0),
],
cashierName: 'Suriya',
terminalId: 'T0TEST',
),
throwsA(isA<CheckoutFailure>()),
);
@@ -166,7 +172,12 @@ void main() {
test('refuses a bill with no tender', () async {
final cart = await milkCart();
expect(
() => checkout(cart: cart, payments: const [], cashierName: 'Suriya'),
() => checkout(
cart: cart,
payments: const [],
cashierName: 'Suriya',
terminalId: 'T0TEST',
),
throwsA(isA<CheckoutFailure>()),
);
});
@@ -180,6 +191,7 @@ void main() {
PaymentSplit(method: PaymentMethod.cash, amount: 100, tendered: 100),
],
cashierName: 'Suriya',
terminalId: 'T0TEST',
),
throwsA(isA<CheckoutFailure>()),
);
@@ -198,6 +210,7 @@ void main() {
),
],
cashierName: 'Suriya',
terminalId: 'T0TEST',
),
throwsA(isA<CheckoutFailure>()),
);
@@ -206,7 +219,12 @@ void main() {
test('leaves stock untouched when validation fails', () async {
final cart = await milkCart();
try {
await checkout(cart: cart, payments: const [], cashierName: 'Suriya');
await checkout(
cart: cart,
payments: const [],
cashierName: 'Suriya',
terminalId: 'T0TEST',
);
} on CheckoutFailure {
// expected
}

View File

@@ -0,0 +1,325 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:nearle_pos/data/datasources/local_store.dart';
import 'package:nearle_pos/data/datasources/seed_data.dart';
import 'package:nearle_pos/data/local/app_database.dart';
import 'package:nearle_pos/data/remote/order_transport.dart';
import 'package:nearle_pos/data/remote/simulated_catalogue_source.dart';
import 'package:nearle_pos/data/repositories/customer_repository_impl.dart';
import 'package:nearle_pos/data/repositories/product_repository_impl.dart';
import 'package:nearle_pos/data/repositories/sync_repository_impl.dart';
import 'package:nearle_pos/data/repositories/transaction_repository_impl.dart';
import 'package:nearle_pos/domain/entities/cart.dart';
import 'package:nearle_pos/domain/entities/customer.dart';
import 'package:nearle_pos/domain/entities/transaction.dart';
import 'package:nearle_pos/domain/usecases/checkout_sale.dart';
/// A shopper who signs up at the till has to reach the back office in their
/// own right. Before the customer outbox they only ever travelled as three
/// fields riding along on a bill — so somebody who registered and then bought
/// nothing, or whose bill was still queued, existed on one terminal and
/// nowhere else.
void main() {
late LocalStore store;
late CustomerRepositoryImpl customers;
late CheckoutSale checkout;
setUpAll(() {
LocalStore.registerSeed(
products: SeedData.products,
customers: SeedData.customers,
);
});
setUp(() async {
store = LocalStore.instance;
await store.reset(withCatalogue: true);
customers = CustomerRepositoryImpl(store);
checkout = CheckoutSale(
productRepository: ProductRepositoryImpl(store),
customerRepository: customers,
transactionRepository: TransactionRepositoryImpl(store),
);
});
tearDownAll(() => AppDatabase.instance.close());
SyncRepositoryImpl syncWith(OrderTransport transport) => SyncRepositoryImpl(
store,
SimulatedCatalogueSource(isOffline: () => false),
transport,
);
Future<Customer> register(String mobile, {String name = 'Meena'}) =>
customers.create(Customer(id: '', name: name, mobile: mobile));
group('identity', () {
test('the same mobile produces the same id on any terminal', () {
// The whole point. A hundred tills mint ids without talking to each
// other, so the id has to be a function of the shopper, not of chance.
expect(
Customer.idForMobile('9840012345'),
Customer.idForMobile('9840012345'),
);
});
test('formatting does not create a second shopper', () {
final plain = Customer.idForMobile('9840012345');
expect(Customer.idForMobile('+91 98400 12345'), plain);
expect(Customer.idForMobile('98400-12345'), plain);
});
test('the country code and the trunk prefix are stripped', () {
expect(Customer.normaliseMobile('+91 98400 12345'), '9840012345');
expect(Customer.normaliseMobile('098400 12345'), '9840012345');
expect(Customer.normaliseMobile('9840012345'), '9840012345');
});
test('a number the rule was not written for is left alone', () {
// Mangling something unrecognised is worse than storing it verbatim: a
// wrongly-trimmed number silently merges two different shoppers.
expect(Customer.normaliseMobile('4155550123'), '4155550123');
expect(Customer.normaliseMobile('12345'), '12345');
// Twelve digits that do not start with the Indian country code.
expect(Customer.normaliseMobile('442071234567'), '442071234567');
});
test('different shoppers get different ids', () {
expect(
Customer.idForMobile('9840012345'),
isNot(Customer.idForMobile('9840012346')),
);
});
test('a registration is keyed on the number, not on chance', () async {
final created = await register('+91 98400 12345');
expect(created.id, Customer.idForMobile('9840012345'));
expect(created.mobile, '9840012345');
});
});
group('the outbox', () {
test('a shopper registered at the till is queued', () async {
final before = await store.catalogue.unsyncedCustomerCount();
await register('9840012345');
expect(await store.catalogue.unsyncedCustomerCount(), before + 1);
});
test('a shopper who buys nothing still goes up', () async {
// The case that used to be lost entirely: no bill, so nothing to ride.
final created = await register('9840012345');
final transport = _RecordingTransport();
final outcome = await syncWith(transport).syncCustomers();
expect(outcome.uploaded, greaterThanOrEqualTo(1));
expect(transport.sentIds, contains(created.id));
});
test('shoppers that came from the back office are not posted back',
() async {
// The seed catalogue arrives as an import. Sending it straight back
// would be a round trip telling the server what it just told us.
final imported = store.customers.map((c) => c.id).toSet();
expect(imported, isNotEmpty, reason: 'the fixture needs seeded shoppers');
final pending = await store.catalogue.unsyncedCustomers(limit: 500);
expect(
pending.map((c) => c.id).toSet().intersection(imported),
isEmpty,
);
});
test('only the ids the back office names are marked sent', () async {
final kept = await register('9840012345', name: 'Meena');
final dropped = await register('9840099999', name: 'Ravi');
// Silence about a row is not acceptance of it.
final transport = _RecordingTransport(
accept: (ids) => ids.where((id) => id == kept.id).toList(),
);
await syncWith(transport).syncCustomers();
final stillPending =
(await store.catalogue.unsyncedCustomers(limit: 500))
.map((c) => c.id)
.toSet();
expect(stillPending, contains(dropped.id));
expect(stillPending, isNot(contains(kept.id)));
});
test('a batch nobody accepts stops rather than looping for ever',
() async {
await register('9840012345');
final transport = _RecordingTransport(accept: (_) => const []);
final outcome = await syncWith(transport).syncCustomers();
expect(outcome.isSuccess, isFalse);
expect(outcome.isRetryable, isFalse);
expect(transport.calls, 1, reason: 'the same page must not be re-read');
});
test('an unreachable back office leaves everyone pending', () async {
final created = await register('9840012345');
final outcome = await syncWith(_FailingTransport()).syncCustomers();
expect(outcome.isSuccess, isFalse);
expect(outcome.uploaded, 0);
expect(
(await store.catalogue.unsyncedCustomers(limit: 500))
.map((c) => c.id),
contains(created.id),
);
});
});
group('a sale does not disturb the outbox', () {
/// Rings a bill for [customer] so loyalty movement is written.
Future<void> ringSaleFor(Customer customer) async {
final product = store.products.first;
final cart = Cart(
lines: [CartLine(product: product, quantity: 1)],
customer: customer,
);
await checkout(
cart: cart,
payments: [
PaymentSplit(method: PaymentMethod.cash, amount: cart.grandTotal),
],
cashierName: 'Suriya',
terminalId: 'T4A9',
);
}
test('a sale does not re-queue a shopper already sent', () async {
final created = await register('9840012345');
await syncWith(_RecordingTransport()).syncCustomers();
expect(await store.catalogue.unsyncedCustomerCount(), 0);
await ringSaleFor(created);
// A sale writes the shopper's new points and spend. Done as an upsert
// that replaces the row, every column absent from it — sync_status
// included — would silently revert to its schema default.
expect(
await store.catalogue.unsyncedCustomerCount(),
0,
reason: 'loyalty movement is not a registration change',
);
});
test('a sale still moves the loyalty figures', () async {
// Guards the fix above from being "achieved" by not writing at all.
final created = await register('9840012345');
await ringSaleFor(created);
final after = await store.catalogue.customerById(created.id);
expect(after!.visitCount, 1);
expect(after.lifetimeSpend, greaterThan(0));
expect(after.lastVisitAt, isNotNull);
});
test('a sale does not overwrite a profile', () async {
final created = await register('9840012345', name: 'Meena');
await ringSaleFor(created);
final after = await store.catalogue.customerById(created.id);
expect(after!.name, 'Meena');
expect(after.mobile, '9840012345');
});
});
}
/// Accepts what it is told to and remembers what it saw.
class _RecordingTransport implements OrderTransport {
/// Heartbeats are irrelevant to what these tests assert; recorded only so the
/// fake satisfies the interface.
@override
Future<void> publishHealth(String payload) async {
healthBeats.add(payload);
}
final List<String> healthBeats = [];
_RecordingTransport({List<String> Function(List<String> ids)? accept})
: accept = accept ?? ((ids) => ids);
final List<String> Function(List<String> ids) accept;
final sentIds = <String>[];
int calls = 0;
@override
String get label => 'Recording';
@override
bool get isConnected => true;
@override
Stream<DownlinkMessage> get downlink => const Stream.empty();
@override
Stream<bool> get connectionState => const Stream.empty();
@override
Future<void> connect() async {}
@override
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async =>
PushReceipt(accepted: orders.map((o) => o['id']! as String).toList());
@override
Future<PushReceipt> pushCustomers(
List<Map<String, Object?>> customers,
) async {
calls++;
final ids = customers.map((c) => c['id']! as String).toList();
sentIds.addAll(ids);
return PushReceipt(accepted: accept(ids));
}
@override
Future<void> dispose() async {}
}
class _FailingTransport implements OrderTransport {
/// Heartbeats are irrelevant to what these tests assert; recorded only so the
/// fake satisfies the interface.
@override
Future<void> publishHealth(String payload) async {
healthBeats.add(payload);
}
final List<String> healthBeats = [];
@override
String get label => 'Failing';
@override
bool get isConnected => false;
@override
Stream<DownlinkMessage> get downlink => const Stream.empty();
@override
Stream<bool> get connectionState => const Stream.empty();
@override
Future<void> connect() async {}
@override
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async =>
throw const TransportException('unreachable');
@override
Future<PushReceipt> pushCustomers(
List<Map<String, Object?>> customers,
) async =>
throw const TransportException('unreachable');
@override
Future<void> dispose() async {}
}

View File

@@ -0,0 +1,175 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:nearle_pos/core/config/sync_config.dart';
import 'package:nearle_pos/core/utils/formatters.dart';
import 'package:nearle_pos/data/datasources/local_store.dart';
import 'package:nearle_pos/data/datasources/seed_data.dart';
import 'package:nearle_pos/data/local/app_database.dart';
import 'package:nearle_pos/data/local/terminal_identity.dart';
import 'package:nearle_pos/data/repositories/customer_repository_impl.dart';
import 'package:nearle_pos/data/repositories/product_repository_impl.dart';
import 'package:nearle_pos/data/repositories/transaction_repository_impl.dart';
import 'package:nearle_pos/domain/entities/cart.dart';
import 'package:nearle_pos/domain/entities/transaction.dart';
import 'package:nearle_pos/domain/usecases/checkout_sale.dart';
/// What has to hold when the same build is installed on 100 tills.
///
/// Every one of these was broken: the terminal id was the literal `TERM-01` in
/// five places, so the whole fleet shared one identity, one set of MQTT topics
/// and one invoice series.
void main() {
late LocalStore store;
setUpAll(() {
LocalStore.registerSeed(
products: SeedData.products,
customers: SeedData.customers,
);
});
setUp(() async {
store = LocalStore.instance;
await store.reset(withCatalogue: true);
});
group('identity', () {
test('a device mints an identity on first run and keeps it', () async {
final identityStore = TerminalIdentityStore(store.catalogue);
final first = await identityStore.load();
expect(first.deviceId, isNotEmpty);
expect(first.code, startsWith('T'));
// A second read must not re-mint. If it did, a terminal would change
// identity on every restart and orphan the bills it had already written.
final second = await identityStore.load();
expect(second.deviceId, first.deviceId);
expect(second.code, first.code);
});
test('two devices get different codes', () {
// Derived from the device UUID rather than a counter, because there is no
// shared counter to draw from — each till mints alone and offline.
final a = TerminalIdentityStore.codeFor(
'a1b2c3d4-0000-0000-0000-000000000000',
);
final b = TerminalIdentityStore.codeFor(
'9f8e7d6c-0000-0000-0000-000000000000',
);
expect(a, 'TA1B2');
expect(b, 'T9F8E');
expect(a, isNot(b));
});
test('renaming keeps the device id, so history still points at the till',
() async {
final identityStore = TerminalIdentityStore(store.catalogue);
final before = await identityStore.load();
await identityStore.rename(code: 'till7', name: 'Counter 7');
final after = await identityStore.load();
expect(after.code, 'TILL7');
expect(after.name, 'Counter 7');
expect(after.deviceId, before.deviceId,
reason: 'the machine identity must survive a re-code',);
});
test('the identity is loaded by the store before anything is written',
() async {
expect(store.isReady, isTrue);
expect(store.terminal.deviceId, isNotEmpty);
expect(store.terminal.code, startsWith('T'));
});
});
group('invoice numbers', () {
test('two terminals ringing their first sale do not collide', () {
// The counter lives in each till's own database, so without the terminal
// code every device in the fleet mints INV-2608-00001 for its first sale.
final date = DateTime(2026, 8, 1);
final onTillA = Formatters.invoiceNumber(1, date, terminalCode: 'TA1B2');
final onTillB = Formatters.invoiceNumber(1, date, terminalCode: 'T9F8E');
expect(onTillA, 'INV-2608-TA1B2-00001');
expect(onTillA, isNot(onTillB));
});
test('a real sale carries this terminal\'s code', () async {
final products = ProductRepositoryImpl(store);
final checkout = CheckoutSale(
productRepository: products,
customerRepository: CustomerRepositoryImpl(store),
transactionRepository: TransactionRepositoryImpl(store),
);
final milk = (await products.findByBarcode('8901234500011'))!;
final result = await checkout(
cart: Cart(lines: [CartLine(product: milk, quantity: 1)]),
payments: const [
PaymentSplit(method: PaymentMethod.cash, amount: 62, tendered: 100),
],
cashierName: 'Divya',
terminalId: store.terminal.code,
);
expect(result.transaction.invoiceNumber, contains(store.terminal.code));
expect(result.transaction.terminalId, store.terminal.code);
});
});
group('topics', () {
test('two terminals in one store publish on different topics', () {
const a = SyncConfig(storeId: 'store-01', terminalId: 'TA1B2');
const b = SyncConfig(storeId: 'store-01', terminalId: 'T9F8E');
expect(a.orderTopic, isNot(b.orderTopic));
expect(a.statusTopic, isNot(b.statusTopic));
// A shared client id would evict the other till from the broker on every
// connect, in a loop.
expect(a.clientId, isNot(b.clientId));
});
test('the catalogue topic is shared, because a price change is store-wide',
() {
const a = SyncConfig(storeId: 'store-01', terminalId: 'TA1B2');
const b = SyncConfig(storeId: 'store-01', terminalId: 'T9F8E');
expect(a.catalogueTopic, b.catalogueTopic);
});
test('topics translate to NATS subjects', () {
// NATS' MQTT gateway maps / to . — this is what a JetStream consumer
// binds to.
const config = SyncConfig(storeId: 'store-01', terminalId: 'TA1B2');
expect(
SyncConfig.asNatsSubject(config.orderTopic),
'nearle.pos.store-01.TA1B2.order',
);
expect(
SyncConfig.asNatsSubject(config.statusTopic),
'nearle.pos.store-01.TA1B2.status',
);
expect(
SyncConfig.asNatsSubject(config.healthTopic),
'nearle.pos.store-01.TA1B2.health',
);
});
});
group('database pragmas', () {
test('foreign keys are enforced', () async {
final rows =
await AppDatabase.instance.db.rawQuery('PRAGMA foreign_keys');
expect(rows.first.values.first, 1,
reason: 'order_items must cascade with their order',);
});
test('a contended lock waits instead of throwing', () async {
// Default is 0, which surfaces at checkout as "database is locked" —
// a failed sale with a customer standing there.
final rows =
await AppDatabase.instance.db.rawQuery('PRAGMA busy_timeout');
expect(rows.first.values.first, greaterThanOrEqualTo(5000));
});
});
}

View File

@@ -0,0 +1,156 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:nearle_pos/core/config/sync_config.dart';
import 'package:nearle_pos/core/services/cash_drawer_service.dart';
import 'package:nearle_pos/data/datasources/local_store.dart';
import 'package:nearle_pos/data/datasources/seed_data.dart';
import 'package:nearle_pos/data/local/sync_config_store.dart';
void main() {
setUpAll(() {
LocalStore.registerSeed(
products: SeedData.products,
customers: SeedData.customers,
);
});
group('cash drawer', () {
test('sends the ESC/POS kick to the printer', () async {
// Previously a debugPrint, so the drawer never opened. The PDF pipeline
// cannot carry these bytes — the platform driver renders a document, it
// does not pass raw commands through — so this goes over a socket.
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final received = Completer<List<int>>();
server.listen((socket) {
socket.listen((bytes) {
if (!received.isCompleted) received.complete(bytes);
});
});
final result = await CashDrawerService()
.open(host: server.address.address, port: server.port);
expect(result, DrawerResult.opened);
expect(await received.future, CashDrawerService.kickCommand);
// ESC p 0 25 250 — pin 2, the near-universal wiring.
expect(CashDrawerService.kickCommand, [27, 112, 0, 25, 250]);
await server.close();
});
test('a blank address is not an error', () async {
// Plenty of shops take card only, or open the drawer by hand. A USB
// printer also has no raw path from Flutter, so this is the honest state
// rather than a failure to report.
expect(await CashDrawerService().open(), DrawerResult.notConfigured);
expect(
await CashDrawerService().open(host: ' '),
DrawerResult.notConfigured,
);
});
test('an unreachable printer reports it instead of hanging the sale',
() async {
// Port 1 on loopback refuses immediately.
final result =
await CashDrawerService().open(host: '127.0.0.1', port: 1);
expect(result, DrawerResult.unreachable);
expect(result.isSuccess, isFalse);
expect(result.message, contains('Could not reach'));
});
test('every outcome explains itself to the cashier', () {
for (final result in DrawerResult.values) {
expect(result.message, isNotEmpty);
}
expect(DrawerResult.opened.isSuccess, isTrue);
});
});
group('sync configuration', () {
late LocalStore store;
setUp(() async {
store = LocalStore.instance;
await store.reset(withCatalogue: true);
});
test('the route survives a restart', () async {
// Held only in memory these had to be retyped after every restart, which
// on a shop-floor terminal means they end up on a sticky note instead.
const configured = SyncConfig(
transport: TransportKind.mqtt,
brokerHost: 'nats.example.com',
brokerPort: 1883,
useTls: false,
storeId: 'store-77',
terminalId: 'T9A1',
);
await store.syncConfig.save(configured);
// A fresh store object, as if the app had been relaunched.
final reloaded = await SyncConfigStore(store.catalogue)
.load(const SyncConfig(storeId: 'store-77', terminalId: 'T9A1'));
expect(reloaded.transport, TransportKind.mqtt);
expect(reloaded.brokerHost, 'nats.example.com');
expect(reloaded.brokerPort, 1883);
expect(reloaded.useTls, isFalse);
});
test('no credential is written to the database', () async {
// The whole reason they go to the platform keystore. SQLite holds the
// bills, on a machine behind a shop counter.
await store.syncConfig.save(const SyncConfig(
transport: TransportKind.mqtt,
brokerHost: 'nats.example.com',
username: 'till-04',
password: 'sup3rs3cret',
apiKey: 'ak_live_9f21',
),);
final rows = await store.catalogue.allMeta();
final everything = rows.entries.map((e) => '${e.key}=${e.value}').join('|');
expect(everything, isNot(contains('sup3rs3cret')));
expect(everything, isNot(contains('ak_live_9f21')));
// Non-secret settings are expected to be there — that is the point of
// the split.
expect(everything, contains('nats.example.com'));
});
test('the terminal identity is never overwritten by a saved route',
() async {
// Store and terminal ids belong to the device. Re-pointing a till at a
// different broker must not change who it is, or its bills and its
// presence records stop lining up.
await store.syncConfig.save(const SyncConfig(
transport: TransportKind.http,
httpBaseUrl: 'https://api.example.com',
storeId: 'wrong-store',
terminalId: 'WRONG',
),);
final reloaded = await SyncConfigStore(store.catalogue).load(
const SyncConfig(storeId: 'store-01', terminalId: 'T4A9'),
);
expect(reloaded.storeId, 'store-01');
expect(reloaded.terminalId, 'T4A9');
expect(reloaded.httpBaseUrl, 'https://api.example.com');
});
test('an unconfigured terminal falls back rather than failing', () async {
final loaded = await SyncConfigStore(store.catalogue)
.load(const SyncConfig());
expect(loaded.transport, TransportKind.simulated);
expect(loaded.isConfigured, isTrue);
});
});
}

Some files were not shown because too many files have changed in this diff Show More