Compare commits
17 Commits
e5fc777202
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a2474ee6c | |||
| ad44402232 | |||
| 09e5e29df2 | |||
| 0988d39d8b | |||
|
|
829e5a8188 | ||
| eebd10da6d | |||
|
|
cb065a0f69 | ||
|
|
7b6cd598f0 | ||
|
|
4f9a5c3d6b | ||
|
|
b5b2047bcd | ||
|
|
908058038a | ||
|
|
353c6c1075 | ||
| 6c0266c9c7 | |||
|
|
33b4337933 | ||
|
|
09edce5dc6 | ||
|
|
fe428931ec | ||
|
|
467d5eee75 |
27
.clangd
Normal file
27
.clangd
Normal 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
|
||||
19
README.md
19
README.md
@@ -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
BIN
assets/images/bg.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
BIN
assets/images/logo.png
Normal file
BIN
assets/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
267
docs/integration-guide.md
Normal file
267
docs/integration-guide.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
66
lib/core/widgets/brand_mark.dart
Normal file
66
lib/core/widgets/brand_mark.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
83
lib/data/local/session_store.dart
Normal file
83
lib/data/local/session_store.dart
Normal 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)');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
87
lib/data/local/void_pin_store.dart
Normal file
87
lib/data/local/void_pin_store.dart
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
163
lib/data/remote/pos_auth_api.dart
Normal file
163
lib/data/remote/pos_auth_api.dart
Normal 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();
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
226
lib/data/sync/health_reporter.dart
Normal file
226
lib/data/sync/health_reporter.dart
Normal 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);
|
||||
@@ -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
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
287
lib/domain/entities/pos_session.dart
Normal file
287
lib/domain/entities/pos_session.dart
Normal 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);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
final normalised = email.trim().toLowerCase();
|
||||
|
||||
if (normalised != DemoCredentials.email) {
|
||||
state = const AuthFailure('No store is registered against that email.');
|
||||
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) {
|
||||
state = const AuthFailure(
|
||||
'This terminal has no staff accounts. Reinstall to seed them.',
|
||||
);
|
||||
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,
|
||||
try {
|
||||
final session = await _ref.read(posAuthApiProvider).login(
|
||||
authname: authname,
|
||||
password: password,
|
||||
deviceId: _ref.read(terminalIdentityProvider).deviceId,
|
||||
);
|
||||
|
||||
state = Authenticated(store: store, user: opener);
|
||||
await _open(session, persist: true);
|
||||
return true;
|
||||
} on AuthApiException catch (e) {
|
||||
state = AuthFailure(e.message);
|
||||
return false;
|
||||
} on Object catch (e, stack) {
|
||||
debugPrint('Sign-in failed: $e\n$stack');
|
||||
state = const AuthFailure(
|
||||
'Sign-in failed unexpectedly. Please try again.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-opens the session stored on this terminal, if there is a live one.
|
||||
///
|
||||
/// Called once at startup, before the first frame, so a till that was signed
|
||||
/// in when it lost power comes back up on the same shell rather than at a
|
||||
/// login screen someone has to find the credentials for.
|
||||
///
|
||||
/// Returns false — and leaves the terminal signed out — when there is no
|
||||
/// session, or the token has expired.
|
||||
Future<bool> restore() async {
|
||||
final session = await _ref.read(sessionStoreProvider).read();
|
||||
if (session == null) return false;
|
||||
|
||||
try {
|
||||
// Already on disk, so nothing to persist. Details are re-applied because
|
||||
// a shop that changed its GSTIN in the back office should not print the
|
||||
// old one just because this terminal never signed out.
|
||||
await _open(session, persist: false);
|
||||
return true;
|
||||
} on Object catch (e, stack) {
|
||||
debugPrint('Session restore failed: $e\n$stack');
|
||||
await _ref.read(sessionStoreProvider).clear();
|
||||
state = const Unauthenticated();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns a session into a live shell.
|
||||
Future<void> _open(PosSession session, {required bool persist}) async {
|
||||
if (persist) await _ref.read(sessionStoreProvider).save(session);
|
||||
|
||||
await _applyStoreDetails(session);
|
||||
|
||||
// Read directly rather than through `storeAccountProvider`: that provider
|
||||
// watches this controller, so going through it here would rebuild it in
|
||||
// the middle of the sign-in that is about to populate it. Setting the
|
||||
// state below is what refreshes it, once.
|
||||
final store = await _ref.read(storeRepositoryProvider).load(
|
||||
email: session.authname,
|
||||
);
|
||||
|
||||
state = Authenticated(
|
||||
store: store,
|
||||
// The operator is whoever the back office says signed in, not a local
|
||||
// seeded account that happens to share a role.
|
||||
user: session.user,
|
||||
login: TerminalLogin.forSession(session),
|
||||
session: session,
|
||||
);
|
||||
}
|
||||
|
||||
/// Copies the outlet's details out of the login response into this
|
||||
/// terminal's own record.
|
||||
///
|
||||
/// The name, address, GSTIN and phone are printed on every invoice, where
|
||||
/// they are a legal requirement rather than decoration — so the back office
|
||||
/// is the source of truth for them, and a correction made there reaches the
|
||||
/// till on the next sign-in. Blank fields are skipped, so a partial response
|
||||
/// never erases details that are already right.
|
||||
///
|
||||
/// Delete this method if the terminal should keep whatever was typed into
|
||||
/// Settings instead; nothing else depends on it.
|
||||
Future<void> _applyStoreDetails(PosSession session) async {
|
||||
final catalogue = _ref.read(localStoreProvider).catalogue;
|
||||
|
||||
Future<void> put(String key, String value) async {
|
||||
if (value.trim().isEmpty) return;
|
||||
await catalogue.setMeta(key, value.trim());
|
||||
}
|
||||
|
||||
await put(MetaKeys.storeName, session.locationName);
|
||||
await put(MetaKeys.storeAddress, session.address);
|
||||
await put(MetaKeys.storeGstin, session.gstin.toUpperCase());
|
||||
await put(MetaKeys.storePhone, session.phone);
|
||||
|
||||
await _followOutlet(session);
|
||||
}
|
||||
|
||||
/// Re-points the terminal at the outlet the session belongs to.
|
||||
///
|
||||
/// The outlet id namespaces every sync topic, so a till moved between shops
|
||||
/// would otherwise keep publishing its bills into the previous shop's books.
|
||||
///
|
||||
/// Goes through the identity store rather than writing the meta row alone:
|
||||
/// the in-memory [TerminalIdentity] is what `syncConfigProvider` reads, and a
|
||||
/// row on disk that nothing has re-read is a change that appears to have
|
||||
/// worked and has not.
|
||||
Future<void> _followOutlet(PosSession session) async {
|
||||
if (session.storeId.trim().isEmpty) return;
|
||||
|
||||
final local = _ref.read(localStoreProvider);
|
||||
if (local.terminal.storeId == session.storeId) return;
|
||||
|
||||
await local.identityStore.rename(storeId: session.storeId);
|
||||
local.terminal = await local.identityStore.load();
|
||||
|
||||
// Rebuilds the sync configuration, and with it the catalogue source and
|
||||
// the order transport, onto the new outlet's topics.
|
||||
_ref.invalidate(terminalIdentityProvider);
|
||||
}
|
||||
|
||||
/// Switches the active operator, checking their PIN.
|
||||
///
|
||||
/// Every bill is stamped with whoever is active, so this is the boundary that
|
||||
/// decides who a sale is attributed to — it cannot be a bare selection from a
|
||||
/// list.
|
||||
/// 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(),
|
||||
);
|
||||
|
||||
@@ -4,14 +4,27 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../core/constants/asset_paths.dart';
|
||||
import '../../../core/router/app_router.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/utils/validators.dart';
|
||||
import '../../../core/widgets/primary_button.dart';
|
||||
import '../providers/auth_controller.dart';
|
||||
|
||||
/// Store sign-in. The terminal shows this until a valid account is entered.
|
||||
/// Store sign-in. The terminal shows this until the back office issues a
|
||||
/// session.
|
||||
///
|
||||
/// One centred card on a plain background, at every width. The split-screen
|
||||
/// version put a marketing panel beside the form, which meant the thing the
|
||||
/// person came here to use was never in the middle of the screen, was a
|
||||
/// different width on every monitor, and collapsed into a different layout
|
||||
/// below 1000px. A till is signed into at the start of a shift by someone who
|
||||
/// already bought the product; the pitch was costing the form its position.
|
||||
///
|
||||
/// There is no role picker. The role comes back in the login response and is
|
||||
/// the server's to decide — a tab on this screen would only ever have been a
|
||||
/// hint, and a hint that disagreed with the response would be a bug someone
|
||||
/// spends an afternoon on.
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@@ -21,15 +34,16 @@ class LoginScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _email = TextEditingController(text: DemoCredentials.email);
|
||||
final _password = TextEditingController(text: DemoCredentials.password);
|
||||
|
||||
final _authname = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
|
||||
bool _obscure = true;
|
||||
bool _rememberTerminal = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_email.dispose();
|
||||
_authname.dispose();
|
||||
_password.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -39,194 +53,89 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
|
||||
final ok = await ref.read(authControllerProvider.notifier).signIn(
|
||||
email: _email.text,
|
||||
authname: _authname.text,
|
||||
password: _password.text,
|
||||
);
|
||||
|
||||
if (ok && mounted) context.go(AppRoutes.pos);
|
||||
if (!ok || !mounted) return;
|
||||
|
||||
// Admin accounts land on the full shell, cashiers on the till. Read from
|
||||
// the session that was just opened rather than from anything typed here.
|
||||
context.go(AppRoutes.homeFor(ref.read(terminalLoginProvider)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = ref.watch(authControllerProvider);
|
||||
final session = ref.watch(cashierSessionProvider);
|
||||
final busy = auth is Authenticating;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Below this there isn't room for the brand panel beside the form.
|
||||
final showBrandPanel = constraints.maxWidth >= 1000;
|
||||
|
||||
return Row(
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (showBrandPanel)
|
||||
const Expanded(flex: 5, child: _BrandPanel()),
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: _FormPanel(
|
||||
const _Backdrop(),
|
||||
SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final tight = box.maxHeight < 620;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xl,
|
||||
vertical: tight ? AppSpacing.xl : AppSpacing.xxxl,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
// Fills the viewport so the card is centred vertically,
|
||||
// and scrolls the moment it cannot be.
|
||||
constraints: BoxConstraints(
|
||||
minHeight: (box.maxHeight - (tight ? 40 : 64))
|
||||
.clamp(0.0, double.infinity),
|
||||
),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 440),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: tight ? AppSpacing.lg : AppSpacing.xl,
|
||||
),
|
||||
_Card(
|
||||
formKey: _formKey,
|
||||
email: _email,
|
||||
authname: _authname,
|
||||
password: _password,
|
||||
obscure: _obscure,
|
||||
rememberTerminal: _rememberTerminal,
|
||||
showCompactLogo: !showBrandPanel,
|
||||
onToggleObscure: () => setState(() => _obscure = !_obscure),
|
||||
busy: busy,
|
||||
failure:
|
||||
auth is AuthFailure ? auth.message : null,
|
||||
onToggleObscure: () =>
|
||||
setState(() => _obscure = !_obscure),
|
||||
onToggleRemember: (v) =>
|
||||
setState(() => _rememberTerminal = v ?? true),
|
||||
onSubmit: _submit,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Center(
|
||||
child: Text(
|
||||
'Terminal ${session.terminalId}',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: Colors.white.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BrandPanel extends StatelessWidget {
|
||||
const _BrandPanel();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.primaryGradient),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.giant),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'N',
|
||||
style: TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontSize: 23,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
const Flexible(
|
||||
child: Text(
|
||||
'Nearle POS',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.giant),
|
||||
const Text(
|
||||
'Billing that keeps up\nwith your counter.',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 34,
|
||||
height: 1.25,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text(
|
||||
'Scanner-first billing, GST-ready invoices and loyalty '
|
||||
'built in — for supermarkets, pharmacies and retail.',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.78),
|
||||
fontSize: 15,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.giant),
|
||||
const _Feature(
|
||||
icon: Icons.qr_code_scanner_rounded,
|
||||
title: 'Scan and go',
|
||||
body: 'No dialogs between items. Barcode to bill instantly.',
|
||||
),
|
||||
const _Feature(
|
||||
icon: Icons.receipt_long_rounded,
|
||||
title: 'GST compliant',
|
||||
body: 'Per-slab tax split into CGST and SGST on every bill.',
|
||||
),
|
||||
const _Feature(
|
||||
icon: Icons.stars_rounded,
|
||||
title: 'Loyalty that runs itself',
|
||||
body: 'Tiers and points applied without cashier input.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
).animate().fadeIn(duration: 300.ms);
|
||||
}
|
||||
}
|
||||
|
||||
class _Feature extends StatelessWidget {
|
||||
const _Feature({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.body,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String body;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.xl),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.16),
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: Icon(icon, color: Colors.white, size: 19),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.lg),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
body,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.72),
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -234,41 +143,95 @@ class _Feature extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _FormPanel extends ConsumerWidget {
|
||||
const _FormPanel({
|
||||
/// The shopfront behind the form.
|
||||
///
|
||||
/// Darkened, because it is atmosphere rather than something to read: a sharp
|
||||
/// photograph under a sign-in card competes with the two fields the person came
|
||||
/// here to fill in. Scaled up slightly so the edges are pushed off-screen
|
||||
/// instead of showing as a pale border.
|
||||
///
|
||||
/// Falls back to the plain background colour if the asset is missing, so an
|
||||
/// undeclared file costs the login screen its atmosphere and not its function.
|
||||
class _Backdrop extends StatelessWidget {
|
||||
const _Backdrop();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ClipRect(
|
||||
child: Transform.scale(
|
||||
scale: 1.12,
|
||||
child: Image.asset(
|
||||
AssetPaths.loginBackground,
|
||||
fit: BoxFit.cover,
|
||||
filterQuality: FilterQuality.medium,
|
||||
errorBuilder: (context, _, __) =>
|
||||
const ColoredBox(color: AppColors.background),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Two layers, not one: the flat wash guarantees contrast wherever the
|
||||
// photograph happens to be pale, and the gradient puts the darkest part
|
||||
// behind the card rather than spreading it evenly and flattening the
|
||||
// image out.
|
||||
const ColoredBox(color: Color(0x8A1A0B22)),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x66000000), Color(0x22000000), Color(0x77000000)],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Card extends StatelessWidget {
|
||||
const _Card({
|
||||
required this.formKey,
|
||||
required this.email,
|
||||
required this.authname,
|
||||
required this.password,
|
||||
required this.obscure,
|
||||
required this.rememberTerminal,
|
||||
required this.showCompactLogo,
|
||||
required this.busy,
|
||||
required this.failure,
|
||||
required this.onToggleObscure,
|
||||
required this.onToggleRemember,
|
||||
required this.onSubmit,
|
||||
});
|
||||
|
||||
final GlobalKey<FormState> formKey;
|
||||
final TextEditingController email;
|
||||
final TextEditingController authname;
|
||||
final TextEditingController password;
|
||||
final bool obscure;
|
||||
final bool rememberTerminal;
|
||||
final bool showCompactLogo;
|
||||
final bool busy;
|
||||
final String? failure;
|
||||
final VoidCallback onToggleObscure;
|
||||
final ValueChanged<bool?> onToggleRemember;
|
||||
final VoidCallback onSubmit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final auth = ref.watch(authControllerProvider);
|
||||
final session = ref.watch(cashierSessionProvider);
|
||||
final busy = auth is Authenticating;
|
||||
|
||||
return SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.xxl),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: AppRadius.brXl,
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x33101828),
|
||||
blurRadius: 40,
|
||||
offset: Offset(0, 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Form(
|
||||
key: formKey,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
@@ -276,56 +239,42 @@ class _FormPanel extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showCompactLogo) ...[
|
||||
Center(
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'N',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
],
|
||||
|
||||
Text(
|
||||
'Sign in to your store',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
const Text(
|
||||
'Use the credentials issued when your outlet was '
|
||||
'Sign in to your store',
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.3,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const Text(
|
||||
'Use the terminal account issued when your outlet was '
|
||||
'registered.',
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxxl),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
|
||||
const _Label('Store email'),
|
||||
const _Label('Terminal account'),
|
||||
TextFormField(
|
||||
controller: email,
|
||||
controller: authname,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
enabled: !busy,
|
||||
autocorrect: false,
|
||||
// Deliberately not validated as an email address. It looks like
|
||||
// one, but it is an account name the back office issues and its
|
||||
// shape is theirs to change.
|
||||
validator: (v) => (v ?? '').trim().isEmpty
|
||||
? 'Store email is required'
|
||||
: Validators.emailOptional(v),
|
||||
? 'The terminal account is required'
|
||||
: null,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'store@example.in',
|
||||
hintText: 'supervisor.1135@pos.nearle.in',
|
||||
prefixIcon: Icon(Icons.storefront_outlined),
|
||||
),
|
||||
),
|
||||
@@ -338,11 +287,11 @@ class _FormPanel extends ConsumerWidget {
|
||||
enabled: !busy,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => onSubmit(),
|
||||
validator: (v) => (v ?? '').isEmpty
|
||||
? 'Password is required'
|
||||
: ((v ?? '').length < 6
|
||||
? 'Password looks too short'
|
||||
: null),
|
||||
// Length is the server's rule to enforce. Refusing to *send* a
|
||||
// short password only produces a second, different error message
|
||||
// for the same wrong credential.
|
||||
validator: (v) =>
|
||||
(v ?? '').isEmpty ? 'Password is required' : null,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Enter your password',
|
||||
prefixIcon: const Icon(Icons.lock_outline_rounded),
|
||||
@@ -366,9 +315,7 @@ class _FormPanel extends ConsumerWidget {
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: busy
|
||||
? null
|
||||
: () => onToggleRemember(!rememberTerminal),
|
||||
onTap: busy ? null : () => onToggleRemember(!rememberTerminal),
|
||||
borderRadius: AppRadius.brXs,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
@@ -403,7 +350,7 @@ class _FormPanel extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
|
||||
if (auth is AuthFailure) ...[
|
||||
if (failure != null) ...[
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
@@ -413,12 +360,15 @@ class _FormPanel extends ConsumerWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline_rounded,
|
||||
color: AppColors.danger, size: 18,),
|
||||
const Icon(
|
||||
Icons.error_outline_rounded,
|
||||
color: AppColors.danger,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
auth.message,
|
||||
failure!,
|
||||
style: const TextStyle(
|
||||
color: AppColors.danger,
|
||||
fontSize: 13,
|
||||
@@ -430,7 +380,7 @@ class _FormPanel extends ConsumerWidget {
|
||||
).animate().shake(duration: 320.ms, hz: 3),
|
||||
],
|
||||
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
PrimaryButton(
|
||||
label: 'Sign in',
|
||||
icon: Icons.login_rounded,
|
||||
@@ -438,34 +388,10 @@ class _FormPanel extends ConsumerWidget {
|
||||
busy: busy,
|
||||
onPressed: onSubmit,
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
_DemoHint(
|
||||
onFill: busy
|
||||
? null
|
||||
: () {
|
||||
email.text = DemoCredentials.email;
|
||||
password.text = DemoCredentials.password;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
Center(
|
||||
child: Text(
|
||||
'Terminal ${session.terminalId}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
).animate().fadeIn(duration: 300.ms).slideY(begin: 0.02, end: 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,60 +415,3 @@ class _Label extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DemoHint extends StatelessWidget {
|
||||
const _DemoHint({this.onFill});
|
||||
|
||||
final VoidCallback? onFill;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primarySurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
border: Border.all(color: AppColors.primaryBorder),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.info_outline_rounded,
|
||||
size: 17, color: AppColors.primary,),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Demo account',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
SelectableText(
|
||||
'${DemoCredentials.email} · ${DemoCredentials.password}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onFill,
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size(0, 32),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
|
||||
),
|
||||
child: const Text('Fill', style: TextStyle(fontSize: 12.5)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,24 @@ import '../../../app/providers.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../core/widgets/numeric_keypad.dart';
|
||||
import '../../../core/widgets/primary_button.dart';
|
||||
import '../../../core/widgets/status_pill.dart';
|
||||
import '../../../domain/entities/customer.dart';
|
||||
import '../../pos/providers/cart_controller.dart';
|
||||
import '../providers/customer_providers.dart';
|
||||
|
||||
/// Attaches a customer to the current bill using nothing but a mobile number.
|
||||
/// Attaches a customer to the current bill.
|
||||
///
|
||||
/// Registration is deliberately minimal: an unknown number can be saved with
|
||||
/// just a name, or the whole step skipped. Nothing here blocks the sale.
|
||||
/// A mobile number, optionally a name, or skip. Nothing else — no lookup
|
||||
/// result to read, no tier, no points balance. Those were a screenful of
|
||||
/// information nobody at a counter acts on, in front of a queue, for a step
|
||||
/// that is optional in the first place.
|
||||
///
|
||||
/// The lookup still happens; it just does not show. On save the number is
|
||||
/// matched against what the terminal already holds, so a returning shopper is
|
||||
/// attached to their existing record rather than duplicated — the loyalty
|
||||
/// figures stay correct, they simply are not read out at the till.
|
||||
Future<void> showCustomerCaptureSheet(BuildContext context) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -35,13 +41,14 @@ class _CustomerCaptureSheet extends ConsumerStatefulWidget {
|
||||
_CustomerCaptureSheetState();
|
||||
}
|
||||
|
||||
class _CustomerCaptureSheetState
|
||||
extends ConsumerState<_CustomerCaptureSheet> {
|
||||
class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
|
||||
String _digits = '';
|
||||
final _name = TextEditingController();
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
|
||||
bool get _complete => _digits.length == AppConstants.mobileNumberLength;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
@@ -54,15 +61,14 @@ class _CustomerCaptureSheetState
|
||||
_digits += d;
|
||||
_error = null;
|
||||
});
|
||||
if (_digits.length == AppConstants.mobileNumberLength) {
|
||||
ref.read(customerLookupProvider.notifier).search(_digits);
|
||||
}
|
||||
}
|
||||
|
||||
void _backspace() {
|
||||
if (_digits.isEmpty) return;
|
||||
setState(() => _digits = _digits.substring(0, _digits.length - 1));
|
||||
ref.read(customerLookupProvider.notifier).reset();
|
||||
setState(() {
|
||||
_digits = _digits.substring(0, _digits.length - 1);
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _clear() {
|
||||
@@ -70,7 +76,6 @@ class _CustomerCaptureSheetState
|
||||
_digits = '';
|
||||
_error = null;
|
||||
});
|
||||
ref.read(customerLookupProvider.notifier).reset();
|
||||
}
|
||||
|
||||
void _attachAndClose(Customer? customer) {
|
||||
@@ -78,22 +83,38 @@ class _CustomerCaptureSheetState
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
/// Saves with whatever was given. Both fields are optional: a bare number
|
||||
/// is still worth keeping, because it is what the WhatsApp bill is sent to.
|
||||
Future<void> _quickRegister() async {
|
||||
final typed = _name.text.trim();
|
||||
final name = typed.isEmpty
|
||||
? 'Customer ${_digits.substring(_digits.length - 4)}'
|
||||
: typed;
|
||||
/// Saves with whatever was given.
|
||||
///
|
||||
/// The name is optional: a bare number is still worth keeping, because it is
|
||||
/// what the WhatsApp bill is sent to. An existing record wins over creating a
|
||||
/// second one — silently, because a cashier does not need to be told the
|
||||
/// shopper has been here before to finish the sale.
|
||||
Future<void> _save() async {
|
||||
if (!_complete) return;
|
||||
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final typed = _name.text.trim();
|
||||
final repository = ref.read(customerRepositoryProvider);
|
||||
|
||||
try {
|
||||
final created = await ref.read(customerRepositoryProvider).create(
|
||||
Customer(id: '', name: name, mobile: _digits),
|
||||
final existing = await repository.findByMobile(_digits);
|
||||
if (existing != null) {
|
||||
if (mounted) _attachAndClose(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
final created = await repository.create(
|
||||
Customer(
|
||||
id: '',
|
||||
name: typed.isEmpty
|
||||
? 'Customer ${_digits.substring(_digits.length - 4)}'
|
||||
: typed,
|
||||
mobile: _digits,
|
||||
),
|
||||
);
|
||||
ref.invalidate(recentCustomersProvider);
|
||||
if (mounted) _attachAndClose(created);
|
||||
@@ -108,7 +129,6 @@ class _CustomerCaptureSheetState
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lookup = ref.watch(customerLookupProvider);
|
||||
final attached = ref.watch(
|
||||
cartControllerProvider.select((c) => c.customer),
|
||||
);
|
||||
@@ -130,36 +150,100 @@ class _CustomerCaptureSheetState
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_grabber(),
|
||||
// Capped and centred. A modal sheet on a 27-inch till used to run
|
||||
// the full width of the screen, which put the keypad and the save
|
||||
// button at opposite ends of the desk.
|
||||
Flexible(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 640),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_header(attached),
|
||||
const Divider(height: 1),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppSpacing.xxl),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Side by side once there is room for both columns.
|
||||
final wide = constraints.maxWidth >= 720;
|
||||
final entry = _entryColumn();
|
||||
final result = _resultColumn(lookup);
|
||||
|
||||
if (!wide) {
|
||||
return Column(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.xl,
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.xxl,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
entry,
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
result,
|
||||
_display(),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
_complete
|
||||
? 'Ready to save'
|
||||
: '${AppConstants.mobileNumberLength - _digits.length}'
|
||||
' more digit(s)',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Center(
|
||||
child: NumericKeypad(
|
||||
maxWidth: 320,
|
||||
onKey: _append,
|
||||
onBackspace: _backspace,
|
||||
onClear: _clear,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
TextField(
|
||||
controller: _name,
|
||||
textCapitalization:
|
||||
TextCapitalization.words,
|
||||
enabled: !_saving,
|
||||
onSubmitted: (_) => _save(),
|
||||
inputFormatters: [
|
||||
LengthLimitingTextInputFormatter(60),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: entry),
|
||||
const SizedBox(width: AppSpacing.xxl),
|
||||
Expanded(child: result),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Customer name',
|
||||
hintText: 'Optional',
|
||||
prefixIcon:
|
||||
Icon(Icons.person_outline_rounded),
|
||||
),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
_error!,
|
||||
style: const TextStyle(
|
||||
color: AppColors.danger,
|
||||
fontSize: 12.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
PrimaryButton(
|
||||
label: 'Save & use',
|
||||
icon: Icons.check_rounded,
|
||||
large: true,
|
||||
busy: _saving,
|
||||
onPressed: _complete ? _save : null,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
PrimaryButton(
|
||||
label: 'Skip',
|
||||
tone: ButtonTone.neutral,
|
||||
onPressed:
|
||||
_saving ? null : () => _attachAndClose(null),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -184,11 +268,23 @@ class _CustomerCaptureSheetState
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.xxl,
|
||||
0,
|
||||
AppSpacing.md,
|
||||
AppSpacing.lg,
|
||||
AppSpacing.lg,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primarySurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
border: Border.all(color: AppColors.primaryBorder),
|
||||
),
|
||||
child: const Icon(Icons.person_add_alt_1_outlined,
|
||||
size: 20, color: AppColors.primary,),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -197,10 +293,16 @@ class _CustomerCaptureSheetState
|
||||
Text(
|
||||
attached == null ? 'Add customer' : 'Change customer',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.3,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'Optional — for loyalty points and tier discounts',
|
||||
'Optional — the bill can be sent to this number',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
@@ -211,357 +313,84 @@ class _CustomerCaptureSheetState
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
TextButton(
|
||||
onPressed: () => _attachAndClose(null),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.textSecondary,
|
||||
),
|
||||
child: const Text('Skip'),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
color: AppColors.textTertiary,
|
||||
tooltip: 'Close',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _entryColumn() => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_display(),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
Center(
|
||||
child: NumericKeypad(
|
||||
maxWidth: 340,
|
||||
onKey: _append,
|
||||
onBackspace: _backspace,
|
||||
onClear: _clear,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
/// The number as it is keyed, grouped 5 + 5 the way it is read aloud.
|
||||
Widget _display() {
|
||||
final filled = _digits.isNotEmpty;
|
||||
final head = _digits.length <= 5 ? _digits : _digits.substring(0, 5);
|
||||
final tail = _digits.length <= 5 ? '' : _digits.substring(5);
|
||||
|
||||
Widget _display() => Container(
|
||||
height: 68,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
||||
return Container(
|
||||
height: 64,
|
||||
padding: const EdgeInsets.only(left: AppSpacing.md, right: AppSpacing.xs),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primarySurface,
|
||||
color: filled ? AppColors.surface : AppColors.surfaceAlt,
|
||||
borderRadius: AppRadius.brLg,
|
||||
border: Border.all(color: AppColors.primaryBorder),
|
||||
border: Border.all(
|
||||
color: filled ? AppColors.primary : AppColors.border,
|
||||
width: filled ? 1.4 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: AppRadius.brXs,
|
||||
),
|
||||
child: const Text(
|
||||
'+91',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
// FittedBox guarantees ten digits fit at any sheet width.
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
_digits.isEmpty
|
||||
? '– – – – – – – – – –'
|
||||
: _digits.split('').join(' '),
|
||||
child: filled
|
||||
? Text(
|
||||
tail.isEmpty ? head : '$head $tail',
|
||||
style: AppTypography.money(23).copyWith(
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'Mobile number',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
color: _digits.isEmpty
|
||||
? AppColors.textTertiary
|
||||
: AppColors.textPrimary,
|
||||
fontSize: 16,
|
||||
color: AppColors.textTertiary.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_digits.isNotEmpty)
|
||||
if (filled)
|
||||
IconButton(
|
||||
onPressed: _clear,
|
||||
icon: const Icon(Icons.close_rounded, size: 20),
|
||||
icon: const Icon(Icons.backspace_outlined, size: 18),
|
||||
color: AppColors.textTertiary,
|
||||
tooltip: 'Clear',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _resultColumn(CustomerLookupState state) => switch (state) {
|
||||
LookupIdle() => _idle(),
|
||||
LookupSearching() => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: AppSpacing.giant),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
LookupFound(:final customer) => _found(customer),
|
||||
LookupNotFound() => _notFound(),
|
||||
LookupError(:final message) => _message(
|
||||
Icons.error_outline_rounded,
|
||||
AppColors.danger,
|
||||
message,
|
||||
),
|
||||
};
|
||||
|
||||
Widget _idle() {
|
||||
final recent = ref.watch(recentCustomersProvider).value ?? const [];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_message(
|
||||
Icons.dialpad_rounded,
|
||||
AppColors.textTertiary,
|
||||
'Key in a 10-digit mobile number — the lookup runs automatically. '
|
||||
'Both fields are optional.',
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_nameField(),
|
||||
if (recent.isNotEmpty) ...[
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'Recent',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: [
|
||||
for (final c in recent.take(4))
|
||||
ActionChip(
|
||||
avatar: CircleAvatar(
|
||||
radius: 11,
|
||||
backgroundColor: AppColors.primarySurface,
|
||||
child: Text(
|
||||
Formatters.initials(c.name),
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
label: Text(
|
||||
c.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12.5),
|
||||
),
|
||||
onPressed: () => _attachAndClose(c),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _found(Customer c) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.successSurface,
|
||||
borderRadius: AppRadius.brLg,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundColor: AppColors.surface,
|
||||
child: Text(
|
||||
Formatters.initials(c.name),
|
||||
style: const TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
c.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
Formatters.mobile(c.mobile),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
StatusPill.tier(c.tier, dense: true),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _miniStat(
|
||||
'${c.loyaltyPoints}', 'points held',),
|
||||
),
|
||||
Expanded(
|
||||
child: _miniStat(
|
||||
Formatters.money(c.redeemableValue),
|
||||
'redeemable',
|
||||
),
|
||||
),
|
||||
if (c.tier.discountRate > 0)
|
||||
Expanded(
|
||||
child: _miniStat(
|
||||
Formatters.percent(c.tier.discountRate),
|
||||
'auto discount',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
PrimaryButton(
|
||||
label: 'Use this customer',
|
||||
icon: Icons.check_rounded,
|
||||
large: true,
|
||||
onPressed: () => _attachAndClose(c),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _notFound() => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_message(
|
||||
Icons.person_search_rounded,
|
||||
AppColors.warning,
|
||||
'New number. Add a name if you have it — the bill can be sent to '
|
||||
'this number on WhatsApp either way.',
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_nameField(),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
_error!,
|
||||
style: const TextStyle(color: AppColors.danger, fontSize: 12.5),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
PrimaryButton(
|
||||
label: 'Save & use',
|
||||
icon: Icons.person_add_alt_1_rounded,
|
||||
large: true,
|
||||
busy: _saving,
|
||||
onPressed: _quickRegister,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
PrimaryButton(
|
||||
label: 'Continue without customer',
|
||||
tone: ButtonTone.neutral,
|
||||
onPressed: _saving ? null : () => _attachAndClose(null),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _nameField() => TextField(
|
||||
controller: _name,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
enabled: !_saving,
|
||||
onSubmitted: (_) => _quickRegister(),
|
||||
inputFormatters: [LengthLimitingTextInputFormatter(60)],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Customer name',
|
||||
hintText: 'Optional',
|
||||
prefixIcon: Icon(Icons.person_outline_rounded),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _miniStat(String value, String label) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _message(IconData icon, Color color, String text) => Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: AppRadius.brMd,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 19, color: color),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,),
|
||||
|
||||
@@ -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}',
|
||||
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: 13,
|
||||
height: 1.45,
|
||||
color: ok ? AppColors.success : AppColors.danger,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../core/router/app_router.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
@@ -11,7 +12,7 @@ import '../../../core/utils/formatters.dart';
|
||||
import '../../../core/widgets/glass_card.dart';
|
||||
import '../../../core/widgets/numeric_keypad.dart';
|
||||
import '../../../core/widgets/primary_button.dart';
|
||||
import '../../../core/widgets/status_pill.dart';
|
||||
import '../../../domain/entities/promo.dart';
|
||||
import '../../../domain/entities/transaction.dart';
|
||||
import '../../customer/widgets/customer_capture_sheet.dart';
|
||||
import '../../pos/providers/cart_controller.dart';
|
||||
@@ -97,7 +98,15 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_customerCard(),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
// Only builds anything when a campaign is on the bill or one is
|
||||
// within reach, so a shop running none sees no empty card.
|
||||
_offersCard(),
|
||||
_methodsCard(controller, state),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
// Carries the rest of the column's height, and answers the
|
||||
// question a customer asks at the counter — what am I paying
|
||||
// for — without going back to the bill.
|
||||
_summaryCard(),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -112,16 +121,37 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
// One scroll view over both columns, equal flex, centred and capped.
|
||||
//
|
||||
// Two independent scrollers with a 4:5 split were what made this read
|
||||
// as lopsided: the columns started at different widths, ended at
|
||||
// different heights, and the whole thing sat against the top-left of
|
||||
// a much larger window. The minimum height fills the viewport so the
|
||||
// pair sits in the middle of the screen instead of clinging to the
|
||||
// top edge, and the cap stops the cards stretching into bands on a
|
||||
// wide till display.
|
||||
final minHeight = constraints.maxHeight.isFinite
|
||||
? (constraints.maxHeight - pad * 2).clamp(0.0, double.infinity)
|
||||
: 0.0;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.all(pad),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: minHeight),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1340),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(flex: 4, child: SingleChildScrollView(child: left)),
|
||||
Expanded(child: left),
|
||||
const SizedBox(width: AppSpacing.lg),
|
||||
Expanded(flex: 5, child: SingleChildScrollView(child: right)),
|
||||
Expanded(child: right),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -224,10 +254,6 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (customer != null) ...[
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
StatusPill.tier(customer.tier, dense: true),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
@@ -289,57 +315,6 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (state.splits.isNotEmpty) ...[
|
||||
const Divider(height: AppSpacing.xxl),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Split tenders',
|
||||
style:
|
||||
TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: controller.clearSplits,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.danger,
|
||||
minimumSize: const Size(0, 32),
|
||||
),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final e in state.splits.asMap().entries)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(e.value.method.emoji,
|
||||
style: const TextStyle(fontSize: 15),),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
e.value.method.label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 13.5),
|
||||
),
|
||||
),
|
||||
Text(Formatters.money(e.value.amount),
|
||||
style: AppTypography.money(13.5),),
|
||||
IconButton(
|
||||
onPressed: () => controller.removeSplit(e.key),
|
||||
icon: const Icon(Icons.close_rounded, size: 16),
|
||||
color: AppColors.textTertiary,
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 30, minHeight: 30),
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: 'Remove tender',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -350,23 +325,115 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
radius: AppRadius.xl,
|
||||
child: state.activeMethod.needsChange
|
||||
? _cashTender(controller)
|
||||
: _referenceTender(controller, state),
|
||||
child: _amountTender(controller, state),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _cashTender(PaymentController controller) {
|
||||
/// Amount entry for whichever method is active.
|
||||
///
|
||||
/// Every method works the same way — a keypad, an Exact shortcut and an
|
||||
/// explicit amount — rather than cash alone asking what was received while
|
||||
/// card/UPI/wallet silently assumed the full balance. Cash additionally gets
|
||||
/// denomination shortcuts and a change-due row, since only cash can be
|
||||
/// over-tendered; a method that captures a reference (card, UPI, gift card)
|
||||
/// gets that field below the keypad.
|
||||
Widget _amountTender(PaymentController controller, PaymentState state) {
|
||||
final cash = state.activeMethod.needsChange;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
// Wide enough to stand the shortcuts beside the keypad instead of
|
||||
// above it. A centred 330px keypad in a 560px column was the other
|
||||
// half of the lopsided look — the space beside it did nothing.
|
||||
final sideBySide = cash && box.maxWidth >= 500;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Cash received',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
Row(
|
||||
children: [
|
||||
Icon(methodIcon(state.activeMethod),
|
||||
size: 20, color: AppColors.primary,),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
cash
|
||||
? 'Cash received'
|
||||
: '${state.activeMethod.label} amount received',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Container(
|
||||
_amountField(),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
if (!sideBySide) ...[
|
||||
_shortcutWrap(controller, cash),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
],
|
||||
|
||||
if (cash) ...[
|
||||
_changeRow(controller.changeDue),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
],
|
||||
|
||||
if (sideBySide)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _shortcutColumn(controller)),
|
||||
const SizedBox(width: AppSpacing.lg),
|
||||
SizedBox(width: 296, child: _keypad()),
|
||||
],
|
||||
)
|
||||
else
|
||||
Center(child: _keypad()),
|
||||
|
||||
if (state.activeMethod.needsReference) ...[
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextField(
|
||||
onChanged: controller.setReference,
|
||||
decoration: InputDecoration(
|
||||
labelText: switch (state.activeMethod) {
|
||||
PaymentMethod.card => 'Approval code',
|
||||
PaymentMethod.upi => 'UPI transaction ID',
|
||||
PaymentMethod.giftCard => 'Gift card number',
|
||||
_ => 'Reference',
|
||||
},
|
||||
prefixIcon: const Icon(Icons.tag_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
_partPaymentAction(controller, state),
|
||||
_receivedSoFar(controller, state),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _keypad() => NumericKeypad(
|
||||
allowDecimal: true,
|
||||
maxWidth: 330,
|
||||
onKey: _appendCash,
|
||||
onBackspace: _backspaceCash,
|
||||
);
|
||||
|
||||
/// The typed amount.
|
||||
///
|
||||
/// The symbol sits in the same run as the digits and in the same style. It
|
||||
/// used to be a separate, smaller, grey glyph, which rendered as a mismatched
|
||||
/// mark floating beside the number rather than part of it.
|
||||
Widget _amountField() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.md,
|
||||
@@ -378,25 +445,26 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('₹',
|
||||
style:
|
||||
TextStyle(fontSize: 22, color: AppColors.textTertiary),),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
_cashBuffer.isEmpty ? '0' : _cashBuffer,
|
||||
style: AppTypography.money(28),
|
||||
'₹${_cashBuffer.isEmpty ? '0' : _cashBuffer}',
|
||||
style: AppTypography.money(30),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Wrap(
|
||||
);
|
||||
}
|
||||
|
||||
static const List<int> _notes = [50, 100, 200, 500, 2000];
|
||||
|
||||
/// Shortcuts above the keypad, for the narrow layout.
|
||||
Widget _shortcutWrap(PaymentController controller, bool cash) {
|
||||
return Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: [
|
||||
@@ -405,27 +473,47 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
label: const Text('Exact'),
|
||||
onPressed: () => _setCash(controller.balanceDue),
|
||||
),
|
||||
for (final note in const [50, 100, 200, 500, 2000])
|
||||
// Denomination shortcuts only make sense for physical notes.
|
||||
if (cash)
|
||||
for (final note in _notes)
|
||||
ActionChip(
|
||||
label: Text('₹$note'),
|
||||
onPressed: () =>
|
||||
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_changeRow(controller.changeDue),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Center(
|
||||
child: NumericKeypad(
|
||||
allowDecimal: true,
|
||||
maxWidth: 330,
|
||||
onKey: _appendCash,
|
||||
onBackspace: _backspaceCash,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shortcuts beside the keypad, for the wide layout.
|
||||
Widget _shortcutColumn(PaymentController controller) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _setCash(controller.balanceDue),
|
||||
icon: const Icon(Icons.done_all_rounded, size: 17),
|
||||
label: Text('Exact ${Formatters.money(controller.balanceDue)}'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(0, 48),
|
||||
backgroundColor: AppColors.primarySurface,
|
||||
foregroundColor: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_splitButton(controller, amount: double.tryParse(_cashBuffer) ?? 0),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
for (final note in _notes) ...[
|
||||
OutlinedButton(
|
||||
onPressed: () =>
|
||||
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 48),
|
||||
foregroundColor: AppColors.textPrimary,
|
||||
),
|
||||
child: Text('+ ₹$note'),
|
||||
),
|
||||
if (note != _notes.last) const SizedBox(height: AppSpacing.sm),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -473,87 +561,380 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _referenceTender(PaymentController controller, PaymentState state) {
|
||||
return Column(
|
||||
/// Takes part of the bill on the current method.
|
||||
///
|
||||
/// This is the old "Add as split payment" button, and it stages exactly the
|
||||
/// same tender — but it no longer asks the cashier to know what a split is,
|
||||
/// or to press it for a payment that is not one. It appears only when the
|
||||
/// typed amount is genuinely short of the balance, and says what it will do
|
||||
/// in the customer's terms: take this much now, leave that much to pay.
|
||||
Widget _partPaymentAction(
|
||||
PaymentController controller,
|
||||
PaymentState state,
|
||||
) {
|
||||
final entered = double.tryParse(_cashBuffer) ?? 0;
|
||||
final due = controller.balanceDue;
|
||||
final short = entered > 0.009 && entered < due - 0.009;
|
||||
|
||||
if (!short) return const SizedBox.shrink();
|
||||
|
||||
final rest = due - entered;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () {
|
||||
controller.addSplit(amount: entered);
|
||||
setState(() => _cashBuffer = '');
|
||||
},
|
||||
icon: const Icon(Icons.add_rounded, size: 18),
|
||||
label: Text(
|
||||
'Take ${Formatters.money(entered)} by '
|
||||
'${state.activeMethod.label}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size(0, 48)),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
'${Formatters.money(rest)} left to pay — pick another method for '
|
||||
'the rest.',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Tenders already staged against this bill, and what is still outstanding.
|
||||
Widget _receivedSoFar(PaymentController controller, PaymentState state) {
|
||||
if (state.splits.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Received so far',
|
||||
style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: controller.clearSplits,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.danger,
|
||||
minimumSize: const Size(0, 32),
|
||||
),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final e in state.splits.asMap().entries)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(methodIcon(e.value.method),
|
||||
size: 17, color: AppColors.textSecondary,),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
e.value.method.label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 13.5),
|
||||
),
|
||||
),
|
||||
Text(Formatters.money(e.value.amount),
|
||||
style: AppTypography.money(13.5),),
|
||||
IconButton(
|
||||
onPressed: () => controller.removeSplit(e.key),
|
||||
icon: const Icon(Icons.close_rounded, size: 16),
|
||||
color: AppColors.textTertiary,
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 30, minHeight: 30),
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: 'Remove tender',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Still to pay',
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
Formatters.money(controller.balanceDue),
|
||||
style: AppTypography.money(
|
||||
15,
|
||||
color: controller.balanceDue > 0
|
||||
? AppColors.warning
|
||||
: AppColors.success,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- Offers
|
||||
/// Campaigns on this bill, and the nearest one that is not on it yet.
|
||||
///
|
||||
/// The engine already applies everything that qualifies, silently — the
|
||||
/// shopper only ever saw a discount line. Two things were missing at the
|
||||
/// counter: a cashier could not answer "did the weekend offer come off?"
|
||||
/// without opening the promo module, and nobody could see that a bill was a
|
||||
/// few rupees short of one. The near-miss rows are the point of this card:
|
||||
/// a minimum-bill campaign is worth nothing if the person paying is never
|
||||
/// told they are close to it.
|
||||
///
|
||||
/// Read-only. Nothing here applies or removes a campaign — that stays with
|
||||
/// [PromoEngine], so the till cannot be talked into a discount by hand.
|
||||
Widget _offersCard() {
|
||||
final cart = ref.watch(cartControllerProvider);
|
||||
final promos = ref.watch(activePromosProvider).value ?? const <Promo>[];
|
||||
|
||||
final applied = cart.appliedPromos;
|
||||
final appliedIds = applied.map((a) => a.promo.id).toSet();
|
||||
final now = DateTime.now();
|
||||
|
||||
// Live today, not already firing, and gated only by a bill minimum this
|
||||
// cart has not reached. A campaign that fails for any other reason —
|
||||
// wrong category, wrong product, wrong day — is not "nearly earned" and
|
||||
// saying so would be a false promise.
|
||||
final withinReach = promos
|
||||
.where((p) =>
|
||||
p.isLiveAt(now) &&
|
||||
!appliedIds.contains(p.id) &&
|
||||
p.minBillValue > 0 &&
|
||||
cart.subtotal < p.minBillValue,)
|
||||
.toList()
|
||||
..sort((a, b) => a.minBillValue.compareTo(b.minBillValue));
|
||||
|
||||
if (applied.isEmpty && withinReach.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.md),
|
||||
child: GlassCard(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
radius: AppRadius.xl,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(state.activeMethod.emoji,
|
||||
style: const TextStyle(fontSize: 20),),
|
||||
const Icon(Icons.sell_outlined,
|
||||
size: 18, color: AppColors.primary,),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'${state.activeMethod.label} payment',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
'Offers',
|
||||
style:
|
||||
const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 104,
|
||||
height: 104,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primarySurface,
|
||||
borderRadius: AppRadius.brXl,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(state.activeMethod.emoji,
|
||||
style: const TextStyle(fontSize: 46),),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
if (applied.isNotEmpty)
|
||||
Text(
|
||||
'Charge ${Formatters.money(controller.balanceDue)} on the '
|
||||
'${state.activeMethod.label.toLowerCase()} terminal',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.5,
|
||||
'− ${Formatters.money(
|
||||
applied.fold<double>(0, (sum, a) => sum + a.amount),
|
||||
)}',
|
||||
style: AppTypography.money(14, color: AppColors.success),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
if (state.activeMethod.needsReference)
|
||||
TextField(
|
||||
onChanged: controller.setReference,
|
||||
decoration: InputDecoration(
|
||||
labelText: switch (state.activeMethod) {
|
||||
PaymentMethod.card => 'Approval code',
|
||||
PaymentMethod.upi => 'UPI transaction ID',
|
||||
PaymentMethod.giftCard => 'Gift card number',
|
||||
_ => 'Reference',
|
||||
},
|
||||
prefixIcon: const Icon(Icons.tag_rounded),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_splitButton(controller),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
for (final a in applied)
|
||||
_offerRow(
|
||||
icon: Icons.check_circle_rounded,
|
||||
tone: AppColors.success,
|
||||
title: a.promo.name,
|
||||
subtitle: a.promo.summary,
|
||||
trailing: '− ${Formatters.money(a.amount)}',
|
||||
),
|
||||
|
||||
// Two is the useful number: the next one to reach and the one
|
||||
// after it. A full list turns a payment screen into a catalogue
|
||||
// of things the shopper is not getting.
|
||||
for (final p in withinReach.take(2))
|
||||
_offerRow(
|
||||
icon: Icons.lock_open_rounded,
|
||||
tone: AppColors.warning,
|
||||
title: p.name,
|
||||
subtitle: '${p.summary} \u00b7 add '
|
||||
'${Formatters.money(p.minBillValue - cart.subtotal)} more '
|
||||
'to reach ${Formatters.money(p.minBillValue)}',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _splitButton(PaymentController controller, {double? amount}) {
|
||||
return OutlinedButton.icon(
|
||||
onPressed: controller.balanceDue > 0
|
||||
? () {
|
||||
controller.addSplit(
|
||||
amount: amount?.clamp(0, controller.balanceDue).toDouble(),
|
||||
Widget _offerRow({
|
||||
required IconData icon,
|
||||
required Color tone,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
String? trailing,
|
||||
}) =>
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 17, color: tone),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(trailing, style: AppTypography.money(13.5, color: tone)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
setState(() => _cashBuffer = '');
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.call_split_rounded, size: 17),
|
||||
label: const Text('Add as split payment'),
|
||||
style: OutlinedButton.styleFrom(minimumSize: const Size(0, 44)),
|
||||
|
||||
// --------------------------------------------------------- Bill summary
|
||||
/// What the amount due is made of.
|
||||
Widget _summaryCard() {
|
||||
final cart = ref.watch(cartControllerProvider);
|
||||
final discounts = cart.lineDiscountTotal + cart.billDiscountTotal;
|
||||
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
radius: AppRadius.xl,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Bill summary',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_summaryRow('Subtotal', Formatters.money(cart.subtotal)),
|
||||
if (discounts > 0)
|
||||
_summaryRow(
|
||||
'Discounts',
|
||||
'− ${Formatters.money(discounts)}',
|
||||
tone: AppColors.success,
|
||||
),
|
||||
if (cart.loyaltyRedemptionValue > 0)
|
||||
_summaryRow(
|
||||
'Points redeemed',
|
||||
'− ${Formatters.money(cart.loyaltyRedemptionValue)}',
|
||||
tone: AppColors.success,
|
||||
),
|
||||
if (cart.roundOff != 0)
|
||||
_summaryRow('Round off', Formatters.money(cart.roundOff)),
|
||||
const Divider(height: AppSpacing.xl),
|
||||
_summaryRow(
|
||||
'Total',
|
||||
Formatters.money(cart.grandTotal),
|
||||
strong: true,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
// Prices are GST-inclusive, so this is a breakdown of the total
|
||||
// rather than another line added to it — said plainly, because a
|
||||
// customer reading a tax figure will otherwise try to add it on.
|
||||
Text(
|
||||
'Includes GST ${Formatters.money(cart.taxAmount)} '
|
||||
'(CGST ${Formatters.money(cart.cgst)} + '
|
||||
'SGST ${Formatters.money(cart.sgst)})',
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: AppColors.textTertiary,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _summaryRow(
|
||||
String label,
|
||||
String value, {
|
||||
Color? tone,
|
||||
bool strong = false,
|
||||
}) =>
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: strong ? 14.5 : 13.5,
|
||||
fontWeight: strong ? FontWeight.w600 : FontWeight.w400,
|
||||
color: strong
|
||||
? AppColors.textPrimary
|
||||
: (tone ?? AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: AppTypography.money(
|
||||
strong ? 17 : 13.5,
|
||||
color: tone ?? (strong ? AppColors.primary : null),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------ Bottom bar
|
||||
Widget _bottomBar(
|
||||
PaymentController controller,
|
||||
@@ -622,7 +1003,6 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.activeMethod.needsChange)
|
||||
TextButton(
|
||||
onPressed: () => _setCash(controller.balanceDue),
|
||||
style: TextButton.styleFrom(
|
||||
@@ -705,7 +1085,11 @@ class _MethodTile extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(method.emoji, style: const TextStyle(fontSize: 22)),
|
||||
Icon(
|
||||
methodIcon(method),
|
||||
size: 22,
|
||||
color: selected ? Colors.white : AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
method.label,
|
||||
@@ -725,3 +1109,19 @@ class _MethodTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A flat icon per tender type.
|
||||
///
|
||||
/// These were emoji — 💵 for cash, 💳 for card — which render as small
|
||||
/// photographic pictures on most platforms and as a fallback box on some. Next
|
||||
/// to Material iconography everywhere else on the screen they read as clip
|
||||
/// art pasted into the UI rather than part of it, and the cash one in
|
||||
/// particular looked like a picture of American banknotes on a rupee till.
|
||||
IconData methodIcon(PaymentMethod method) => switch (method) {
|
||||
PaymentMethod.cash => Icons.payments_outlined,
|
||||
PaymentMethod.card => Icons.credit_card_rounded,
|
||||
PaymentMethod.upi => Icons.qr_code_2_rounded,
|
||||
PaymentMethod.wallet => Icons.account_balance_wallet_outlined,
|
||||
PaymentMethod.giftCard => Icons.card_giftcard_rounded,
|
||||
PaymentMethod.loyalty => Icons.stars_rounded,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -35,6 +35,11 @@ import 'pos_view.dart';
|
||||
/// * `1120–1300` sidebar as an icon rail, docked bill
|
||||
/// * `920–1120` 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),
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_layout.dart';
|
||||
import '../../../core/widgets/primary_button.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
import '../../sync/providers/sync_controller.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../widgets/category_chips.dart';
|
||||
@@ -41,8 +42,8 @@ class PosView extends ConsumerWidget {
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const CustomerBar(),
|
||||
const Divider(height: 1),
|
||||
|
||||
|
||||
Padding(
|
||||
padding:
|
||||
EdgeInsets.fromLTRB(pad, AppSpacing.lg, pad, AppSpacing.md),
|
||||
@@ -82,6 +83,11 @@ class _CatalogueRequired extends ConsumerWidget {
|
||||
final state = ref.watch(catalogueImportProvider);
|
||||
final running = state is ImportRunning;
|
||||
|
||||
// Pulling the catalogue is an admin job, and a cashier has no Product
|
||||
// Import module to be sent to. Offering them a button that opens a screen
|
||||
// they cannot reach is worse than telling them who to ask.
|
||||
final cashier = ref.watch(isCashierModeProvider);
|
||||
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppSpacing.xxl),
|
||||
@@ -102,17 +108,23 @@ class _CatalogueRequired extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
Text(
|
||||
'Import products to start billing',
|
||||
cashier
|
||||
? 'No products on this terminal'
|
||||
: 'Import products to start billing',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
const Text(
|
||||
'This terminal has no catalogue yet. Pull the current products '
|
||||
'once at the start of your shift — after that everything runs '
|
||||
'offline.',
|
||||
Text(
|
||||
cashier
|
||||
? 'Nothing has been imported for this shift yet. Ask an '
|
||||
'admin to sign in and pull the catalogue — once they '
|
||||
'have, sign in again and everything runs offline.'
|
||||
: 'This terminal has no catalogue yet. Pull the current '
|
||||
'products once at the start of your shift — after that '
|
||||
'everything runs offline.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.6,
|
||||
@@ -120,7 +132,7 @@ class _CatalogueRequired extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
|
||||
if (running) ...[
|
||||
if (!cashier && state is ImportRunning) ...[
|
||||
Text(
|
||||
state.stage,
|
||||
style: const TextStyle(
|
||||
@@ -142,7 +154,7 @@ class _CatalogueRequired extends ConsumerWidget {
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
],
|
||||
|
||||
if (state is ImportFailed) ...[
|
||||
if (!cashier && state is ImportFailed) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
|
||||
@@ -171,6 +183,34 @@ class _CatalogueRequired extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
|
||||
if (cashier)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.infoSurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.admin_panel_settings_outlined,
|
||||
size: 18, color: AppColors.info,),
|
||||
SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Importing the catalogue is an admin job. Nothing '
|
||||
'can be billed here until it has been done.',
|
||||
style: TextStyle(
|
||||
color: AppColors.info,
|
||||
fontSize: 13,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
PrimaryButton(
|
||||
label: 'Import catalogue now',
|
||||
icon: Icons.cloud_download_rounded,
|
||||
@@ -189,6 +229,7 @@ class _CatalogueRequired extends ConsumerWidget {
|
||||
label: const Text('Open Product Import'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
189
lib/presentation/pos/widgets/admin_pin_dialog.dart
Normal file
189
lib/presentation/pos/widgets/admin_pin_dialog.dart
Normal 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',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +351,13 @@ class _Row extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
|
||||
child: Row(children: [
|
||||
child: Row(
|
||||
// Start, not space-between. With space-between *and* a Spacer, the
|
||||
// free width was shared out between every child — which pushed the
|
||||
// slab hint away from the label it belongs to, so "GST" and "(5%, 12%)"
|
||||
// read as two unrelated columns. The Spacer alone puts all the slack in
|
||||
// one place, between the label group and the amount.
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
@@ -373,13 +370,14 @@ class _Row extends StatelessWidget {
|
||||
),
|
||||
if (hint != null) ...[
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Text('($hint)',
|
||||
Text(
|
||||
'($hint)',
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: AppColors.textTertiary,
|
||||
),),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
const Spacer(),
|
||||
Text(
|
||||
value,
|
||||
@@ -393,7 +391,8 @@ class _Row extends StatelessWidget {
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Icon(trailingIcon, size: 14, color: AppColors.textTertiary),
|
||||
],
|
||||
],),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -414,18 +413,23 @@ class _Actions extends ConsumerWidget {
|
||||
AppSpacing.xl,
|
||||
AppSpacing.xl,
|
||||
),
|
||||
child: PrimaryButton(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PrimaryButton(
|
||||
label: 'CHARGE',
|
||||
large: true,
|
||||
onPressed: enabled
|
||||
? () async {
|
||||
// Ask once per bill, before payment. Skipping is one tap and
|
||||
// leaves the sale as walk-in.
|
||||
// Ask once per bill, before payment. Skipping is one tap
|
||||
// and leaves the sale as walk-in.
|
||||
if (ref.read(cartControllerProvider).customer == null) {
|
||||
await showCustomerCaptureSheet(context);
|
||||
}
|
||||
// Navigation result is not needed here.
|
||||
if (context.mounted) unawaited(context.push(AppRoutes.payment));
|
||||
if (context.mounted) {
|
||||
unawaited(context.push(AppRoutes.payment));
|
||||
}
|
||||
}
|
||||
: null,
|
||||
trailing: enabled
|
||||
@@ -435,6 +439,8 @@ class _Actions extends ConsumerWidget {
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],),
|
||||
],),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_layout.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../core/widgets/brand_mark.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
import '../../shift/widgets/session_end_sheet.dart';
|
||||
import '../providers/cart_controller.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
|
||||
@@ -32,7 +35,10 @@ class PageHeader extends ConsumerWidget {
|
||||
return LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final showStatus = box.maxWidth >= 720;
|
||||
return _bar(context, ref, compact, showStatus);
|
||||
// The brand block only earns its space once the bar is genuinely wide;
|
||||
// below that it would push the actions off the end.
|
||||
final showBrand = box.maxWidth >= 1000;
|
||||
return _bar(context, ref, compact, showStatus, showBrand);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -42,10 +48,14 @@ class PageHeader extends ConsumerWidget {
|
||||
WidgetRef ref,
|
||||
bool compact,
|
||||
bool showStatus,
|
||||
bool showBrand,
|
||||
) {
|
||||
final module = ref.watch(activeModuleProvider);
|
||||
final now = ref.watch(clockProvider).value ?? DateTime.now();
|
||||
|
||||
// With no sidebar there is nothing else on screen carrying the brand, the
|
||||
// sync log or the way out — so all three are promoted into this bar.
|
||||
final cashierMode = ref.watch(isCashierModeProvider);
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
|
||||
padding: EdgeInsets.symmetric(
|
||||
@@ -58,7 +68,7 @@ class PageHeader extends ConsumerWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (compact) ...[
|
||||
if (compact && onMenuTap != null) ...[
|
||||
IconButton(
|
||||
onPressed: onMenuTap,
|
||||
icon: const Icon(Icons.menu_rounded),
|
||||
@@ -68,28 +78,12 @@ class PageHeader extends ConsumerWidget {
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
],
|
||||
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
module.title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.4,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
if (!compact) _Breadcrumb(module: module),
|
||||
// Dropped first when the bar gets tight: the actions are what the
|
||||
// counter actually presses.
|
||||
if (cashierMode && showBrand) ...[
|
||||
const _CashierBrand(),
|
||||
const SizedBox(width: AppSpacing.lg),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
if (showStatus) ...[
|
||||
_LivePill(offline: ref.watch(simulateOfflineProvider)),
|
||||
@@ -103,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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
821
lib/presentation/shift/screens/end_shift_screen.dart
Normal file
821
lib/presentation/shift/screens/end_shift_screen.dart
Normal 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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
235
lib/presentation/shift/widgets/session_end_sheet.dart
Normal file
235
lib/presentation/shift/widgets/session_end_sheet.dart
Normal 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,),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.print</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -4,5 +4,7 @@
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.print</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
16
pubspec.lock
16
pubspec.lock
@@ -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:
|
||||
|
||||
@@ -54,3 +54,4 @@ flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/sounds/
|
||||
- assets/images/
|
||||
|
||||
325
test/unit/customer_sync_test.dart
Normal file
325
test/unit/customer_sync_test.dart
Normal 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 {}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
194
test/unit/health_reporter_test.dart
Normal file
194
test/unit/health_reporter_test.dart
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
288
test/unit/pos_session_test.dart
Normal file
288
test/unit/pos_session_test.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
143
test/unit/staff_import_test.dart
Normal file
143
test/unit/staff_import_test.dart
Normal 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',);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
219
test/unit/tax_apportionment_test.dart
Normal file
219
test/unit/tax_apportionment_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
76
test/unit/timestamp_offset_test.dart
Normal file
76
test/unit/timestamp_offset_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
// 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
30
test/widget_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user