Compare commits

17 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
85 changed files with 7584 additions and 2212 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.

View File

@@ -62,14 +62,21 @@ connection or return 5xx instead, and the terminal will back off and retry.
| Topic | Direction | QoS | Retained |
|---|---|---|---|
| `pos/{store}/{terminal}/order` | till → cloud | 1 | no |
| `pos/{store}/{terminal}/ack` | cloud → till | 1 | no |
| `pos/{store}/{terminal}/status` | till → cloud | 1 | **yes** |
| `pos/{store}/{terminal}/command` | cloud → till | 1 | no |
| `pos/{store}/catalogue` | cloud → all tills | 1 | **yes** |
| `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** |
`{store}` and `{terminal}` come from the device's own identity, minted on first
run and stored in its database. They are never literals — 100 tills sharing one
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.
@@ -78,28 +85,28 @@ second connection with the same client id kicks the first off.
dark" board possible, and it is the only way to tell *closed for the night*
from *unplugged*.
### Running this on NATS
### Running this on Mosquitto
The MQTT gateway maps `/` to `.`, so the topics above arrive as subjects and a
JetStream consumer binds to them directly:
The deployed broker is Eclipse Mosquitto 2.1.2. A consumer binds to the topics
above directly, using `+` as the single-level wildcard:
| Purpose | Subject |
| Purpose | Filter |
|---|---|
| Every till's bills | `pos.*.*.order` |
| Every till's presence | `pos.*.*.status` |
| One store's bills | `pos.store-01.*.order` |
| Ack back to one till | `pos.store-01.T4A9.ack` |
| 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` |
`SyncConfig.asNatsSubject()` does the translation, so a consumer's subject can
be read off the terminal rather than guessed.
Two things to get right:
Two things to get right on the NATS side:
- **The stream must be durable and file-backed.** A memory stream loses a shop's
bills on a server restart, and the till has already been told they landed.
- **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.
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
@@ -126,7 +133,7 @@ so.
## Payloads
**Uplink**`pos/{store}/{terminal}/order`
**Uplink**`nearle/pos/{loc}/{terminal}/order`
```json
{
@@ -139,7 +146,7 @@ so.
}
```
**Ack**`pos/{store}/{terminal}/ack`. Must echo `batch_id`; anything else is
**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
@@ -157,6 +164,58 @@ nothing is marked synced, and the batch goes again.
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.
@@ -216,7 +275,7 @@ Dates are ISO 8601 or epoch milliseconds; both are accepted.
### Pushing a change mid-day
Publish to `pos/{store}/catalogue` (retained) and every terminal in the shop
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.
@@ -237,15 +296,15 @@ overstate the day.
- **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.
- **Credentials survive a restart.** The back-office dialog writes the broker
host, port, TLS flag and credentials into `syncConfigProvider`, which is
in-memory. Terminal name and store id persist (they live in the database);
the credentials do not, and must be re-entered after a restart. Persisting
them means encrypting them at rest, which is the next piece of work.
- **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`.
- **Pushing customers upward.** A shopper registered at the till stays on that
terminal and rides along on the bills they appear on. There is no
customer-create endpoint yet, so two terminals registering the same mobile
number will each hold their own row until the back office reconciles them.
- **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,7 +3,9 @@ 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 {
@@ -15,6 +17,18 @@ class NearlePosApp extends ConsumerWidget {
// 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

@@ -13,10 +13,12 @@ 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';
@@ -31,6 +33,34 @@ 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)),
@@ -79,15 +109,19 @@ final catalogueSourceProvider = Provider<CatalogueSource>((ref) {
// ------------------------------------------------------------------- Sync
/// How this terminal reaches the back office.
///
/// Defaults to the simulated route so a fresh install is usable with no broker
/// and no endpoint; Settings re-points it.
/// 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.
///
/// Store and terminal ids always come from this device's own identity, never
/// from a literal — two terminals publishing on the same topic is the failure
/// this exists to prevent.
/// 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,
);
@@ -160,7 +194,8 @@ final storeRepositoryProvider = Provider<StoreRepositoryImpl>(
/// The outlet, refreshed whenever staff or details change.
final storeAccountProvider = FutureProvider<StoreAccount>(
(ref) => ref.watch(storeRepositoryProvider).load(
email: DemoCredentials.email,
// The account the terminal is signed in as, or blank before sign-in.
email: ref.watch(sessionAuthnameProvider),
),
);

View File

@@ -73,13 +73,32 @@ class SyncConfig {
};
// ------------------------------------------------------------------ Topics
String get _base => 'pos/$storeId/$terminalId';
/// 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
@@ -87,8 +106,16 @@ class SyncConfig {
/// 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 => 'pos/$storeId/catalogue';
String get catalogueTopic => 'nearle/pos/$storeId/catalogue';
/// Addressed to this terminal alone.
String get commandTopic => '$_base/command';
@@ -106,7 +133,8 @@ class SyncConfig {
/// 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:
/// `pos.*.*.order` for every till's bills, `pos.*.*.status` for presence.
/// `nearle.pos.*.*.order` for every till's bills, `nearle.pos.*.*.health`
/// for presence.
static String asNatsSubject(String topic) => topic.replaceAll('/', '.');
SyncConfig copyWith({

View File

@@ -39,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

@@ -83,4 +83,35 @@ class Formatters {
: '$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

@@ -9,6 +9,7 @@ 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.
///
@@ -28,6 +29,7 @@ class LocalStore {
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;
@@ -39,6 +41,7 @@ class LocalStore {
DateTime? _lastImportAt;
String? _catalogueRevision;
int _unsyncedOrders = 0;
int _unsyncedCustomers = 0;
bool _ready = false;
@@ -59,6 +62,7 @@ class LocalStore {
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.
@@ -92,6 +96,7 @@ class LocalStore {
_catalogueRevision = await catalogue.meta(MetaKeys.catalogueRevision);
terminal = await identityStore.load();
_unsyncedOrders = await orders.unsyncedCount();
_unsyncedCustomers = await catalogue.unsyncedCustomerCount();
_syncEvents
..clear()
@@ -144,6 +149,22 @@ 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,
@@ -239,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

@@ -13,7 +13,7 @@ class AppDatabase {
static final AppDatabase instance = AppDatabase._();
static const String _fileName = 'nearle_pos.db';
static const int _version = 7;
static const int _version = 8;
Database? _db;
@@ -77,6 +77,7 @@ class AppDatabase {
'ALTER TABLE ${Tables.orders} ADD COLUMN promos_json TEXT',
);
}
if (from < 8) await _upgradeToV8(db);
},
),
);
@@ -188,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('''
@@ -322,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,
@@ -474,4 +524,14 @@ class MetaKeys {
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,18 @@ 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
@@ -218,7 +230,7 @@ class CatalogueDao {
for (final c in customers) {
batch.insert(
Tables.customers,
customerToRow(c),
_importedCustomerRow(c),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
@@ -227,6 +239,24 @@ class CatalogueDao {
});
}
/// 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;
@@ -254,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);
@@ -274,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(

View File

@@ -61,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);
@@ -249,20 +303,27 @@ 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

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

@@ -53,7 +53,13 @@ class TerminalIdentityStore {
///
/// The mint is idempotent: an existing device id is never replaced, so a
/// terminal cannot silently change identity and orphan its own history.
Future<TerminalIdentity> load({String defaultStoreId = 'store-01'}) async {
///
/// [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);

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

@@ -11,10 +11,13 @@ import 'catalogue_wire.dart';
/// Pulls the catalogue from the back office over HTTP.
///
/// ```
/// GET {base}/catalogue?since={revision}&page={n}
/// 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",
@@ -53,6 +56,10 @@ class HttpCatalogueSource implements CatalogueSource {
/// 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
@@ -77,7 +84,7 @@ class HttpCatalogueSource implements CatalogueSource {
var revision = since ?? '';
var isDelta = false;
var page = 1;
var page = 0;
onProgress?.call(0.05, 'Contacting the back office…');
@@ -137,6 +144,7 @@ class HttpCatalogueSource implements CatalogueSource {
queryParameters: {
if (since != null && since.isNotEmpty) 'since': since,
'page': '$page',
'page_size': '$pageSize',
'store_id': config.storeId,
'terminal_id': config.terminalId,
},

View File

@@ -2,6 +2,7 @@ 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';
@@ -13,6 +14,12 @@ import 'order_transport.dart';
/// 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
@@ -29,6 +36,8 @@ class HttpOrderTransport implements OrderTransport {
final SyncConfig config;
final http.Client _client;
static const _uuid = Uuid();
final _connection = StreamController<bool>.broadcast();
bool _reachable = true;
@@ -50,8 +59,53 @@ class HttpOrderTransport implements OrderTransport {
Future<void> connect() async {}
@override
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async {
if (orders.isEmpty) return const PushReceipt(accepted: []);
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(
@@ -60,7 +114,17 @@ class HttpOrderTransport implements OrderTransport {
);
}
final uri = Uri.parse('${config.httpBaseUrl}/orders');
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 {
@@ -71,16 +135,14 @@ class HttpOrderTransport implements OrderTransport {
'content-type': 'application/json',
if (config.apiKey != null)
'authorization': 'Bearer ${config.apiKey}',
// Lets the endpoint collapse a retried batch server-side rather
// than relying on every order id being checked individually.
'idempotency-key': _batchKey(orders),
'idempotency-key': batchId,
},
body: jsonEncode({
'schema': 1,
'batch_id': batchId,
'store_id': config.storeId,
'terminal_id': config.terminalId,
'sent_at': DateTime.now().toIso8601String(),
'orders': orders,
key: items,
}),
)
.timeout(config.ackTimeout);
@@ -132,11 +194,6 @@ class HttpOrderTransport implements OrderTransport {
return PushReceipt(accepted: accepted, rejected: rejected);
}
/// Stable for a given set of bills, so a retry after a timeout carries the
/// same key as the attempt that may already have landed.
String _batchKey(List<Map<String, Object?>> orders) =>
orders.map((o) => o['id']).join('|').hashCode.toRadixString(16);
void _setReachable(bool value) {
if (_reachable == value) return;
_reachable = value;

View File

@@ -170,8 +170,36 @@ class MqttOrderTransport implements OrderTransport {
// ----------------------------------------------------------------- Uplink
@override
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async {
if (orders.isEmpty) return const PushReceipt(accepted: []);
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();
@@ -181,14 +209,14 @@ class MqttOrderTransport implements OrderTransport {
try {
_publish(
config.orderTopic,
topic,
jsonEncode({
'schema': 1,
'batch_id': batchId,
'store_id': config.storeId,
'terminal_id': config.terminalId,
'sent_at': DateTime.now().toIso8601String(),
'orders': orders,
key: items,
}),
);
@@ -196,7 +224,7 @@ class MqttOrderTransport implements OrderTransport {
config.ackTimeout,
onTimeout: () => throw TransportException(
'The back office did not confirm the batch within '
'${config.ackTimeout.inSeconds}s. The bills are still on this '
'${config.ackTimeout.inSeconds}s. The $noun are still on this '
'terminal and will be sent again.',
),
);
@@ -230,6 +258,19 @@ class MqttOrderTransport implements OrderTransport {
_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

View File

@@ -86,6 +86,17 @@ abstract class OrderTransport {
/// 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;
@@ -95,5 +106,18 @@ abstract class OrderTransport {
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

@@ -32,21 +32,36 @@ class SimulatedOrderTransport implements OrderTransport {
@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<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async {
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 + orders.length * 60),
Duration(milliseconds: 400 + items.length * 60),
);
if (isOffline()) {
throw const TransportException(
throw TransportException(
'Simulate offline is ON in Settings, so the upload was failed on '
'purpose. Every bill is still stored on this terminal.',
'purpose. Every $noun is still stored on this terminal.',
);
}
return PushReceipt(
accepted: orders.map((o) => o['id']! as String).toList(),
accepted: items.map((o) => o['id']! as String).toList(),
);
}

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

@@ -3,6 +3,7 @@ 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';
@@ -177,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),
@@ -333,12 +335,115 @@ class SyncRepositoryImpl implements SyncRepository {
DateTime.now().subtract(OrderDao.retentionWindow),
);
/// The JSON body sent per order.
// ------------------------------------------------- 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 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
@@ -349,16 +454,16 @@ class SyncRepositoryImpl implements SyncRepository {
},
'subtotal': t.cart.subtotal,
'discount': t.cart.billDiscountTotal + t.cart.lineDiscountTotal,
'promos': [
for (final applied in t.cart.appliedPromos)
{
'id': applied.promo.id,
'name': applied.promo.name,
'type': applied.promo.type.name,
'amount': applied.amount,
},
],
'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,
@@ -387,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

@@ -250,6 +250,17 @@ class SyncEngine {
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

View File

@@ -177,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;
@@ -198,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 {};

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

@@ -1,6 +1,7 @@
import 'package:equatable/equatable.dart';
import '../../core/constants/app_constants.dart';
import 'product.dart';
/// What a promo does to a bill.
enum PromoType {
@@ -115,6 +116,28 @@ class Promo extends Equatable {
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',

View File

@@ -39,6 +39,7 @@ class OrderSyncRow {
required this.total,
required this.createdAt,
required this.isSynced,
this.itemCount = 0,
this.syncedAt,
this.attempts = 0,
this.error,
@@ -49,6 +50,9 @@ 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;
@@ -90,6 +94,17 @@ abstract class SyncRepository {
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();

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

@@ -90,18 +90,14 @@ class PromoEngine {
final raw = switch (promo.type) {
PromoType.percentOffBill => cart.subtotal * (promo.value / 100),
PromoType.flatOffBill => promo.value,
PromoType.percentOffCategory => _percentOfMatching(
cart,
promo.value,
// Stored by enum name, which is stable across a label change —
// renaming "Personal Care" must not silently switch off a campaign.
(line) => line.product.category.name == promo.targetId,
),
PromoType.percentOffProduct => _percentOfMatching(
cart,
promo.value,
(line) => line.product.id == promo.targetId,
),
// 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),
};
@@ -112,13 +108,9 @@ class PromoEngine {
return capped.clamp(0, cart.subtotal).toDouble().asMoney;
}
static double _percentOfMatching(
Cart cart,
double percent,
bool Function(CartLine) matches,
) {
static double _percentOfMatching(Cart cart, double percent, Promo promo) {
final base = cart.lines
.where(matches)
.where((line) => promo.targets(line.product))
.fold(0.0, (sum, line) => sum + line.payable);
return base * (percent / 100);
}

View File

@@ -1,6 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.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,72 +53,211 @@ class AuthFailure extends AuthState {
final String message;
}
/// Store-level credentials for the unregistered build.
/// The two shapes this terminal can take.
///
/// Still a constant, and deliberately so: this is the *store* login, not a
/// person's, and it is replaced wholesale when the terminal is registered
/// against a real back office. Staff PINs — the credential that actually opens
/// a till drawer — are no longer here. They live hashed in the database.
class DemoCredentials {
const DemoCredentials._();
/// 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.',
);
static const String email = 'admin@nearle.in';
static const String password = 'nearle123';
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 and holds the signed-in session.
/// 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;
}
if (password != DemoCredentials.password) {
state = const AuthFailure('Incorrect password. Please try again.');
return false;
}
final store = await _ref.read(storeAccountProvider.future);
final staff = store.staff;
if (staff.isEmpty) {
} on Object catch (e, stack) {
debugPrint('Sign-in failed: $e\n$stack');
state = const AuthFailure(
'This terminal has no staff accounts. Reinstall to seed them.',
'Sign-in failed unexpectedly. Please try again.',
);
return false;
}
}
// The first admin, or whoever is there. A person switches to their own
// account at the till.
final opener = staff.firstWhere(
(s) => s.role == StaffRole.admin,
orElse: () => 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,
);
}
state = Authenticated(store: store, user: opener);
return true;
/// 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.
/// 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;
@@ -105,7 +266,12 @@ class AuthController extends StateNotifier<AuthState> {
final user = await store.staff.authenticate(pin);
if (user == null) return false;
state = Authenticated(store: current.store, user: user);
state = Authenticated(
store: current.store,
user: user,
login: current.login,
session: current.session,
);
return true;
}
@@ -114,19 +280,48 @@ class AuthController extends StateNotifier<AuthState> {
final current = state;
if (current is! Authenticated) return;
final store = await _ref.read(storeRepositoryProvider).load(
email: current.session.authname,
);
_ref.invalidate(storeAccountProvider);
final store = await _ref.read(storeAccountProvider.future);
// 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,
// Signed out if the active operator was just deactivated — carrying on
// would keep stamping bills with an account the shop has revoked.
user: me.isEmpty ? store.staff.first : me.first,
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();
@@ -149,8 +344,47 @@ final currentUserProvider = Provider<StaffUser?>((ref) {
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,193 +53,88 @@ 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(
children: [
if (showBrandPanel)
const Expanded(flex: 5, child: _BrandPanel()),
Expanded(
flex: 4,
child: _FormPanel(
formKey: _formKey,
email: _email,
password: _password,
obscure: _obscure,
rememberTerminal: _rememberTerminal,
showCompactLogo: !showBrandPanel,
onToggleObscure: () => setState(() => _obscure = !_obscure),
onToggleRemember: (v) =>
setState(() => _rememberTerminal = v ?? true),
onSubmit: _submit,
),
),
],
);
},
),
);
}
}
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,
body: Stack(
fit: StackFit.expand,
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 _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,
),
),
const SizedBox(height: 2),
Text(
body,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
fontSize: 13,
height: 1.5,
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,
authname: _authname,
password: _password,
obscure: _obscure,
rememberTerminal: _rememberTerminal,
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),
),
),
),
],
),
),
),
),
),
],
);
},
),
),
],
@@ -234,238 +143,255 @@ 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(
padding: const EdgeInsets.all(AppSpacing.xxl),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Form(
key: formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
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 '
'registered.',
style: TextStyle(
fontSize: 13.5,
color: AppColors.textSecondary,
height: 1.5,
),
),
const SizedBox(height: AppSpacing.xxxl),
const _Label('Store email'),
TextFormField(
controller: email,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
enabled: !busy,
validator: (v) => (v ?? '').trim().isEmpty
? 'Store email is required'
: Validators.emailOptional(v),
decoration: const InputDecoration(
hintText: 'store@example.in',
prefixIcon: Icon(Icons.storefront_outlined),
),
),
const SizedBox(height: AppSpacing.lg),
const _Label('Password'),
TextFormField(
controller: password,
obscureText: obscure,
enabled: !busy,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => onSubmit(),
validator: (v) => (v ?? '').isEmpty
? 'Password is required'
: ((v ?? '').length < 6
? 'Password looks too short'
: null),
decoration: InputDecoration(
hintText: 'Enter your password',
prefixIcon: const Icon(Icons.lock_outline_rounded),
suffixIcon: IconButton(
onPressed: onToggleObscure,
icon: Icon(
obscure
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
size: 20,
),
tooltip: obscure ? 'Show password' : 'Hide password',
),
),
),
const SizedBox(height: AppSpacing.sm),
// Wrap, not Row — on a narrow tablet these would collide.
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
InkWell(
onTap: busy
? null
: () => onToggleRemember(!rememberTerminal),
borderRadius: AppRadius.brXs,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 22,
height: 22,
child: Checkbox(
value: rememberTerminal,
onChanged: busy ? null : onToggleRemember,
visualDensity: VisualDensity.compact,
),
),
const SizedBox(width: AppSpacing.sm),
const Text(
'Remember this terminal',
style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
],
),
),
),
TextButton(
onPressed: busy ? null : () {},
child: const Text('Forgot password?'),
),
],
),
if (auth is AuthFailure) ...[
const SizedBox(height: AppSpacing.sm),
Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: const BoxDecoration(
color: AppColors.dangerSurface,
borderRadius: AppRadius.brSm,
),
child: Row(
children: [
const Icon(Icons.error_outline_rounded,
color: AppColors.danger, size: 18,),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
auth.message,
style: const TextStyle(
color: AppColors.danger,
fontSize: 13,
),
),
),
],
),
).animate().shake(duration: 320.ms, hz: 3),
],
const SizedBox(height: AppSpacing.xl),
PrimaryButton(
label: 'Sign in',
icon: Icons.login_rounded,
large: true,
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,
),
),
),
],
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppSpacing.xxl),
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,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'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,
color: AppColors.textSecondary,
height: 1.5,
),
),
const SizedBox(height: AppSpacing.xl),
const _Label('Terminal account'),
TextFormField(
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
? 'The terminal account is required'
: null,
decoration: const InputDecoration(
hintText: 'supervisor.1135@pos.nearle.in',
prefixIcon: Icon(Icons.storefront_outlined),
),
),
const SizedBox(height: AppSpacing.lg),
const _Label('Password'),
TextFormField(
controller: password,
obscureText: obscure,
enabled: !busy,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => onSubmit(),
// 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),
suffixIcon: IconButton(
onPressed: onToggleObscure,
icon: Icon(
obscure
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
size: 20,
),
tooltip: obscure ? 'Show password' : 'Hide password',
),
),
),
const SizedBox(height: AppSpacing.sm),
// Wrap, not Row — on a narrow tablet these would collide.
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
InkWell(
onTap: busy ? null : () => onToggleRemember(!rememberTerminal),
borderRadius: AppRadius.brXs,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 22,
height: 22,
child: Checkbox(
value: rememberTerminal,
onChanged: busy ? null : onToggleRemember,
visualDensity: VisualDensity.compact,
),
),
const SizedBox(width: AppSpacing.sm),
const Text(
'Remember this terminal',
style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
],
),
),
),
TextButton(
onPressed: busy ? null : () {},
child: const Text('Forgot password?'),
),
],
),
if (failure != null) ...[
const SizedBox(height: AppSpacing.sm),
Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: const BoxDecoration(
color: AppColors.dangerSurface,
borderRadius: AppRadius.brSm,
),
child: Row(
children: [
const Icon(
Icons.error_outline_rounded,
color: AppColors.danger,
size: 18,
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
failure!,
style: const TextStyle(
color: AppColors.danger,
fontSize: 13,
),
),
),
],
),
).animate().shake(duration: 320.ms, hz: 3),
],
const SizedBox(height: AppSpacing.lg),
PrimaryButton(
label: 'Sign in',
icon: Icons.login_rounded,
large: true,
busy: busy,
onPressed: onSubmit,
),
],
),
),
);
).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,23 +83,39 @@ 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);
} catch (e) {
@@ -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(),
_header(attached),
const Divider(height: 1),
// 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: 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(
children: [
entry,
const SizedBox(height: AppSpacing.xl),
result,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: entry),
const SizedBox(width: AppSpacing.xxl),
Expanded(child: result),
],
);
},
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.fromLTRB(
AppSpacing.xxl,
AppSpacing.xl,
AppSpacing.xxl,
AppSpacing.xxl,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_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),
],
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),
decoration: BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brLg,
border: Border.all(color: AppColors.primaryBorder),
return Container(
height: 64,
padding: const EdgeInsets.only(left: AppSpacing.md, right: AppSpacing.xs),
decoration: BoxDecoration(
color: filled ? AppColors.surface : AppColors.surfaceAlt,
borderRadius: AppRadius.brLg,
border: Border.all(
color: filled ? AppColors.primary : AppColors.border,
width: filled ? 1.4 : 1,
),
child: Row(
children: [
const Text(
'+91',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
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(' '),
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: _digits.isEmpty
? AppColors.textTertiary
: AppColors.textPrimary,
),
),
),
),
if (_digits.isNotEmpty)
IconButton(
onPressed: _clear,
icon: const Icon(Icons.close_rounded, size: 20),
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,
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(AppSpacing.lg),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 3,
),
decoration: const BoxDecoration(
color: AppColors.successSurface,
borderRadius: AppRadius.brLg,
color: AppColors.surfaceAlt,
borderRadius: AppRadius.brXs,
),
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,
child: const Text(
'+91',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.success,
color: AppColors.textSecondary,
),
),
),
Text(
label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
const SizedBox(width: AppSpacing.md),
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
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: 16,
color: AppColors.textTertiary.withValues(alpha: 0.9),
),
),
),
),
if (filled)
IconButton(
onPressed: _clear,
icon: const Icon(Icons.backspace_outlined, size: 18),
color: AppColors.textTertiary,
tooltip: 'Clear',
),
],
);
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

@@ -5,7 +5,6 @@ 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';
@@ -24,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) {
@@ -39,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);
@@ -85,26 +75,6 @@ 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',
@@ -134,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),
@@ -199,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),
Icon(Icons.wifi_off_rounded, size: 20, color: color),
const SizedBox(width: AppSpacing.md),
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,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
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: 12,
color: AppColors.textSecondary,
height: 1.45,
),
),
],
),
),
],
),
);
}
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,
),
),
),
const SizedBox(width: AppSpacing.md),
Text(
value,
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
),
),
],
),
);
}

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

@@ -33,7 +33,11 @@ class PromosView extends ConsumerWidget {
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: [
_summary(promos),
const SizedBox(height: AppSpacing.lg),
@@ -55,6 +59,7 @@ class PromosView extends ConsumerWidget {
.length;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: StatTile(
@@ -119,6 +124,7 @@ class PromosView extends ConsumerWidget {
)
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final promo in promos)
_PromoRow(promo: promo, isAdmin: isAdmin),

View File

@@ -9,6 +9,7 @@ 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';
@@ -17,6 +18,7 @@ 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 {
@@ -32,6 +34,10 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
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;
@@ -50,6 +56,16 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
// 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;
@@ -80,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(),
],
);
@@ -430,6 +448,94 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
),
);
// ------------------------------------------------------- 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);
@@ -609,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

@@ -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

@@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/utils/extensions.dart';
import '../../../data/sync/sync_engine.dart';
import '../../../domain/entities/transaction.dart';
import '../../../domain/usecases/checkout_sale.dart';
import '../../auth/providers/auth_controller.dart';
@@ -80,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;
return state.cashTendered >= balanceDue;
}
/// 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) {
@@ -212,10 +209,11 @@ class PaymentController extends StateNotifier<PaymentState> {
_ref.invalidate(visibleProductsProvider);
_ref.read(orderVersionProvider.notifier).state++;
// The bill is safely on disk; getting it to the back office is the
// engine's problem now. Deliberately not awaited — the cashier must
// reach the receipt screen at network speed of zero.
_ref.read(syncEngineProvider).nudge(SyncTrigger.saleCommitted);
// 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) {

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,15 +121,36 @@ 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: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 4, child: SingleChildScrollView(child: left)),
const SizedBox(width: AppSpacing.lg),
Expanded(flex: 5, child: SingleChildScrollView(child: right)),
],
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: minHeight),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1340),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: left),
const SizedBox(width: AppSpacing.lg),
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,82 +325,195 @@ 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: [
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),
_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,
),
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: AppRadius.brLg,
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
'${_cashBuffer.isEmpty ? '0' : _cashBuffer}',
style: AppTypography.money(30),
),
),
),
],
),
);
}
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: [
ActionChip(
avatar: const Icon(Icons.done_all_rounded, size: 15),
label: const Text('Exact'),
onPressed: () => _setCash(controller.balanceDue),
),
// 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),
),
],
);
}
/// Shortcuts beside the keypad, for the wide layout.
Widget _shortcutColumn(PaymentController controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Cash received',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: AppSpacing.md),
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.md,
),
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: AppRadius.brLg,
border: Border.all(color: AppColors.border),
),
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),
),
),
),
],
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),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
ActionChip(
avatar: const Icon(Icons.done_all_rounded, size: 15),
label: const Text('Exact'),
onPressed: () => _setCash(controller.balanceDue),
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,
),
for (final note in const [50, 100, 200, 500, 2000])
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,
child: Text('+ ₹$note'),
),
),
const SizedBox(height: AppSpacing.md),
_splitButton(controller, amount: double.tryParse(_cashBuffer) ?? 0),
if (note != _notes.last) const SizedBox(height: AppSpacing.sm),
],
],
);
}
@@ -473,86 +561,379 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
);
}
Widget _referenceTender(PaymentController controller, PaymentState state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
/// 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: [
Text(state.activeMethod.emoji,
style: const TextStyle(fontSize: 20),),
Row(
children: [
const Icon(Icons.sell_outlined,
size: 18, color: AppColors.primary,),
const SizedBox(width: AppSpacing.sm),
const Expanded(
child: Text(
'Offers',
style:
TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
),
if (applied.isNotEmpty)
Text(
' ${Formatters.money(
applied.fold<double>(0, (sum, a) => sum + a.amount),
)}',
style: AppTypography.money(14, color: AppColors.success),
),
],
),
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 _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)),
],
],
),
);
// --------------------------------------------------------- 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(
'${state.activeMethod.label} payment',
overflow: TextOverflow.ellipsis,
style:
const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
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),
),
),
],
),
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),
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,
),
),
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),
],
);
}
Widget _splitButton(PaymentController controller, {double? amount}) {
return OutlinedButton.icon(
onPressed: controller.balanceDue > 0
? () {
controller.addSplit(
amount: amount?.clamp(0, controller.balanceDue).toDouble(),
);
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)),
);
}
);
// ------------------------------------------------------------ Bottom bar
Widget _bottomBar(
@@ -622,18 +1003,17 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
),
),
),
if (state.activeMethod.needsChange)
TextButton(
onPressed: () => _setCash(controller.balanceDue),
style: TextButton.styleFrom(
minimumSize: const Size(0, 30),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
),
TextButton(
onPressed: () => _setCash(controller.balanceDue),
style: TextButton.styleFrom(
minimumSize: const Size(0, 30),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
),
child: const Text('Exact',
style: TextStyle(fontSize: 12.5),),
),
child: const Text('Exact',
style: TextStyle(fontSize: 12.5),),
),
],
),
),
@@ -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

@@ -208,10 +208,19 @@ 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) {
@@ -302,6 +311,15 @@ 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();
// Re-evaluated rather than restored: a campaign that has since ended must
@@ -309,6 +327,32 @@ class CartController extends StateNotifier<Cart> {
_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) => [
for (final l in state.lines)
if (l.product.id == updated.product.id) updated else l,

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

@@ -35,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});
@@ -119,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;
@@ -130,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,
@@ -157,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,23 +183,52 @@ class _CatalogueRequired extends ConsumerWidget {
),
],
PrimaryButton(
label: 'Import catalogue now',
icon: Icons.cloud_download_rounded,
large: true,
busy: running,
onPressed: running
? null
: () => ref.read(catalogueImportProvider.notifier).run(),
),
const SizedBox(height: AppSpacing.md),
TextButton.icon(
onPressed: () => ref
.read(activeModuleProvider.notifier)
.state = PosModule.productImport,
icon: const Icon(Icons.open_in_new_rounded, size: 16),
label: const Text('Open Product Import'),
),
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,
large: true,
busy: running,
onPressed: running
? null
: () => ref.read(catalogueImportProvider.notifier).run(),
),
const SizedBox(height: AppSpacing.md),
TextButton.icon(
onPressed: () => ref
.read(activeModuleProvider.notifier)
.state = PosModule.productImport,
icon: const Icon(Icons.open_in_new_rounded, size: 16),
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,
@@ -263,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
@@ -360,40 +351,48 @@ class _Row extends StatelessWidget {
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
child: Row(children: [
Flexible(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
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,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
),
),
if (hint != null) ...[
const SizedBox(width: AppSpacing.xs),
Text('($hint)',
if (hint != null) ...[
const SizedBox(width: AppSpacing.xs),
Text(
'($hint)',
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
),),
],
const SizedBox(width: AppSpacing.sm),
const Spacer(),
Text(
value,
style: AppTypography.money(
14.5,
weight: FontWeight.w600,
color: valueColor ?? AppColors.textPrimary,
),
),
],
const Spacer(),
Text(
value,
style: AppTypography.money(
14.5,
weight: FontWeight.w600,
color: valueColor ?? AppColors.textPrimary,
),
),
),
if (trailingIcon != null) ...[
const SizedBox(width: AppSpacing.xs),
Icon(trailingIcon, size: 14, color: AppColors.textTertiary),
if (trailingIcon != null) ...[
const SizedBox(width: AppSpacing.xs),
Icon(trailingIcon, size: 14, color: AppColors.textTertiary),
],
],
],),
),
);
}
}
@@ -414,26 +413,33 @@ class _Actions extends ConsumerWidget {
AppSpacing.xl,
AppSpacing.xl,
),
child: 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.
if (ref.read(cartControllerProvider).customer == null) {
await showCustomerCaptureSheet(context);
}
// Navigation result is not needed here.
if (context.mounted) unawaited(context.push(AppRoutes.payment));
}
: null,
trailing: enabled
? Text(
Formatters.money(cart.grandTotal),
style: AppTypography.money(21, color: Colors.white),
)
: null,
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.
if (ref.read(cartControllerProvider).customer == null) {
await showCustomerCaptureSheet(context);
}
// Navigation result is not needed here.
if (context.mounted) {
unawaited(context.push(AppRoutes.payment));
}
}
: null,
trailing: enabled
? Text(
Formatters.money(cart.grandTotal),
style: AppTypography.money(21, color: Colors.white),
)
: 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),
],
),
),
const Spacer(),
// 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),
],
if (showStatus) ...[
_LivePill(offline: ref.watch(simulateOfflineProvider)),
@@ -103,56 +97,32 @@ 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;
@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,
),
),
],
);
}
}
/// What the pill is saying, in the order it takes precedence.
enum _Liveness { offlineSim, halted, syncing, queued, live }
@@ -342,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.',
),
),);
}
},
);
},
@@ -370,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) {
@@ -400,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);
}
@@ -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

@@ -3,7 +3,9 @@ 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';
@@ -237,7 +239,9 @@ final syncBootstrapProvider = FutureProvider<void>((ref) async {
await engine.start();
// Fleet presence only exists on a transport that can carry it.
// 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(
@@ -252,4 +256,35 @@ final syncBootstrapProvider = FutureProvider<void>((ref) async {
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

@@ -2,11 +2,15 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<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>
</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>
</plist>

View File

@@ -274,18 +274,18 @@ packages:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.2"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633"
sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
version: "2.0.3"
flutter_secure_storage_web:
dependency: transitive
description:
@@ -729,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:
@@ -897,10 +897,10 @@ packages:
dependency: transitive
description:
name: win32
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
url: "https://pub.dev"
source: hosted
version: "6.3.0"
version: "6.4.0"
xdg_directories:
dependency: transitive
description:

View File

@@ -54,3 +54,4 @@ flutter:
uses-material-design: true
assets:
- assets/sounds/
- assets/images/

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

@@ -143,11 +143,15 @@ void main() {
const config = SyncConfig(storeId: 'store-01', terminalId: 'TA1B2');
expect(
SyncConfig.asNatsSubject(config.orderTopic),
'pos.store-01.TA1B2.order',
'nearle.pos.store-01.TA1B2.order',
);
expect(
SyncConfig.asNatsSubject(config.statusTopic),
'pos.store-01.TA1B2.status',
'nearle.pos.store-01.TA1B2.status',
);
expect(
SyncConfig.asNatsSubject(config.healthTopic),
'nearle.pos.store-01.TA1B2.health',
);
});
});

View File

@@ -0,0 +1,194 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:nearle_pos/core/config/sync_config.dart';
import 'package:nearle_pos/data/local/terminal_identity.dart';
import 'package:nearle_pos/data/remote/order_transport.dart';
import 'package:nearle_pos/data/sync/health_reporter.dart';
import 'package:nearle_pos/data/sync/sync_engine.dart';
import 'package:nearle_pos/domain/entities/shift_report.dart';
import 'package:nearle_pos/domain/repositories/sync_repository.dart';
/// The failure these cover reached production and stayed invisible for a day.
///
/// The reporter was typed against the broker transport and started behind an
/// `is MqttOrderTransport` check, so a shop configured for the HTTP route
/// uploaded 17 bills perfectly and never once appeared on the fleet board.
/// Nothing logged it, because from the terminal's point of view nothing had
/// failed — the feature simply did not exist on that route.
///
/// There was no test here at all. That is why it shipped.
void main() {
group('a heartbeat is sent on any route that can carry one', () {
test('publishes over a transport that is not the broker', () async {
final transport = _RecordingTransport();
final reporter = _reporter(transport);
await reporter.publish();
expect(
transport.beats,
hasLength(1),
reason: 'an HTTP terminal must still report its health',
);
final beat = jsonDecode(transport.beats.single) as Map<String, Object?>;
expect(beat['terminal_id'], 'T5EDD');
expect(beat['location_id'], '1135');
expect(beat['status'], 'online');
reporter.dispose();
});
test('carries the queue depth that makes a silent failure visible',
() async {
// The number the board exists for. A till that is connected, selling and
// quietly accumulating unsent bills looks completely healthy from the
// shop floor.
final transport = _RecordingTransport();
final reporter = _reporter(transport, pending: 213);
await reporter.publish();
final beat = jsonDecode(transport.beats.single) as Map<String, Object?>;
expect(beat['pending_bills'], 213);
expect(beat['today_bills'], 17);
expect(beat['today_amount'], 2510.0);
reporter.dispose();
});
test('names the route it is using', () async {
// So a support call can tell an HTTP shop from a broker one without
// asking anybody to read a settings screen aloud.
final transport = _RecordingTransport();
final reporter = _reporter(transport, transportKind: TransportKind.http);
await reporter.publish();
final beat = jsonDecode(transport.beats.single) as Map<String, Object?>;
expect(beat['transport'], 'http');
reporter.dispose();
});
test('says nothing while the route is down', () async {
// Not an error — there is nowhere to send it. The board ages the till off
// by itself when the beats stop.
final transport = _RecordingTransport(connected: false);
final reporter = _reporter(transport);
await reporter.publish();
expect(transport.beats, isEmpty);
reporter.dispose();
});
test('a transport that throws does not stop the till', () async {
// The whole reason every failure here is swallowed. Halting a shop
// because a dashboard was unreachable would be a self-inflicted outage.
final transport = _ThrowingTransport();
final reporter = _reporter(transport);
await expectLater(reporter.publish(), completes);
reporter.dispose();
});
});
}
HealthReporter _reporter(
OrderTransport transport, {
int pending = 0,
TransportKind transportKind = TransportKind.http,
}) =>
HealthReporter(
transport: transport,
terminal: const TerminalIdentity(
deviceId: 'device-1',
code: 'T5EDD',
name: 'Counter 1',
storeId: '1135',
),
config: SyncConfig(
transport: transportKind,
storeId: '1135',
terminalId: 'T5EDD',
httpBaseUrl: 'https://example.invalid/api/v1/pos',
),
engine: _StubEngine(pending: pending),
repository: _StubRepository(),
appVersion: '1.1.0',
);
/// A [SyncEngine] whose state the test dictates.
class _StubEngine implements SyncEngine {
_StubEngine({required this.pending});
final int pending;
@override
SyncEngineState get state => SyncEngineState(pending: pending);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _StubRepository implements SyncRepository {
@override
Future<int> unsyncedCustomerCount() async => 0;
@override
Future<List<OrderSyncRow>> orderSyncRows({int limit = 200}) async => const [];
@override
Future<ShiftReport> todayReport({
required String terminalId,
required String cashierName,
bool scopeToCashier = false,
}) async =>
ShiftReport(
businessDate: DateTime(2026, 8, 5),
terminalId: terminalId,
cashierName: cashierName,
billCount: 17,
itemCount: 21,
grossSales: 2510,
taxCollected: 265.06,
discountGiven: 0,
roundOff: 0,
paymentBreakdown: const {},
loyaltyPointsIssued: 0,
loyaltyPointsRedeemed: 0,
);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _RecordingTransport implements OrderTransport {
_RecordingTransport({this.connected = true});
final bool connected;
final List<String> beats = [];
@override
bool get isConnected => connected;
@override
Future<void> publishHealth(String payload) async => beats.add(payload);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _ThrowingTransport implements OrderTransport {
@override
bool get isConnected => true;
@override
Future<void> publishHealth(String payload) async =>
throw Exception('the back office is down');
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}

View File

@@ -130,6 +130,19 @@ void main() {
'synced_bills': 12,
});
await db.insert('app_meta', {'key': 'invoice_sequence', 'value': '12'});
// A shopper registered before the customer outbox existed. Whether they
// reach the back office at all depends on what v8 does with this row.
await db.insert('customers', {
'id': 'legacy-customer-1',
'name': 'Meena',
'mobile': '9840011111',
'loyalty_points': 40,
'lifetime_spend': 2400.0,
'visit_count': 3,
'created_at': 1000,
});
await db.close();
}
@@ -139,7 +152,7 @@ void main() {
await AppDatabase.instance.open(overridePath: dbPath);
final db = AppDatabase.instance.db;
expect(await db.getVersion(), 7);
expect(await db.getVersion(), 8);
final rows = await db.query('day_archive');
expect(rows, hasLength(1));
@@ -159,6 +172,19 @@ void main() {
// not handed promotions it never created.
final promos = await db.query('promos');
expect(promos, isEmpty);
// v8 turns customers into an outbox. Every existing shopper is queued
// rather than assumed sent: the terminal cannot tell which rows came down
// in a catalogue pull and which were registered at the till, and only one
// of those two mistakes loses somebody. Re-sending is safe because the
// uplink is insert-if-absent on id.
final customer = (await db.query('customers')).single;
expect(customer['sync_status'], 0);
expect(customer['synced_at'], isNull);
// …and their loyalty standing survives the migration untouched.
expect(customer['loyalty_points'], 40);
expect(customer['lifetime_spend'], 2400.0);
expect(row['bill_count'], 12);
expect(row['gross_sales'], 8450.0);
expect(row['tax_collected'], 620.5);

View File

@@ -0,0 +1,288 @@
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/remote/pos_auth_api.dart';
import 'package:nearle_pos/domain/entities/pos_session.dart';
/// Sign-in used to be two constants compiled into the app, compared after a
/// fake 600ms delay. The store id came from a field in Settings, so a till
/// named its own outlet and was believed — one number changed on one screen
/// moved a terminal into another tenant's books.
///
/// These cover the replacement: the outlet arrives *from* the back office, and
/// everything the till does with that answer.
void main() {
group('a session read off the wire', () {
test('takes its outlet from the back office, not from the till', () {
final session = PosSession.fromJson({
'token': 'abc.def',
'expires_at': '2026-09-05T10:00:00Z',
'user_id': 1229,
'full_name': 'Selvapuram',
'tenant_id': 1087,
'tenant_name': 'Ragul Stores',
'store_id': '1135',
'location_id': 1135,
'location_name': 'Ragul stores Selvapuram',
});
expect(session.storeId, '1135');
expect(session.locationId, 1135);
expect(session.tenantId, 1087);
});
test('reads an id whether it arrives quoted or bare', () {
// The backend sends `location_id` as a number and `store_id` as a string
// for the same value. A till that accepted only one shape would read zero
// for the other — which looks like "no outlet" rather than like a bug.
final quoted = PosSession.fromJson({
'location_id': '1135',
'token': 't',
'expires_at': '2026-09-05T10:00:00Z',
});
final bare = PosSession.fromJson({
'location_id': 1135,
'token': 't',
'expires_at': '2026-09-05T10:00:00Z',
});
expect(quoted.locationId, 1135);
expect(bare.locationId, 1135);
});
test('falls back to the location id when no store id is sent', () {
final session = PosSession.fromJson({
'token': 't',
'expires_at': '2026-09-05T10:00:00Z',
'location_id': 1135,
});
expect(session.storeId, '1135');
});
test('an unreadable expiry counts as already finished', () {
// Guessing "valid" here would keep a till sending a token the server
// stopped honouring hours ago, and reading the resulting refusals as a
// server fault.
final session = PosSession.fromJson({
'token': 't',
'location_id': 1135,
'expires_at': 'not a date',
});
expect(session.isValidAt(DateTime.now()), isFalse);
});
test('survives a round trip through storage', () {
final original = PosSession.fromJson({
'token': 'abc.def',
'expires_at': '2026-09-05T10:00:00Z',
'user_id': 1305,
'full_name': 'Gokul R',
'email': 'raguladmin@example.test',
'tenant_id': 1087,
'tenant_name': 'Ragul Stores',
'store_id': '1097',
'location_id': 1097,
'location_name': 'Ragul stores',
'gstin': '33AABCU9603R1ZM',
'locations': [
{'location_id': 1097, 'location_name': 'Ragul stores'},
{'location_id': 1135, 'location_name': 'Ragul stores Selvapuram'},
],
});
final restored = PosSession.fromJson(
jsonDecode(jsonEncode(original.toJson())) as Map<String, Object?>,
);
expect(restored.token, original.token);
expect(restored.locationId, original.locationId);
expect(restored.gstin, original.gstin);
expect(restored.outlets.length, 2);
expect(restored.expiresAt.toUtc(), original.expiresAt.toUtc());
});
test('offers a choice only when there is one', () {
final single = PosSession.fromJson({
'token': 't',
'expires_at': '2026-09-05T10:00:00Z',
'location_id': 1135,
'locations': [
{'location_id': 1135, 'location_name': 'Selvapuram'},
],
});
final several = PosSession.fromJson({
'token': 't',
'expires_at': '2026-09-05T10:00:00Z',
'location_id': 1097,
'locations': [
{'location_id': 1097, 'location_name': 'Ragul stores'},
{'location_id': 1135, 'location_name': 'Selvapuram'},
],
});
expect(single.hasChoiceOfOutlet, isFalse);
expect(several.hasChoiceOfOutlet, isTrue);
});
});
group('the session is what authenticates a request', () {
test('takes precedence over a static api key', () {
// The key says "this 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.
const config = SyncConfig(
apiKey: 'fleet-wide-key',
sessionToken: 'per-user-session',
);
expect(config.bearerToken, 'per-user-session');
});
test('falls back to the api key before a terminal has signed in', () {
const config = SyncConfig(apiKey: 'fleet-wide-key');
expect(config.bearerToken, 'fleet-wide-key');
});
test('an emptied session does not authenticate as itself', () {
// Sign-out clears the token by writing an empty string rather than by
// rebuilding the config. If that read as a credential, a signed-out till
// would keep uploading as the shop that signed in this morning.
const config = SyncConfig(sessionToken: '', apiKey: '');
expect(config.bearerToken, isNull);
});
});
group('signing in against the back office', () {
PosAuthApi apiReturning(int status, Object body) => PosAuthApi(
baseUrl: 'https://example.invalid/pos',
client: MockClient(
(_) async => http.Response(jsonEncode(body), status,
headers: {'content-type': 'application/json'},),
),
);
test('returns the outlet the back office named', () async {
final api = apiReturning(200, {
'code': 200,
'status': true,
'details': {
'token': 'abc.def',
'expires_at': '2026-09-05T10:00:00Z',
'location_id': 1135,
'store_id': '1135',
'location_name': 'Ragul stores Selvapuram',
},
});
final session = await api.login(authname: 'a@b.test', password: 'pw');
expect(session.storeId, '1135');
expect(session.token, 'abc.def');
});
test('a wrong password is reported as one worth re-typing', () async {
final api = apiReturning(401, {
'code': 401,
'status': false,
'message': 'those sign-in details were not recognised',
});
await expectLater(
api.login(authname: 'a@b.test', password: 'wrong'),
throwsA(
isA<PosAuthException>()
.having((e) => e.isCredentialFailure, 'credential failure', true),
),
);
});
test('a refused outlet is not reported as a wrong password', () async {
// 403 is a real account that may not open this till. Telling someone to
// re-type a password that was correct sends them round a loop.
final api = apiReturning(403, {
'code': 403,
'status': false,
'message': 'this account cannot open a till at outlet 1185',
});
await expectLater(
api.login(authname: 'a@b.test', password: 'pw'),
throwsA(
isA<PosAuthException>()
.having((e) => e.isCredentialFailure, 'credential failure', false),
),
);
});
test('a session with no token is refused rather than saved', () async {
// Saving it would fail against every later request instead of here, which
// is much harder to diagnose from a shop floor.
final api = apiReturning(200, {
'code': 200,
'status': true,
'details': {'location_id': 1135, 'expires_at': '2026-09-05T10:00:00Z'},
});
await expectLater(
api.login(authname: 'a@b.test', password: 'pw'),
throwsA(isA<PosAuthException>()),
);
});
test('a session naming no outlet is refused', () async {
final api = apiReturning(200, {
'code': 200,
'status': true,
'details': {'token': 'abc.def', 'expires_at': '2026-09-05T10:00:00Z'},
});
await expectLater(
api.login(authname: 'a@b.test', password: 'pw'),
throwsA(isA<PosAuthException>()),
);
});
test('an unconfigured terminal says so instead of failing obscurely',
() async {
final api = PosAuthApi(baseUrl: '');
await expectLater(
api.login(authname: 'a@b.test', password: 'pw'),
throwsA(
isA<PosAuthException>().having(
(e) => e.message,
'message',
contains('no back office configured'),
),
),
);
});
});
}
/// A client answering with one canned response.
///
/// Hand-rolled rather than pulled from `http/testing.dart` so the test suite
/// does not gain a dependency for four lines.
class MockClient extends http.BaseClient {
MockClient(this._handler);
final Future<http.Response> Function(http.BaseRequest) _handler;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final response = await _handler(request);
return http.StreamedResponse(
Stream.value(response.bodyBytes),
response.statusCode,
headers: response.headers,
);
}
}

View File

@@ -16,6 +16,15 @@ import 'package:nearle_pos/domain/usecases/checkout_sale.dart';
/// A transport whose answer each call is dictated by the test.
class _ScriptedTransport 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 = [];
_ScriptedTransport(this.answer);
/// Given the ids in a batch, returns what the back office says about them.
@@ -44,6 +53,16 @@ class _ScriptedTransport implements OrderTransport {
return answer(orders.map((o) => o['id']! as String).toList());
}
/// Registrations are not what these tests are about; accepting them keeps
/// the drain from stalling before it reaches the bills.
@override
Future<PushReceipt> pushCustomers(
List<Map<String, Object?>> customers,
) async =>
PushReceipt(
accepted: customers.map((c) => c['id']! as String).toList(),
);
@override
Future<void> dispose() async {}
}

View File

@@ -0,0 +1,143 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:nearle_pos/data/datasources/local_store.dart';
import 'package:nearle_pos/data/local/staff_dao.dart';
import 'package:nearle_pos/domain/entities/store_account.dart';
/// The till shipped with three names and three PINs compiled into it —
/// Suriya/4821, Divya/5093, Rahul/6274 — identical on every install and
/// readable by anyone with the APK. They existed because a shop with nothing in
/// its back office still has to trade on day one, and that is still true: only
/// 116 of 596 accounts on the platform have a PIN, and the outlet this build
/// ships pointed at has none at all.
///
/// So they stay, as a last resort, and these cover the thing that makes that
/// safe: real staff must *retire* them rather than sit alongside them.
StaffDao get staff => LocalStore.instance.staff;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
await LocalStore.instance.reset();
});
test('a fresh till has the seeded accounts and they work', () async {
final seeded = await staff.all();
expect(seeded, hasLength(3));
expect(await staff.authenticate('4821'), isNotNull);
// Every one is flagged, so the first person in is made to change it.
expect(seeded.every((s) => s.mustChangePin), isTrue);
});
test('real staff retire the built-in PINs', () async {
// The whole point. Without this the hardcoded logins would survive next to
// the real ones for ever, on every terminal in the fleet.
await staff.replaceFromBackOffice(const [
StaffImportRecord(
localId: 'boffice-1229',
name: 'Selvapuram',
role: StaffRole.manager,
pin: '7391',
),
]);
expect(await staff.authenticate('7391'), isNotNull,
reason: 'the imported account must work',);
expect(await staff.authenticate('4821'), isNull,
reason: 'Suriya was compiled into the app and must be gone',);
expect(await staff.authenticate('5093'), isNull);
expect(await staff.authenticate('6274'), isNull);
});
test('an empty answer leaves a working till alone', () async {
// The common case: most outlets have nobody recorded. Wiping the logins
// because the back office has not been filled in yet would close a shop.
final written = await staff.replaceFromBackOffice(const []);
expect(written, 0);
expect(await staff.authenticate('4821'), isNotNull);
});
test('a leaver loses the till on the next sign-in', () async {
await staff.replaceFromBackOffice(const [
StaffImportRecord(
localId: 'boffice-1', name: 'Asha', role: StaffRole.cashier, pin: '7391',),
StaffImportRecord(
localId: 'boffice-2', name: 'Ravi', role: StaffRole.cashier, pin: '8402',),
]);
expect(await staff.authenticate('8402'), isNotNull);
// Ravi is removed in the back office.
await staff.replaceFromBackOffice(const [
StaffImportRecord(
localId: 'boffice-1', name: 'Asha', role: StaffRole.cashier, pin: '7391',),
]);
expect(await staff.authenticate('7391'), isNotNull);
expect(await staff.authenticate('8402'), isNull);
});
test('re-syncing the same person updates rather than duplicates', () async {
// Keyed on the back office user id, so a shop that changes somebody's PIN
// gets one account with a new PIN, not two accounts with one each.
await staff.replaceFromBackOffice(const [
StaffImportRecord(
localId: 'boffice-1', name: 'Asha', role: StaffRole.cashier, pin: '7391',),
]);
await staff.replaceFromBackOffice(const [
StaffImportRecord(
localId: 'boffice-1', name: 'Asha Kumar', role: StaffRole.manager, pin: '8402',),
]);
final all = await staff.all();
expect(all, hasLength(1));
expect(all.single.name, 'Asha Kumar');
expect(all.single.role, StaffRole.manager);
expect(await staff.authenticate('8402'), isNotNull);
expect(await staff.authenticate('7391'), isNull);
});
test('an imported PIN is not flagged for change', () async {
// It was set by the shop in the back office, so it is already theirs. The
// flag is for the seeds, which everyone shares.
await staff.replaceFromBackOffice(const [
StaffImportRecord(
localId: 'boffice-1', name: 'Asha', role: StaffRole.cashier, pin: '7391',),
]);
final imported = await staff.authenticate('7391');
expect(imported!.mustChangePin, isFalse);
});
test('an unusable PIN is skipped rather than written', () async {
// A name on screen that nobody can sign in as reads as a broken terminal.
// `pin = 0` is the single most common value in app_users.
await staff.replaceFromBackOffice(const [
StaffImportRecord(
localId: 'boffice-1', name: 'No PIN', role: StaffRole.cashier, pin: '0',),
StaffImportRecord(
localId: 'boffice-2', name: 'Blank', role: StaffRole.cashier, pin: '',),
StaffImportRecord(
localId: 'boffice-3', name: 'Usable', role: StaffRole.cashier, pin: '7391',),
]);
final all = await staff.all();
expect(all, hasLength(1));
expect(all.single.name, 'Usable');
});
test('an answer of nothing usable does not strand the till', () async {
// Every row unusable is not the same as a deliberate empty list, but it has
// to behave the same way — otherwise a back office full of `pin = 0` rows
// would deactivate the seeds and leave nobody able to sign in.
final written = await staff.replaceFromBackOffice(const [
StaffImportRecord(
localId: 'boffice-1', name: 'No PIN', role: StaffRole.cashier, pin: '0',),
]);
expect(written, 0);
expect(await staff.authenticate('4821'), isNotNull,
reason: 'the seeds must survive an import that wrote nobody',);
});
}

View File

@@ -39,6 +39,23 @@ class _StubRepository implements SyncRepository {
@override
Future<int> unsyncedCount() async => pending;
/// Counted so a test can prove registrations go up before bills do.
int customerSyncs = 0;
/// Set to make the registration pass throw, proving it cannot hold up the
/// bills behind it.
bool customerSyncThrows = false;
@override
Future<int> unsyncedCustomerCount() async => 0;
@override
Future<SyncOutcome> syncCustomers() async {
customerSyncs++;
if (customerSyncThrows) throw Exception('registration upload failed');
return const SyncOutcome(attempted: 0, uploaded: 0);
}
@override
Future<int> purgeExpired() async => 0;
@@ -308,6 +325,10 @@ void main() {
final engine = build(connectivity: connectivity.stream);
await engine.start();
// start() fires a drain of its own. Let it finish before taking the
// baseline, so this measures what connectivity did rather than how many
// awaits happen to sit in front of the repository call.
await _settle();
final atStart = repo.calls;
connectivity.add(false);
@@ -385,6 +406,34 @@ void main() {
});
});
group('registrations', () {
test('go up before the bills that might refer to them', () async {
final engine = build();
engine.nudge(SyncTrigger.saleCommitted);
await _settle();
expect(repo.customerSyncs, 1);
expect(repo.calls, 1);
});
test('failing to upload one cannot strand a day of takings', () async {
// A shopper waiting to go up must never be the reason money stays on the
// terminal. The registration pass is allowed to fail on its own.
repo.customerSyncThrows = true;
final engine = build();
engine.nudge(SyncTrigger.saleCommitted);
await _settle();
expect(repo.calls, 1, reason: 'the bills still went');
expect(engine.state.consecutiveFailures, 0);
expect(engine.state.isHalted, isFalse);
await engine.dispose();
});
});
test('a repository that throws is treated as a retryable failure, not a crash',
() async {
// Anything escaping the repository is a defect. The engine must still empty

View File

@@ -0,0 +1,219 @@
import 'package:flutter_test/flutter_test.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/promo.dart';
/// Where a discount lands decides which GST slab it comes out of.
///
/// The bill total is the same either way, which is what makes getting this
/// wrong so easy to ship: the shopper pays the right money, the receipt looks
/// right, and only the slab split on a filed return is off.
void main() {
/// 5% slab — the everyday grocery rate.
Product atta({double price = 100}) => Product(
id: 'atta',
name: 'Atta 5kg',
barcode: 'bc-atta',
sku: 'sku-atta',
category: ProductCategory.grocery,
price: price,
stock: 100,
gstRate: 0.05,
);
/// 18% slab.
Product cola({double price = 100}) => Product(
id: 'cola',
name: 'Cola 2L',
barcode: 'bc-cola',
sku: 'sku-cola',
category: ProductCategory.beverages,
price: price,
stock: 100,
gstRate: 0.18,
);
Cart cartOf(
List<({Product product, double qty})> items, {
List<AppliedPromo> promos = const [],
Discount billDiscount = Discount.none,
Customer? customer,
int pointsRedeemed = 0,
}) =>
Cart(
lines: [
for (final i in items) CartLine(product: i.product, quantity: i.qty),
],
appliedPromos: promos,
billDiscount: billDiscount,
customer: customer,
pointsRedeemed: pointsRedeemed,
);
/// GST inside [amount] at [rate].
double taxInside(double amount, double rate) => amount - amount / (1 + rate);
/// Slabs are reconciled against the bill total before being returned, so the
/// largest one absorbs up to a paisa of rounding residue. That is deliberate
/// — a tax invoice cannot show parts that miss their own total — so slab
/// assertions allow it, and the exact reconciliation is asserted separately.
Matcher isPaise(double expected) => closeTo(expected, 0.011);
group('a targeted campaign only reduces the lines it names', () {
test('a category promo leaves the other slab untouched', () {
// ₹100 of atta at 5% and ₹100 of cola at 18%. "50% off Beverages" takes
// ₹50, and every rupee of it must come out of the cola line.
final cart = cartOf(
[(product: atta(), qty: 1), (product: cola(), qty: 1)],
promos: const [
AppliedPromo(
promo: Promo(
id: 'bev',
name: 'Half off beverages',
type: PromoType.percentOffCategory,
value: 50,
targetId: 'beverages',
),
amount: 50,
),
],
);
expect(cart.netAmount, 150);
final slabs = cart.taxBreakdown;
// Atta was not discounted, so its slab is exactly what it always was.
expect(slabs[0.05], isPaise(taxInside(100, 0.05)));
// Cola carried the whole ₹50.
expect(slabs[0.18], isPaise(taxInside(50, 0.18)));
// The old pro-rata split would have moved tax off the atta line to
// subsidise a campaign it never qualified for.
expect(slabs[0.05], isNot(isPaise(taxInside(75, 0.05))));
});
test('a product promo behaves the same way', () {
final cart = cartOf(
[(product: atta(), qty: 1), (product: cola(), qty: 1)],
promos: const [
AppliedPromo(
promo: Promo(
id: 'cola-20',
name: '20% off cola',
type: PromoType.percentOffProduct,
value: 20,
targetId: 'cola',
),
amount: 20,
),
],
);
expect(cart.taxBreakdown[0.05], isPaise(taxInside(100, 0.05)));
expect(cart.taxBreakdown[0.18], isPaise(taxInside(80, 0.18)));
});
});
group('a bill-wide reduction still spreads across everything', () {
test('a manual discount is shared pro rata', () {
// Nothing here names a line, so both slabs give up the same proportion.
// This is the behaviour that was already right and must stay right.
final cart = cartOf(
[(product: atta(), qty: 1), (product: cola(), qty: 1)],
billDiscount: const Discount(type: DiscountType.percentage, value: 10),
);
expect(cart.netAmount, 180);
expect(cart.taxBreakdown[0.05], isPaise(taxInside(90, 0.05)));
expect(cart.taxBreakdown[0.18], isPaise(taxInside(90, 0.18)));
});
test('points redeemed come off every line', () {
final cart = cartOf(
[(product: atta(), qty: 1), (product: cola(), qty: 1)],
customer: const Customer(id: 'c1', name: 'A', mobile: '9840000000'),
pointsRedeemed: 0,
);
// Baseline with no reduction at all: each line keeps its own tax.
expect(cart.taxBreakdown[0.05], isPaise(taxInside(100, 0.05)));
expect(cart.taxBreakdown[0.18], isPaise(taxInside(100, 0.18)));
});
});
group('the parts always add up to the whole', () {
test('slabs reconcile to the bill tax with a targeted promo', () {
final cart = cartOf(
[(product: atta(price: 137), qty: 3), (product: cola(price: 89), qty: 2)],
promos: const [
AppliedPromo(
promo: Promo(
id: 'bev',
name: '15% off beverages',
type: PromoType.percentOffCategory,
value: 15,
targetId: 'beverages',
),
amount: 26.7,
),
],
billDiscount: const Discount(type: DiscountType.flat, value: 40),
);
final slabSum = cart.taxBreakdown.values.fold(0.0, (a, b) => a + b);
expect(slabSum.toStringAsFixed(2), cart.taxAmount.toStringAsFixed(2));
// And the tax still sits inside the money actually collected.
expect(
(cart.taxableAmount + cart.taxAmount).toStringAsFixed(2),
cart.netAmount.toStringAsFixed(2),
);
});
test('a campaign that clears a line cannot drive its tax negative', () {
// 100% off beverages, then a bill discount on top. The cola line has
// nothing left to give, so the manual discount has to fall entirely on
// the atta line rather than pushing cola below zero.
final cart = cartOf(
[(product: atta(), qty: 1), (product: cola(), qty: 1)],
promos: const [
AppliedPromo(
promo: Promo(
id: 'free',
name: 'Free beverages',
type: PromoType.percentOffCategory,
value: 100,
targetId: 'beverages',
),
amount: 100,
),
],
billDiscount: const Discount(type: DiscountType.flat, value: 50),
);
expect(cart.netAmount, 50);
for (final slab in cart.taxBreakdown.values) {
expect(slab, greaterThanOrEqualTo(0));
}
expect(cart.taxAmount, greaterThanOrEqualTo(0));
// Everything left on the bill is atta, so all the tax is at 5%.
expect(cart.taxBreakdown[0.05], isPaise(taxInside(50, 0.05)));
expect(cart.taxBreakdown[0.18] ?? 0, 0);
});
test('discounts exceeding the bill leave nothing taxable', () {
final cart = cartOf(
[(product: cola(), qty: 1)],
billDiscount: const Discount(type: DiscountType.flat, value: 500),
);
expect(cart.netAmount, 0);
expect(cart.taxAmount, 0);
expect(cart.taxableAmount, 0);
});
});
}

View File

@@ -0,0 +1,76 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:nearle_pos/core/utils/formatters.dart';
/// The fault these cover was found in live data, not in a test.
///
/// Bill INV-2608-T5EDD-00116 carried `billedat 2026-08-05T12:49:28.245Z` beside
/// `receivedat 2026-08-05T07:19:28.586Z` — the bill appearing to have been rung
/// five and a half hours *after* the back office received it. Exactly the IST
/// offset, every time.
///
/// Nothing was lying. `DateTime.toIso8601String()` on a local time emits no
/// zone marker at all, Go's `time.Parse` fills that silence with UTC, and the
/// till's wall clock was recorded as though it had been read in London.
void main() {
group('a timestamp says which zone it was read in', () {
test('carries an explicit offset', () {
final iso = Formatters.isoWithOffset(DateTime(2026, 8, 5, 12, 49, 28));
expect(
iso,
matches(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.*[+-]\d{2}:\d{2}$'),
reason: 'without an offset the receiver has to guess, and guesses UTC',
);
});
test('round-trips back to the same instant', () {
// The whole point. The old form parsed to a different moment than the one
// the cashier rang the bill at.
final rung = DateTime(2026, 8, 5, 12, 49, 28, 245);
final parsed = DateTime.parse(Formatters.isoWithOffset(rung));
expect(
parsed.toUtc(),
rung.toUtc(),
reason: 'the instant must survive the trip to the back office',
);
});
test('keeps the wall clock the till actually showed', () {
// Load-bearing for businessdate. The back office derives a day's takings
// from the wall clock, so a bill rung at 12:49 must still read 12:49 —
// converting to UTC before sending would have moved it to 07:19 and put
// late-evening sales on the previous day.
final iso = Formatters.isoWithOffset(DateTime(2026, 8, 5, 12, 49, 28));
expect(iso, startsWith('2026-08-05T12:49:28'));
});
test('does not drop a half-hour offset', () {
// India is +05:30. An implementation that formatted only whole hours
// would produce +05:00 here and be wrong by thirty minutes — the kind of
// error that survives review because it looks almost right.
final offset = DateTime.now().timeZoneOffset;
final iso = Formatters.isoWithOffset(DateTime(2026, 8, 5, 12, 0));
final minutes = offset.abs().inMinutes % 60;
expect(
iso.substring(iso.length - 2),
minutes.toString().padLeft(2, '0'),
reason: 'the minutes component must come from the real offset',
);
});
test('a UTC input is converted, not relabelled', () {
// Passing an already-UTC DateTime must not stamp it with the local
// offset while leaving the UTC wall clock in place — that would recreate
// the original bug in reverse.
final instant = DateTime.utc(2026, 8, 5, 7, 19, 28);
final parsed = DateTime.parse(Formatters.isoWithOffset(instant));
expect(parsed.toUtc(), instant);
});
});
}

View File

@@ -29,10 +29,11 @@ void main() {
test('topics are namespaced per store and per terminal', () {
// Two stores sharing one broker must never see each other's bills.
expect(config.orderTopic, 'pos/store-9/TERM-04/order');
expect(config.ackTopic, 'pos/store-9/TERM-04/ack');
expect(config.statusTopic, 'pos/store-9/TERM-04/status');
expect(config.catalogueTopic, 'pos/store-9/catalogue');
expect(config.orderTopic, 'nearle/pos/store-9/TERM-04/order');
expect(config.ackTopic, 'nearle/pos/store-9/TERM-04/ack');
expect(config.statusTopic, 'nearle/pos/store-9/TERM-04/status');
expect(config.healthTopic, 'nearle/pos/store-9/TERM-04/health');
expect(config.catalogueTopic, 'nearle/pos/store-9/catalogue');
});
test('an ack naming only some ids accepts only those', () async {

View File

@@ -181,7 +181,17 @@ void main() {
/// Holds a fixed session so a test can choose who is signed in.
class _StubAuth extends AuthController {
_StubAuth(StoreAccount store, StaffUser user) : super(_throwingRef) {
state = Authenticated(store: store, user: user);
// The shell a session opens is decided by the back office, not by the
// person at the counter — so a stub has to state it too. Taken from the
// user's role here purely so these tests keep reading as "signed in as the
// admin" / "signed in as the cashier".
state = Authenticated(
store: store,
user: user,
login: user.role == StaffRole.cashier
? TerminalLogin.cashier
: TerminalLogin.admin,
);
}
@override

View File

@@ -5,10 +5,11 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:nearle_pos/app/app.dart';
import 'package:nearle_pos/data/datasources/local_store.dart';
import 'package:nearle_pos/data/datasources/seed_data.dart';
import 'package:nearle_pos/data/remote/pos_auth_api.dart';
import 'package:nearle_pos/domain/entities/pos_session.dart';
import 'package:nearle_pos/app/providers.dart';
import 'package:nearle_pos/domain/entities/shift_report.dart';
import 'package:nearle_pos/domain/entities/store_account.dart';
import 'package:nearle_pos/presentation/auth/providers/auth_controller.dart';
import 'package:nearle_pos/presentation/pos/providers/cart_controller.dart';
import 'package:nearle_pos/presentation/pos/screens/pos_dashboard_screen.dart';
import 'package:nearle_pos/presentation/sync/providers/sync_controller.dart';
@@ -35,7 +36,7 @@ void main() {
const testStore = StoreAccount(
id: 'store-001',
name: 'Nearle Daily',
email: DemoCredentials.email,
email: 'manager@ragulstores.test',
address: '1 Test Street',
gstin: '33AABCU9603R1ZM',
phone: '9840000000',
@@ -50,7 +51,7 @@ void main() {
cashierName: 'Suriya',
);
Future<void> bootApp(WidgetTester tester) async {
Future<void> bootApp(WidgetTester tester, {bool supervisor = true}) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
@@ -65,6 +66,14 @@ void main() {
// fail on a screen that never arrived.
storeAccountProvider.overrideWith((ref) async => testStore),
// Sign-in is a network call now — a person's own back-office account
// rather than two constants compiled into the build. A widget test
// must not depend on a live endpoint, so the client is swapped for
// one that answers with a fixed session.
posAuthApiProvider.overrideWithValue(
_FakePosAuthApi(canManageStaff: supervisor),
),
// Catalogue reads come from the in-memory cache and resolve on the
// spot, but these four go to SQLite. Real disk I/O cannot be driven
// by the fake clock a widget test runs on: sqflite's own lock-warning
@@ -96,11 +105,20 @@ void main() {
Future<void> signIn(WidgetTester tester) async {
final fields = find.byType(TextFormField);
await tester.enterText(fields.first, DemoCredentials.email);
await tester.enterText(fields.at(1), DemoCredentials.password);
await tester.enterText(fields.first, _testEmail);
await tester.enterText(fields.at(1), _testPassword);
await tester.pump();
await tester.tap(find.text('Sign in').last);
// Sign-in reaches SQLite now: it writes the outlet the back office named
// and the store details a receipt is legally required to carry, before the
// shell opens. Real disk I/O cannot complete on a widget test's fake clock,
// so the tap runs inside runAsync — pumping alone leaves the sign-in
// suspended for ever and every later assertion fails on a screen that never
// arrived.
await tester.runAsync(() async {
await tester.tap(find.text('Sign in').last);
await Future<void>.delayed(const Duration(milliseconds: 200));
});
await settle(tester);
}
@@ -151,10 +169,49 @@ void main() {
await tester.tap(target.first);
await settle(tester);
// Settings builds a printer-settings controller that reads six values
// out of SQLite, and sqflite arms a ten-second lock-warning timer around
// each. Those reads cannot complete on a fake clock, so the timer would
// still be pending at teardown and the binding would fail the test for
// that rather than for anything it is about. Pumping past the ten
// seconds lets the timer fire and clear.
await tester.pump(const Duration(seconds: 11));
expect(tester.takeException(), isNull, reason: 'opening "$label" threw');
}
});
testWidgets('a cashier session gets billing and nothing else',
(tester) async {
// The other half of the role split, and the half worth pinning: the shell
// a person gets is decided by the back office, not by which tab they
// picked on the way in. Same credentials, same screen size, same boot —
// the only difference is `can_manage_staff` on the session, and the
// back-office modules have to be unreachable because of it.
tester.view.physicalSize = const Size(1800, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await bootApp(tester, supervisor: false);
await signIn(tester);
// Signed in, and on the billing screen.
expect(find.byType(PosDashboardScreen), findsOneWidget);
// These labels exist only in the sidebar, so their absence is the whole
// claim: a cashier cannot reach the catalogue, the promos or the
// terminal's configuration.
for (final label in ['Product Import', 'Promo', 'Settings']) {
expect(
find.text(label),
findsNothing,
reason: 'a cashier must not be offered "$label"',
);
}
expect(tester.takeException(), isNull);
});
testWidgets('the back office connection dialog opens and validates',
(tester) async {
// The only way a shop can point a till at a broker. Until it existed a
@@ -169,6 +226,9 @@ void main() {
await tester.tap(find.text('Settings').first);
await settle(tester);
// Clears sqflite's lock-warning timer — see the module loop above.
await tester.pump(const Duration(seconds: 11));
await tester.tap(find.text('Configure').first);
await settle(tester);
@@ -191,3 +251,59 @@ void main() {
expect(tester.takeException(), isNull);
});
}
const _testEmail = 'manager@ragulstores.test';
const _testPassword = 'correct-horse';
/// A back office that accepts one account and refuses everything else.
///
/// Subclasses rather than reimplements an interface because the real client is
/// concrete — and answering a wrong password correctly matters here: the login
/// screen's failure path is part of what these tests cover.
class _FakePosAuthApi extends PosAuthApi {
_FakePosAuthApi({this.canManageStaff = true})
: super(baseUrl: 'https://example.invalid/pos');
/// Which shell the back office says this account gets. A supervisor by
/// default, because most of these tests are about the full shell rendering.
final bool canManageStaff;
@override
Future<PosSession> login({
required String authname,
required String password,
String? terminalId,
String? deviceId,
int? locationId,
int? configId,
}) async {
if (authname.trim() != _testEmail || password != _testPassword) {
throw const PosAuthException(
'those sign-in details were not recognised',
isCredentialFailure: true,
);
}
return PosSession(
token: 'test-session-token',
expiresAt: DateTime.now().add(const Duration(days: 30)),
userId: 1229,
fullName: 'Test Manager',
email: _testEmail,
roleId: canManageStaff ? 7 : 8,
role: canManageStaff ? 'Supervisor' : 'Cashier',
canManageStaff: canManageStaff,
tenantId: 1087,
tenantName: 'Ragul Stores',
storeId: '1135',
locationId: 1135,
locationName: 'Ragul stores Selvapuram',
outlets: const [
PosOutlet(locationId: 1135, locationName: 'Ragul stores Selvapuram'),
],
);
}
@override
void dispose() {}
}

30
test/widget_test.dart Normal file
View File

@@ -0,0 +1,30 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nearle_pos/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}