Compare commits
27 Commits
6174734f6d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a2474ee6c | |||
| ad44402232 | |||
| 09e5e29df2 | |||
| 0988d39d8b | |||
|
|
829e5a8188 | ||
| eebd10da6d | |||
|
|
cb065a0f69 | ||
|
|
7b6cd598f0 | ||
|
|
4f9a5c3d6b | ||
|
|
b5b2047bcd | ||
|
|
908058038a | ||
|
|
353c6c1075 | ||
| 6c0266c9c7 | |||
|
|
33b4337933 | ||
|
|
09edce5dc6 | ||
|
|
fe428931ec | ||
|
|
467d5eee75 | ||
|
|
e5fc777202 | ||
|
|
fbfc02d140 | ||
|
|
46d354ced1 | ||
|
|
fdd90f28d9 | ||
|
|
3513281a11 | ||
|
|
e17937e8f1 | ||
|
|
174fdddb8a | ||
|
|
33467e6963 | ||
|
|
30d6d1080f | ||
|
|
0a49323858 |
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
|
```bash
|
||||||
flutter pub get
|
flutter pub get
|
||||||
flutter run -d windows # or macos, linux, or a connected tablet
|
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
|
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.
|
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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
310
docs/sync-contract.md
Normal file
310
docs/sync-contract.md
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
# Terminal ↔ back office sync contract
|
||||||
|
|
||||||
|
What the till guarantees, and what the back office must do to hold up its end.
|
||||||
|
Everything here is enforced by tests in `test/unit/retention_test.dart`,
|
||||||
|
`test/unit/transport_test.dart` and `test/unit/sync_engine_test.dart`.
|
||||||
|
|
||||||
|
## The shape
|
||||||
|
|
||||||
|
```
|
||||||
|
Customer pays
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
One SQLite transaction: bill + stock + loyalty ← never blocked on network
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
orders row lands at sync_status = 0 ← this table IS the outbox
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
SyncEngine drains on: sale committed · network back · 5-min poll · head office
|
||||||
|
│ asked · cashier pressed Sync
|
||||||
|
▼
|
||||||
|
Transport publishes a batch
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Back office commits and names the ids it took
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Those rows → sync_status = 1, folded into day_archive, kept 7 days, then purged
|
||||||
|
```
|
||||||
|
|
||||||
|
Anything the back office does not name stays at 0 and goes again.
|
||||||
|
|
||||||
|
## Non-negotiables
|
||||||
|
|
||||||
|
**1. Only an application acknowledgement counts.**
|
||||||
|
A broker PUBACK means "I hold these bytes". It is not evidence the ledger
|
||||||
|
accepted anything, and the terminal never treats it as such. The back office
|
||||||
|
must answer on the ack topic naming the order ids it committed.
|
||||||
|
|
||||||
|
**2. Silence is not acceptance.**
|
||||||
|
A `200 OK` with an empty body, or an ack with no `accepted` array, marks *zero*
|
||||||
|
bills synced. The terminal will send them again rather than guess.
|
||||||
|
|
||||||
|
**3. Delivery is at-least-once, so the back office must be idempotent.**
|
||||||
|
QoS 1 re-delivers, and a lost ack makes the terminal re-send the whole batch.
|
||||||
|
Every `order.id` is a UUID minted at the till. Put a unique index on it and
|
||||||
|
upsert. Without this you will double-count a day's takings the first time a
|
||||||
|
shop's line wobbles.
|
||||||
|
|
||||||
|
Invoice numbers are `INV-2608-T4A9-00042` — the terminal code is in there
|
||||||
|
because each till's sequence counter lives in its own database and starts at 1.
|
||||||
|
Unique per terminal, not globally sequential. Do not assume gaps mean missing
|
||||||
|
bills; a till that was replaced restarts its own series.
|
||||||
|
|
||||||
|
**4. A refusal is final, a failure is not.**
|
||||||
|
Naming an id in `rejected` halts the terminal's drain — it will not retry the
|
||||||
|
same bytes, and a person has to press Sync. Use it for "this bill is wrong"
|
||||||
|
(unknown product, duplicate invoice). For "I am having a bad minute", drop the
|
||||||
|
connection or return 5xx instead, and the terminal will back off and retry.
|
||||||
|
|
||||||
|
## MQTT topics
|
||||||
|
|
||||||
|
| Topic | Direction | QoS | Retained |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `nearle/pos/{loc}/{terminal}/order` | till → cloud | 1 | no |
|
||||||
|
| `nearle/pos/{loc}/{terminal}/customer` | till → cloud | 1 | no |
|
||||||
|
| `nearle/pos/{loc}/{terminal}/health` | till → cloud | 1 | no |
|
||||||
|
| `nearle/pos/{loc}/{terminal}/ack` | cloud → till | 1 | no |
|
||||||
|
| `nearle/pos/{loc}/{terminal}/status` | till → cloud | 1 | **yes** |
|
||||||
|
| `nearle/pos/{loc}/{terminal}/command` | cloud → till | 1 | no |
|
||||||
|
| `nearle/pos/{loc}/catalogue` | cloud → all tills | 1 | **yes** |
|
||||||
|
|
||||||
|
Namespaced under `nearle/` alongside the rider fleet's `nearle/riders/…`, so one
|
||||||
|
broker ACL rule covers each system.
|
||||||
|
|
||||||
|
`{loc}` is the back office's numeric location id, entered once in Settings; the
|
||||||
|
tenant is resolved from it server-side and never taken from the wire.
|
||||||
|
`{terminal}` comes from the device's own identity, minted on first run and
|
||||||
|
stored in its database. They are never literals — 100 tills sharing one
|
||||||
|
id would collide on every topic and evict each other from the broker, since a
|
||||||
|
second connection with the same client id kicks the first off.
|
||||||
|
|
||||||
|
`status` is also the Last Will. If a till loses power the broker publishes
|
||||||
|
`{"state":"offline"}` on its behalf — that is what makes a "which tills are
|
||||||
|
dark" board possible, and it is the only way to tell *closed for the night*
|
||||||
|
from *unplugged*.
|
||||||
|
|
||||||
|
### Running this on Mosquitto
|
||||||
|
|
||||||
|
The deployed broker is Eclipse Mosquitto 2.1.2. A consumer binds to the topics
|
||||||
|
above directly, using `+` as the single-level wildcard:
|
||||||
|
|
||||||
|
| Purpose | Filter |
|
||||||
|
|---|---|
|
||||||
|
| Every till's bills | `nearle/pos/+/+/order` |
|
||||||
|
| Every till's heartbeat | `nearle/pos/+/+/health` |
|
||||||
|
| One shop's bills | `nearle/pos/12/+/order` |
|
||||||
|
| Ack back to one till | `nearle/pos/12/T4A9/ack` |
|
||||||
|
|
||||||
|
Two things to get right:
|
||||||
|
|
||||||
|
- **Publish the ack from the consumer, after the database commit** — not from an
|
||||||
|
ingest handler that has merely queued the work. The ack is the terminal's only
|
||||||
|
evidence, and it deletes its copy seven days later on the strength of it.
|
||||||
|
- **Do not treat the broker as durable storage.** Mosquitto's default
|
||||||
|
`max_queued_messages` is 1000 and its `autosave_interval` is 30 minutes, so a
|
||||||
|
long outage or a hard kill can drop queued messages. Nothing is lost, because
|
||||||
|
an undelivered batch is simply never acked and the till sends it again — but
|
||||||
|
only as long as nobody acknowledges on the broker's behalf.
|
||||||
|
|
||||||
|
### Fleet presence
|
||||||
|
|
||||||
|
Every terminal publishes a retained record on its status topic on connect and
|
||||||
|
once a minute. Retained matters: a dashboard connecting at noon gets all 100
|
||||||
|
terminals' last state immediately instead of a blank board.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": 1, "state": "online",
|
||||||
|
"device_id": "…", "terminal_code": "T4A9", "terminal_name": "Counter 2",
|
||||||
|
"store_id": "store-01", "app_version": "1.1.0",
|
||||||
|
"reported_at": "2026-08-01T14:22:05Z",
|
||||||
|
"pending_bills": 3, "last_upload_at": "…", "catalogue_revision": "rev-8821",
|
||||||
|
"sync_halted": false, "sync_error": null, "consecutive_failures": 0,
|
||||||
|
"transport": "mqtt"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The Last Will answers *is it reachable*. These fields answer *is it healthy* —
|
||||||
|
a till can be connected and still be holding 200 unsent bills or running last
|
||||||
|
month's price list, and only `pending_bills` and `catalogue_revision` will say
|
||||||
|
so.
|
||||||
|
|
||||||
|
## Payloads
|
||||||
|
|
||||||
|
**Uplink** — `nearle/pos/{loc}/{terminal}/order`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"batch_id": "9f1c…",
|
||||||
|
"store_id": "store-01",
|
||||||
|
"terminal_id": "T4A9",
|
||||||
|
"sent_at": "2026-08-01T14:22:05.123Z",
|
||||||
|
"orders": [ { "id": "…", "invoice_number": "…", "items": [ … ] } ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ack** — `nearle/pos/{loc}/{terminal}/ack`. Must echo `batch_id`; anything else is
|
||||||
|
ignored as belonging to a batch the terminal is no longer waiting on.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"batch_id": "9f1c…",
|
||||||
|
"accepted": ["order-uuid-a", "order-uuid-b"],
|
||||||
|
"rejected": { "order-uuid-c": "duplicate invoice number" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No ack within `SyncConfig.ackTimeout` (20s default) → the outcome is unknown,
|
||||||
|
nothing is marked synced, and the batch goes again.
|
||||||
|
|
||||||
|
**HTTP equivalent** — `POST {base}/orders`, same body, ack shape as the 200
|
||||||
|
response. Carries an `idempotency-key` header that is stable across retries of
|
||||||
|
the same bills.
|
||||||
|
|
||||||
|
### Shopper registrations
|
||||||
|
|
||||||
|
**Uplink** — `nearle/pos/{loc}/{terminal}/customer`, or `POST {base}/customers`.
|
||||||
|
Acked on the same topic and by the same rules: only ids you name are marked
|
||||||
|
sent.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"batch_id": "3d7a…",
|
||||||
|
"store_id": "store-01",
|
||||||
|
"terminal_id": "T4A9",
|
||||||
|
"customers": [
|
||||||
|
{
|
||||||
|
"id": "7a24e082-060f-5503-aa9e-da0ef42df047",
|
||||||
|
"mobile": "9840012345",
|
||||||
|
"name": "Meena",
|
||||||
|
"email": null,
|
||||||
|
"gender": "female",
|
||||||
|
"date_of_birth": null,
|
||||||
|
"registered_at": "2026-08-01T10:14:00.000Z",
|
||||||
|
"registered_by_terminal": "T4A9"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Three things about this payload are load-bearing:
|
||||||
|
|
||||||
|
- **`id` is derived from the mobile number**, not minted at random — a UUIDv5
|
||||||
|
over the normalised ten-digit number in a fixed namespace. Two tills that
|
||||||
|
register the same shopper independently produce *the same id*, so you
|
||||||
|
deduplicate on a primary key rather than guessing at a merge later. Do not
|
||||||
|
reassign it.
|
||||||
|
- **Treat it as insert-if-absent on `id`.** A registration is not a financial
|
||||||
|
record: it is replayed freely, and it must never overwrite a profile
|
||||||
|
corrected at head office. `ON CONFLICT (id) DO NOTHING`.
|
||||||
|
- **No loyalty figures are sent.** Points, lifetime spend and visit counts are
|
||||||
|
absent on purpose — derive them from the bill stream, which is authoritative
|
||||||
|
and idempotent. Accepting a terminal's local balance would make the last till
|
||||||
|
to sync win, and a shopper who bought at two counters on the same day would
|
||||||
|
end up with whichever figure happened to arrive second.
|
||||||
|
|
||||||
|
Registrations are uploaded *before* bills on every pass, so a bill naming a new
|
||||||
|
shopper arrives after the shopper does. A failure here is logged and does not
|
||||||
|
hold up the bills behind it.
|
||||||
|
|
||||||
|
Terminals that were trading before schema v8 carry shoppers with random ids
|
||||||
|
from the old scheme. Those are queued once by the migration and arrive with
|
||||||
|
their original ids — merge them onto the mobile number. It is a one-off for
|
||||||
|
existing stores; a new terminal never produces one.
|
||||||
|
|
||||||
|
## Catalogue pull
|
||||||
|
|
||||||
|
The other direction: products and customers coming down.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET {base}/catalogue?since={revision}&page={n}&store_id=…&terminal_id=…
|
||||||
|
Authorization: Bearer {apiKey}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"revision": "rev-8821",
|
||||||
|
"is_delta": true,
|
||||||
|
"has_more": false,
|
||||||
|
"products": [ { "id": "…", "name": "…", "barcode": "…", "price": 62.0, … } ],
|
||||||
|
"customers": [ { "id": "…", "name": "…", "mobile": "…", … } ],
|
||||||
|
"retired_product_ids": ["sku-9912"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Paged.** A supermarket catalogue is tens of thousands of rows; one response
|
||||||
|
times out on a shop's line and stalls the UI while it decodes. Answer
|
||||||
|
`has_more: true` and the terminal asks for the next page, up to 200 — past that
|
||||||
|
it stops rather than looping against the shop's connection.
|
||||||
|
|
||||||
|
**`since` is the revision the terminal already holds.** Answer with what has
|
||||||
|
moved and set `is_delta: true`. On a normal morning that is a handful of price
|
||||||
|
changes rather than the whole book. A server that cannot do deltas ignores the
|
||||||
|
parameter and answers `is_delta: false`; the terminal reads the flag rather
|
||||||
|
than assuming, so both work.
|
||||||
|
|
||||||
|
**The flag matters more than it looks.** A full snapshot withdraws every product
|
||||||
|
it does not mention. A delta must not — read as a snapshot, the first morning
|
||||||
|
price change would empty the shelf. Withdraw items in a delta with
|
||||||
|
`retired_product_ids`; the terminal marks them inactive rather than deleting,
|
||||||
|
because order lines already recorded point at them.
|
||||||
|
|
||||||
|
**Send stock only when you mean it.** Any product in the payload has its count
|
||||||
|
overwritten by the server's figure, which predates sales this terminal has rung
|
||||||
|
but not yet uploaded. The terminal replays those sales — but only for products
|
||||||
|
the payload actually carried. A delta that ships a stale count for an untouched
|
||||||
|
product will quietly empty a shelf that is full.
|
||||||
|
|
||||||
|
### Field handling
|
||||||
|
|
||||||
|
| Field | Missing | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id`, `name`, `barcode`, `price` | **import fails** | A dropped product is a shelf item that scans to nothing |
|
||||||
|
| `sku` | falls back to `id` | |
|
||||||
|
| `stock` | `0` | Means "not tracked" |
|
||||||
|
| `gst_rate` | 18% | Accepts `18` or `0.18` — both read the same |
|
||||||
|
| `category` | Grocery | An unrecognised one also falls back; the item still sells |
|
||||||
|
| `unit` | piece | Matched by name or symbol |
|
||||||
|
| `is_active` | `true` | An omitted flag is not a withdrawn catalogue |
|
||||||
|
|
||||||
|
Dates are ISO 8601 or epoch milliseconds; both are accepted.
|
||||||
|
|
||||||
|
### Pushing a change mid-day
|
||||||
|
|
||||||
|
Publish to `nearle/pos/{loc}/catalogue` (retained) and every terminal in the shop
|
||||||
|
pulls immediately instead of waiting for tomorrow morning. The message body is
|
||||||
|
only a nudge — the catalogue itself still comes over HTTP, because a broker is
|
||||||
|
the wrong shape for tens of thousands of rows.
|
||||||
|
|
||||||
|
## Retention on the terminal
|
||||||
|
|
||||||
|
Accepted bills stay for 7 days (`OrderDao.retentionWindow`) so a batch the
|
||||||
|
back office later loses can be re-sent in full. After that only the archived
|
||||||
|
day totals survive, and a lost bill's line items are gone for good.
|
||||||
|
|
||||||
|
While a bill is retained it exists in two places — its own row and
|
||||||
|
`day_archive`. `forBusinessDate` therefore reads **pending rows only**; without
|
||||||
|
that filter every synced bill would be counted twice and the shift report would
|
||||||
|
overstate the day.
|
||||||
|
|
||||||
|
## What is deliberately not built
|
||||||
|
|
||||||
|
- **Downlink beyond catalogue-changed and sync-requested.** The plumbing routes
|
||||||
|
unknown commands to the events log rather than dropping them, so adding one
|
||||||
|
is a server change plus a case arm.
|
||||||
|
- **Historical correction.** Bills already synced by an older build went up
|
||||||
|
with an overstated total. Nothing here fixes that; it needs a server-side
|
||||||
|
reconciliation against `bill_discount`.
|
||||||
|
- **Loyalty balances coming back down.** Points and lifetime spend are computed
|
||||||
|
per terminal from the bills that terminal rang. A shopper who buys at two
|
||||||
|
stores has two partial balances until the back office derives the real one
|
||||||
|
from the bill stream and sends it down in a catalogue pull. The uplink
|
||||||
|
deliberately does not carry local balances, so nothing is corrupted by this —
|
||||||
|
but a shopper's points at the till are that till's view, not the group's.
|
||||||
|
- **Merging pre-v8 shoppers.** Terminals that traded before the customer outbox
|
||||||
|
carry rows with random ids. They are uploaded once by the migration, but
|
||||||
|
collapsing them onto the mobile number is the back office's job.
|
||||||
@@ -3,13 +3,32 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../core/constants/app_constants.dart';
|
import '../core/constants/app_constants.dart';
|
||||||
import '../core/router/app_router.dart';
|
import '../core/router/app_router.dart';
|
||||||
|
import '../core/theme/app_colors.dart';
|
||||||
import '../core/theme/app_theme.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 {
|
class NearlePosApp extends ConsumerWidget {
|
||||||
const NearlePosApp({super.key});
|
const NearlePosApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// Watched, not awaited: the till opens immediately and the queue drains
|
||||||
|
// behind it. Nothing on screen depends on this having finished.
|
||||||
|
ref.watch(syncBootstrapProvider);
|
||||||
|
|
||||||
|
// This one *is* awaited. Re-opening a stored session is a keystore read —
|
||||||
|
// a few milliseconds — and building the router before it lands would show
|
||||||
|
// an already-signed-in terminal the login screen and then snatch it away.
|
||||||
|
final restored = ref.watch(sessionBootstrapProvider);
|
||||||
|
|
||||||
|
if (restored.isLoading) {
|
||||||
|
return const MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
home: ColoredBox(color: AppColors.background),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: AppConstants.appName,
|
title: AppConstants.appName,
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
|
|||||||
@@ -1,22 +1,66 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../core/config/sync_config.dart';
|
||||||
|
import '../core/services/connectivity_service.dart';
|
||||||
import '../core/services/receipt_service.dart';
|
import '../core/services/receipt_service.dart';
|
||||||
import '../core/services/sound_service.dart';
|
import '../core/services/sound_service.dart';
|
||||||
import '../data/datasources/local_store.dart';
|
import '../data/datasources/local_store.dart';
|
||||||
import '../data/repositories/customer_repository_impl.dart';
|
import '../data/repositories/customer_repository_impl.dart';
|
||||||
import '../data/repositories/product_repository_impl.dart';
|
import '../data/repositories/product_repository_impl.dart';
|
||||||
import '../data/datasources/remote_catalogue_source.dart';
|
import '../data/remote/catalogue_source.dart';
|
||||||
|
import '../data/remote/http_catalogue_source.dart';
|
||||||
|
import '../data/remote/simulated_catalogue_source.dart';
|
||||||
|
import '../data/remote/http_order_transport.dart';
|
||||||
|
import '../data/remote/mqtt_order_transport.dart';
|
||||||
|
import '../data/remote/order_transport.dart';
|
||||||
|
import '../data/remote/pos_auth_api.dart';
|
||||||
|
import '../data/remote/simulated_order_transport.dart';
|
||||||
|
import '../data/repositories/store_repository_impl.dart';
|
||||||
import '../data/repositories/sync_repository_impl.dart';
|
import '../data/repositories/sync_repository_impl.dart';
|
||||||
import '../data/repositories/transaction_repository_impl.dart';
|
import '../data/repositories/transaction_repository_impl.dart';
|
||||||
|
import '../data/local/session_store.dart';
|
||||||
|
import '../data/local/terminal_identity.dart';
|
||||||
|
import '../data/sync/sync_engine.dart';
|
||||||
import '../domain/repositories/customer_repository.dart';
|
import '../domain/repositories/customer_repository.dart';
|
||||||
import '../domain/repositories/product_repository.dart';
|
import '../domain/repositories/product_repository.dart';
|
||||||
import '../domain/repositories/sync_repository.dart';
|
import '../domain/repositories/sync_repository.dart';
|
||||||
import '../domain/repositories/transaction_repository.dart';
|
import '../domain/repositories/transaction_repository.dart';
|
||||||
|
import '../domain/entities/promo.dart';
|
||||||
|
import '../domain/entities/store_account.dart';
|
||||||
import '../domain/usecases/checkout_sale.dart';
|
import '../domain/usecases/checkout_sale.dart';
|
||||||
|
import '../presentation/auth/providers/auth_controller.dart';
|
||||||
|
|
||||||
/// Root data source. Overridden in tests with an in-memory double.
|
/// Root data source. Overridden in tests with an in-memory double.
|
||||||
final localStoreProvider = Provider<LocalStore>((ref) => LocalStore.instance);
|
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
|
// ---------------------------------------------------------- Repositories
|
||||||
final productRepositoryProvider = Provider<ProductRepository>(
|
final productRepositoryProvider = Provider<ProductRepository>(
|
||||||
(ref) => ProductRepositoryImpl(ref.watch(localStoreProvider)),
|
(ref) => ProductRepositoryImpl(ref.watch(localStoreProvider)),
|
||||||
@@ -36,28 +80,135 @@ final transactionRepositoryProvider = Provider<TransactionRepository>(
|
|||||||
/// purpose, which reads as a real network fault.
|
/// purpose, which reads as a real network fault.
|
||||||
final simulateOfflineProvider = StateProvider<bool>((ref) => false);
|
final simulateOfflineProvider = StateProvider<bool>((ref) => false);
|
||||||
|
|
||||||
/// Simulated back-office endpoints. Held as singletons so the offline toggle
|
/// Where products and customers come from.
|
||||||
/// in Settings affects every call.
|
///
|
||||||
final remoteCatalogueProvider = Provider<RemoteCatalogueSource>(
|
/// Rebuilt when the route changes, and the old one closed, so a re-pointed
|
||||||
(ref) => RemoteCatalogueSource(
|
/// terminal does not keep a stale client alive.
|
||||||
isOffline: () => ref.read(simulateOfflineProvider),
|
final catalogueSourceProvider = Provider<CatalogueSource>((ref) {
|
||||||
),
|
final config = ref.watch(syncConfigProvider);
|
||||||
);
|
|
||||||
|
|
||||||
final remoteOrderSinkProvider = Provider<RemoteOrderSink>(
|
final source = switch (config.transport) {
|
||||||
(ref) => RemoteOrderSink(
|
// MQTT carries the *notification* that the catalogue moved; the catalogue
|
||||||
|
// itself is a bulk pull, which is an HTTP job. A broker is the wrong shape
|
||||||
|
// for tens of thousands of rows.
|
||||||
|
TransportKind.http || TransportKind.mqtt =>
|
||||||
|
config.httpBaseUrl.isEmpty
|
||||||
|
? SimulatedCatalogueSource(
|
||||||
|
isOffline: () => ref.read(simulateOfflineProvider),
|
||||||
|
)
|
||||||
|
: HttpCatalogueSource(config: config),
|
||||||
|
TransportKind.simulated => SimulatedCatalogueSource(
|
||||||
isOffline: () => ref.read(simulateOfflineProvider),
|
isOffline: () => ref.read(simulateOfflineProvider),
|
||||||
),
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
ref.onDispose(source.dispose);
|
||||||
|
return source;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- Sync
|
||||||
|
/// How this terminal reaches the back office.
|
||||||
|
///
|
||||||
|
/// Defaults to this store's live HTTP endpoint, so importing works out of the
|
||||||
|
/// box against real products rather than the offline demo catalogue.
|
||||||
|
/// Settings → Connectivity & sync → Configure re-points it to a different
|
||||||
|
/// store, endpoint, or transport without a rebuild.
|
||||||
|
///
|
||||||
|
/// Terminal id always comes from this device's own identity, never from a
|
||||||
|
/// literal — two terminals publishing on the same topic is the failure this
|
||||||
|
/// exists to prevent.
|
||||||
|
final syncConfigProvider = StateProvider<SyncConfig>((ref) {
|
||||||
|
final terminal = ref.watch(terminalIdentityProvider);
|
||||||
|
return SyncConfig(
|
||||||
|
transport: TransportKind.http,
|
||||||
|
httpBaseUrl: ref.watch(backOfficeBaseUrlProvider),
|
||||||
|
storeId: terminal.storeId,
|
||||||
|
terminalId: terminal.code,
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Real network state, folded with the Settings offline switch.
|
||||||
|
final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
|
||||||
|
final service = ConnectivityService(
|
||||||
|
isSimulatedOffline: () => ref.read(simulateOfflineProvider),
|
||||||
|
);
|
||||||
|
ref.onDispose(service.dispose);
|
||||||
|
return service;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The wire itself. Rebuilt when the configuration changes, and the old one is
|
||||||
|
/// closed so a re-pointed terminal does not keep a stale broker session open.
|
||||||
|
final orderTransportProvider = Provider<OrderTransport>((ref) {
|
||||||
|
final config = ref.watch(syncConfigProvider);
|
||||||
|
|
||||||
|
final transport = switch (config.transport) {
|
||||||
|
TransportKind.mqtt => MqttOrderTransport(config: config),
|
||||||
|
TransportKind.http => HttpOrderTransport(config: config),
|
||||||
|
TransportKind.simulated => SimulatedOrderTransport(
|
||||||
|
isOffline: () => ref.read(simulateOfflineProvider),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
ref.onDispose(transport.dispose);
|
||||||
|
return transport;
|
||||||
|
});
|
||||||
|
|
||||||
final syncRepositoryProvider = Provider<SyncRepository>(
|
final syncRepositoryProvider = Provider<SyncRepository>(
|
||||||
(ref) => SyncRepositoryImpl(
|
(ref) => SyncRepositoryImpl(
|
||||||
ref.watch(localStoreProvider),
|
ref.watch(localStoreProvider),
|
||||||
ref.watch(remoteCatalogueProvider),
|
ref.watch(catalogueSourceProvider),
|
||||||
ref.watch(remoteOrderSinkProvider),
|
ref.watch(orderTransportProvider),
|
||||||
|
batchSize: ref.watch(syncConfigProvider).batchSize,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Decides when bills are uploaded. Started once, by the app shell.
|
||||||
|
final syncEngineProvider = Provider<SyncEngine>((ref) {
|
||||||
|
final transport = ref.watch(orderTransportProvider);
|
||||||
|
|
||||||
|
final engine = SyncEngine(
|
||||||
|
repository: ref.watch(syncRepositoryProvider),
|
||||||
|
connectivity: ref.watch(connectivityServiceProvider).onlineChanges,
|
||||||
|
downlink: transport.downlink,
|
||||||
|
onCatalogueChanged: () async {
|
||||||
|
await ref.read(syncRepositoryProvider).importCatalogue();
|
||||||
|
ref.invalidate(localStoreProvider);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ref.onDispose(engine.dispose);
|
||||||
|
return engine;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Live engine state for the header pill and the events screen.
|
||||||
|
final syncEngineStateProvider = StreamProvider<SyncEngineState>((ref) {
|
||||||
|
final engine = ref.watch(syncEngineProvider);
|
||||||
|
return engine.states.map((s) => s);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Store details and staff, read from this terminal's database.
|
||||||
|
final storeRepositoryProvider = Provider<StoreRepositoryImpl>(
|
||||||
|
(ref) => StoreRepositoryImpl(ref.watch(localStoreProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The outlet, refreshed whenever staff or details change.
|
||||||
|
final storeAccountProvider = FutureProvider<StoreAccount>(
|
||||||
|
(ref) => ref.watch(storeRepositoryProvider).load(
|
||||||
|
// The account the terminal is signed in as, or blank before sign-in.
|
||||||
|
email: ref.watch(sessionAuthnameProvider),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Campaigns stored on this terminal.
|
||||||
|
final promosProvider = FutureProvider<List<Promo>>(
|
||||||
|
(ref) => ref.watch(localStoreProvider).promos.all(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Only the campaigns the till should be applying right now.
|
||||||
|
final activePromosProvider = FutureProvider<List<Promo>>(
|
||||||
|
(ref) => ref.watch(localStoreProvider).promos.all(activeOnly: true),
|
||||||
|
);
|
||||||
|
|
||||||
// ------------------------------------------------------------- Use cases
|
// ------------------------------------------------------------- Use cases
|
||||||
final checkoutSaleProvider = Provider<CheckoutSale>(
|
final checkoutSaleProvider = Provider<CheckoutSale>(
|
||||||
(ref) => CheckoutSale(
|
(ref) => CheckoutSale(
|
||||||
@@ -87,13 +238,31 @@ class CashierSession {
|
|||||||
final String terminalId;
|
final String terminalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
final cashierSessionProvider = StateProvider<CashierSession>(
|
/// Identity of the physical till, read from its own database.
|
||||||
(ref) => const CashierSession(
|
///
|
||||||
|
/// Falls back only before the store has opened; every real read happens after
|
||||||
|
/// `LocalStore.init`, which mints the identity if this device has never run
|
||||||
|
/// before.
|
||||||
|
final terminalIdentityProvider = Provider<TerminalIdentity>((ref) {
|
||||||
|
final store = ref.watch(localStoreProvider);
|
||||||
|
return store.isReady
|
||||||
|
? store.terminal
|
||||||
|
: const TerminalIdentity(
|
||||||
|
deviceId: 'unopened',
|
||||||
|
code: 'T0000',
|
||||||
|
name: 'Terminal',
|
||||||
|
storeId: 'store-01',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final cashierSessionProvider = StateProvider<CashierSession>((ref) {
|
||||||
|
final terminal = ref.watch(terminalIdentityProvider);
|
||||||
|
return CashierSession(
|
||||||
name: 'Suriya',
|
name: 'Suriya',
|
||||||
role: 'ADMIN',
|
role: 'ADMIN',
|
||||||
terminalId: 'TERM-01',
|
terminalId: terminal.code,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
/// Ticks once a minute to drive the header clock without rebuilding on every
|
/// Ticks once a minute to drive the header clock without rebuilding on every
|
||||||
/// frame.
|
/// frame.
|
||||||
|
|||||||
168
lib/core/config/sync_config.dart
Normal file
168
lib/core/config/sync_config.dart
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
/// Which route completed bills take to the back office.
|
||||||
|
enum TransportKind {
|
||||||
|
/// No back office wired up — bills queue and drain against a local stub.
|
||||||
|
simulated,
|
||||||
|
|
||||||
|
/// Plain request/response upload. Easiest to debug: curl reproduces it and
|
||||||
|
/// failures come back as status codes.
|
||||||
|
http,
|
||||||
|
|
||||||
|
/// Persistent connection. Costs a broker, and buys the downlink: head office
|
||||||
|
/// can push a price change or ask a terminal to sync without waiting for it
|
||||||
|
/// to ask first.
|
||||||
|
///
|
||||||
|
/// Speaks MQTT 3.1.1, so it works against Mosquitto, EMQX or a NATS server
|
||||||
|
/// with its MQTT gateway enabled. Against NATS the topics below arrive as
|
||||||
|
/// subjects with `/` mapped to `.` — `pos/store-01/T4A9/order` becomes
|
||||||
|
/// `pos.store-01.T4A9.order` — which is what a JetStream consumer binds to.
|
||||||
|
mqtt,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the terminal needs to reach the back office.
|
||||||
|
///
|
||||||
|
/// One object rather than scattered constants so a store can be re-pointed at
|
||||||
|
/// a different broker without a rebuild, and so tests can construct a whole
|
||||||
|
/// configuration inline.
|
||||||
|
class SyncConfig {
|
||||||
|
const SyncConfig({
|
||||||
|
this.transport = TransportKind.simulated,
|
||||||
|
this.storeId = 'store-01',
|
||||||
|
this.terminalId = 'TERM-01',
|
||||||
|
this.brokerHost = '',
|
||||||
|
this.brokerPort = 8883,
|
||||||
|
this.useTls = true,
|
||||||
|
this.username,
|
||||||
|
this.password,
|
||||||
|
this.httpBaseUrl = '',
|
||||||
|
this.apiKey,
|
||||||
|
this.ackTimeout = const Duration(seconds: 20),
|
||||||
|
this.batchSize = 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TransportKind transport;
|
||||||
|
|
||||||
|
/// Namespaces every topic. Two stores on one broker must never collide.
|
||||||
|
final String storeId;
|
||||||
|
final String terminalId;
|
||||||
|
|
||||||
|
final String brokerHost;
|
||||||
|
final int brokerPort;
|
||||||
|
final bool useTls;
|
||||||
|
final String? username;
|
||||||
|
final String? password;
|
||||||
|
|
||||||
|
final String httpBaseUrl;
|
||||||
|
final String? apiKey;
|
||||||
|
|
||||||
|
/// How long to wait for the back office to confirm a batch before treating
|
||||||
|
/// the outcome as unknown and leaving every row pending.
|
||||||
|
///
|
||||||
|
/// Generous on purpose: a timeout that fires while the server is committing
|
||||||
|
/// produces a duplicate send, which is safe only because the back office
|
||||||
|
/// keys on order id — but it is still wasted traffic on a slow shop line.
|
||||||
|
final Duration ackTimeout;
|
||||||
|
|
||||||
|
/// Bills per publish. Small enough to stay well inside broker message limits
|
||||||
|
/// on a day that has built up a long backlog.
|
||||||
|
final int batchSize;
|
||||||
|
|
||||||
|
bool get isConfigured => switch (transport) {
|
||||||
|
TransportKind.simulated => true,
|
||||||
|
TransportKind.http => httpBaseUrl.isNotEmpty,
|
||||||
|
TransportKind.mqtt => brokerHost.isNotEmpty,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Topics
|
||||||
|
/// Namespaced under `nearle/` alongside the rider fleet's
|
||||||
|
/// `nearle/riders/{riderId}/…`, so one broker ACL rule covers each system and
|
||||||
|
/// a topic says at a glance which one it belongs to.
|
||||||
|
///
|
||||||
|
/// [storeId] carries the back office's numeric location id. The tenant is
|
||||||
|
/// resolved from it server-side and never taken from the wire — a till that
|
||||||
|
/// could name its own tenant could post sales into another shop's books.
|
||||||
|
String get _base => 'nearle/pos/$storeId/$terminalId';
|
||||||
|
|
||||||
|
/// Uplink. Completed bills, QoS 1.
|
||||||
|
String get orderTopic => '$_base/order';
|
||||||
|
|
||||||
|
/// Uplink. Shoppers registered at this till, QoS 1.
|
||||||
|
///
|
||||||
|
/// Separate from [orderTopic] because the two have different shapes and
|
||||||
|
/// different consumers: bills are financial records that must never be
|
||||||
|
/// replayed twice, registrations are insert-if-absent and can be replayed
|
||||||
|
/// freely. Sharing a topic would force one consumer to branch on a type tag
|
||||||
|
/// and would put a registration behind a stuck bill.
|
||||||
|
String get customerTopic => '$_base/customer';
|
||||||
|
|
||||||
|
/// The back office's answer, naming the ids it committed. Subscribed at
|
||||||
|
/// QoS 1: losing an ack means re-sending bills that are already banked.
|
||||||
|
///
|
||||||
|
/// Carries acks for both uplinks. The `batch_id` says which send is being
|
||||||
|
/// answered, so one subscription is enough.
|
||||||
|
String get ackTopic => '$_base/ack';
|
||||||
|
|
||||||
|
/// Retained, and set as the will message. A terminal that loses power stops
|
||||||
|
/// refreshing it and the broker publishes `offline` on its behalf, which is
|
||||||
|
/// what makes a head-office "which tills are dark" board possible.
|
||||||
|
String get statusTopic => '$_base/status';
|
||||||
|
|
||||||
|
/// Liveness, published on a timer rather than on an event.
|
||||||
|
///
|
||||||
|
/// Separate from [statusTopic]: that one is retained and doubles as the Last
|
||||||
|
/// Will, so it must stay small and rarely written. This carries queue depth,
|
||||||
|
/// today's trading and device state — the things a head-office board needs to
|
||||||
|
/// tell a till that is merely quiet from one that is in trouble.
|
||||||
|
String get healthTopic => '$_base/health';
|
||||||
|
|
||||||
|
/// Store-wide downlink: catalogue changes land here for every terminal.
|
||||||
|
String get catalogueTopic => 'nearle/pos/$storeId/catalogue';
|
||||||
|
|
||||||
|
/// Addressed to this terminal alone.
|
||||||
|
String get commandTopic => '$_base/command';
|
||||||
|
|
||||||
|
/// Stable across restarts so the broker can resume a session and redeliver
|
||||||
|
/// anything in flight, rather than treating each launch as a new client.
|
||||||
|
///
|
||||||
|
/// Derived from the terminal's own identity, which is minted per device — two
|
||||||
|
/// tills sharing a client id would knock each other off the broker in a loop,
|
||||||
|
/// since a second connection with the same id evicts the first.
|
||||||
|
String get clientId => 'pos-$storeId-$terminalId';
|
||||||
|
|
||||||
|
/// The same topic as a NATS subject.
|
||||||
|
///
|
||||||
|
/// NATS' MQTT gateway maps `/` to `.`, so this is what a JetStream stream or
|
||||||
|
/// consumer is configured against. Provided so the wildcard a back-office
|
||||||
|
/// consumer needs can be read off the terminal rather than guessed:
|
||||||
|
/// `nearle.pos.*.*.order` for every till's bills, `nearle.pos.*.*.health`
|
||||||
|
/// for presence.
|
||||||
|
static String asNatsSubject(String topic) => topic.replaceAll('/', '.');
|
||||||
|
|
||||||
|
SyncConfig copyWith({
|
||||||
|
TransportKind? transport,
|
||||||
|
String? storeId,
|
||||||
|
String? terminalId,
|
||||||
|
String? brokerHost,
|
||||||
|
int? brokerPort,
|
||||||
|
bool? useTls,
|
||||||
|
String? username,
|
||||||
|
String? password,
|
||||||
|
String? httpBaseUrl,
|
||||||
|
String? apiKey,
|
||||||
|
Duration? ackTimeout,
|
||||||
|
int? batchSize,
|
||||||
|
}) =>
|
||||||
|
SyncConfig(
|
||||||
|
transport: transport ?? this.transport,
|
||||||
|
storeId: storeId ?? this.storeId,
|
||||||
|
terminalId: terminalId ?? this.terminalId,
|
||||||
|
brokerHost: brokerHost ?? this.brokerHost,
|
||||||
|
brokerPort: brokerPort ?? this.brokerPort,
|
||||||
|
useTls: useTls ?? this.useTls,
|
||||||
|
username: username ?? this.username,
|
||||||
|
password: password ?? this.password,
|
||||||
|
httpBaseUrl: httpBaseUrl ?? this.httpBaseUrl,
|
||||||
|
apiKey: apiKey ?? this.apiKey,
|
||||||
|
ackTimeout: ackTimeout ?? this.ackTimeout,
|
||||||
|
batchSize: batchSize ?? this.batchSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@ class AppConstants {
|
|||||||
const AppConstants._();
|
const AppConstants._();
|
||||||
|
|
||||||
static const String appName = 'Nearle POS';
|
static const String appName = 'Nearle POS';
|
||||||
|
|
||||||
|
/// Reported in every presence record. Across a fleet this is how you find
|
||||||
|
/// the twelve tills still on last month's build when one of them misbehaves.
|
||||||
|
static const String appVersion = '1.1.0';
|
||||||
static const String storeName = 'Nearle Daily';
|
static const String storeName = 'Nearle Daily';
|
||||||
static const String storeAddress = '12 Gandhipuram Main Rd, Coimbatore 641012';
|
static const String storeAddress = '12 Gandhipuram Main Rd, Coimbatore 641012';
|
||||||
static const String storeGstin = '33ABCDE1234F1Z5';
|
static const String storeGstin = '33ABCDE1234F1Z5';
|
||||||
@@ -35,8 +39,13 @@ class AppConstants {
|
|||||||
static const Duration barcodeScanTimeout = Duration(milliseconds: 120);
|
static const Duration barcodeScanTimeout = Duration(milliseconds: 120);
|
||||||
static const int minBarcodeLength = 6;
|
static const int minBarcodeLength = 6;
|
||||||
|
|
||||||
/// Idle time after a completed sale before the terminal resets itself.
|
/// Window after a completed sale during which the terminal waits, and the
|
||||||
static const Duration postSaleResetDelay = Duration(seconds: 3);
|
/// 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 lowStockThreshold = 10;
|
||||||
static const int maxParkedBills = 20;
|
static const int maxParkedBills = 20;
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
/// Typed references to bundled assets.
|
/// Typed references to bundled assets.
|
||||||
///
|
///
|
||||||
/// Only sounds are bundled: product imagery uses emoji glyphs and the welcome
|
/// Product imagery uses emoji glyphs and the welcome artwork is painted in
|
||||||
/// artwork is painted in code, so there are no raster or SVG assets to ship.
|
/// code, so the only raster asset shipped is the mark itself.
|
||||||
class AssetPaths {
|
class AssetPaths {
|
||||||
const AssetPaths._();
|
const AssetPaths._();
|
||||||
|
|
||||||
static const String _snd = 'assets/sounds';
|
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 beepSuccess = '$_snd/beep_success.wav';
|
||||||
static const String beepError = '$_snd/beep_error.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/payment/screens/payment_screen.dart';
|
||||||
import '../../presentation/pos/screens/pos_dashboard_screen.dart';
|
import '../../presentation/pos/screens/pos_dashboard_screen.dart';
|
||||||
import '../../presentation/receipt/screens/receipt_screen.dart';
|
import '../../presentation/receipt/screens/receipt_screen.dart';
|
||||||
|
import '../../presentation/shift/screens/end_shift_screen.dart';
|
||||||
|
|
||||||
class AppRoutes {
|
class AppRoutes {
|
||||||
const AppRoutes._();
|
const AppRoutes._();
|
||||||
|
|
||||||
static const String login = '/login';
|
static const String login = '/login';
|
||||||
|
|
||||||
/// The terminal itself. Signing in lands here directly — customer capture
|
/// The admin shell: billing plus catalogue import, promos, staff, settings.
|
||||||
/// happens at checkout, not before the sale.
|
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 pos = '/';
|
||||||
|
|
||||||
static const String payment = '/payment';
|
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';
|
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
|
/// Three rules, in order:
|
||||||
/// already-signed-in terminal is bounced away from the login screen.
|
///
|
||||||
|
/// 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) {
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
// GoRouter re-evaluates `redirect` whenever this notifier fires.
|
// GoRouter re-evaluates `redirect` whenever this notifier fires. It carries
|
||||||
final authChanged = ValueNotifier<bool>(
|
// the destination rather than a bare bool, so a role change — a cashier
|
||||||
ref.read(authControllerProvider).isAuthenticated,
|
// 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>(
|
ref.listen<AuthState>(
|
||||||
authControllerProvider,
|
authControllerProvider,
|
||||||
(_, next) => authChanged.value = next.isAuthenticated,
|
(_, next) => destination.value = home(next),
|
||||||
);
|
);
|
||||||
ref.onDispose(authChanged.dispose);
|
ref.onDispose(destination.dispose);
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: AppRoutes.login,
|
initialLocation: AppRoutes.login,
|
||||||
refreshListenable: authChanged,
|
refreshListenable: destination,
|
||||||
debugLogDiagnostics: false,
|
debugLogDiagnostics: false,
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
final signedIn = ref.read(authControllerProvider).isAuthenticated;
|
final auth = ref.read(authControllerProvider);
|
||||||
final atLogin = state.matchedLocation == AppRoutes.login;
|
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;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
@@ -54,12 +108,40 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
name: 'login',
|
name: 'login',
|
||||||
pageBuilder: (context, state) => _fade(state, const LoginScreen()),
|
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(
|
GoRoute(
|
||||||
path: AppRoutes.pos,
|
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) =>
|
pageBuilder: (context, state) =>
|
||||||
_fade(state, const PosDashboardScreen()),
|
_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(
|
GoRoute(
|
||||||
path: AppRoutes.payment,
|
path: AppRoutes.payment,
|
||||||
name: 'payment',
|
name: 'payment',
|
||||||
|
|||||||
102
lib/core/security/pin_hasher.dart
Normal file
102
lib/core/security/pin_hasher.dart
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
|
||||||
|
/// Turns a staff PIN into something safe to store.
|
||||||
|
///
|
||||||
|
/// PINs used to be string literals in `auth_controller.dart`, which meant every
|
||||||
|
/// shipped build carried every till's credentials — readable by anyone who
|
||||||
|
/// unzipped the APK. Storing them as plain rows in SQLite would be no better:
|
||||||
|
/// the database file sits on a shop-floor machine.
|
||||||
|
///
|
||||||
|
/// So: PBKDF2-HMAC-SHA256, per-user random salt, and only the derived key is
|
||||||
|
/// kept. A four-digit PIN has 10,000 possibilities, so the iteration count is
|
||||||
|
/// doing the real work — it makes checking all of them slow enough to matter.
|
||||||
|
class PinHasher {
|
||||||
|
const PinHasher._();
|
||||||
|
|
||||||
|
/// Chosen so one verification costs roughly a tenth of a second on terminal
|
||||||
|
/// hardware. A cashier signing in never notices; someone working through all
|
||||||
|
/// 10,000 PINs against a stolen database is looking at ~15 minutes per
|
||||||
|
/// account rather than milliseconds.
|
||||||
|
static const int iterations = 12000;
|
||||||
|
|
||||||
|
static const int _keyLength = 32;
|
||||||
|
static const int _saltLength = 16;
|
||||||
|
|
||||||
|
static final Random _random = Random.secure();
|
||||||
|
|
||||||
|
/// A fresh salt. Random.secure draws from the OS, not a seeded PRNG.
|
||||||
|
static String newSalt() {
|
||||||
|
final bytes = Uint8List.fromList(
|
||||||
|
List.generate(_saltLength, (_) => _random.nextInt(256)),
|
||||||
|
);
|
||||||
|
return base64Encode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String hash(String pin, String salt) {
|
||||||
|
final derived = _pbkdf2(
|
||||||
|
utf8.encode(pin),
|
||||||
|
base64Decode(salt),
|
||||||
|
iterations,
|
||||||
|
_keyLength,
|
||||||
|
);
|
||||||
|
return base64Encode(derived);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constant-time comparison.
|
||||||
|
///
|
||||||
|
/// `==` on strings returns as soon as it finds a difference, and the timing
|
||||||
|
/// of that leaks how much of the guess was right.
|
||||||
|
static bool verify(String pin, {required String salt, required String hash}) {
|
||||||
|
final candidate = base64Decode(PinHasher.hash(pin, salt));
|
||||||
|
final expected = base64Decode(hash);
|
||||||
|
if (candidate.length != expected.length) return false;
|
||||||
|
|
||||||
|
var difference = 0;
|
||||||
|
for (var i = 0; i < candidate.length; i++) {
|
||||||
|
difference |= candidate[i] ^ expected[i];
|
||||||
|
}
|
||||||
|
return difference == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PBKDF2 as specified in RFC 8018, with HMAC-SHA256 as the pseudorandom
|
||||||
|
/// function.
|
||||||
|
static Uint8List _pbkdf2(
|
||||||
|
List<int> password,
|
||||||
|
List<int> salt,
|
||||||
|
int iterations,
|
||||||
|
int keyLength,
|
||||||
|
) {
|
||||||
|
final hmac = Hmac(sha256, password);
|
||||||
|
final blocks = (keyLength / 32).ceil();
|
||||||
|
final output = BytesBuilder();
|
||||||
|
|
||||||
|
for (var block = 1; block <= blocks; block++) {
|
||||||
|
// U1 = PRF(password, salt || INT_32_BE(block))
|
||||||
|
final input = <int>[
|
||||||
|
...salt,
|
||||||
|
(block >> 24) & 0xff,
|
||||||
|
(block >> 16) & 0xff,
|
||||||
|
(block >> 8) & 0xff,
|
||||||
|
block & 0xff,
|
||||||
|
];
|
||||||
|
|
||||||
|
var u = Uint8List.fromList(hmac.convert(input).bytes);
|
||||||
|
final accumulator = Uint8List.fromList(u);
|
||||||
|
|
||||||
|
for (var i = 1; i < iterations; i++) {
|
||||||
|
u = Uint8List.fromList(hmac.convert(u).bytes);
|
||||||
|
for (var j = 0; j < accumulator.length; j++) {
|
||||||
|
accumulator[j] ^= u[j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output.add(accumulator);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Uint8List.fromList(output.toBytes().sublist(0, keyLength));
|
||||||
|
}
|
||||||
|
}
|
||||||
101
lib/core/services/cash_drawer_service.dart
Normal file
101
lib/core/services/cash_drawer_service.dart
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// What happened when the till drawer was asked to open.
|
||||||
|
enum DrawerResult {
|
||||||
|
opened,
|
||||||
|
|
||||||
|
/// No drawer address configured. Not an error — plenty of shops take card
|
||||||
|
/// only, or open the drawer by hand.
|
||||||
|
notConfigured,
|
||||||
|
|
||||||
|
unreachable,
|
||||||
|
refused,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the cash drawer.
|
||||||
|
///
|
||||||
|
/// The drawer is wired to the receipt printer's RJ11 port and fires when the
|
||||||
|
/// printer receives `ESC p m t1 t2`. That is a raw byte sequence, and it cannot
|
||||||
|
/// go through the PDF pipeline — a PDF is rendered by the platform driver,
|
||||||
|
/// which will not pass arbitrary bytes to the device. This used to be a
|
||||||
|
/// `debugPrint`, so the drawer never opened at all.
|
||||||
|
///
|
||||||
|
/// So it goes over the wire instead: nearly every thermal receipt printer with
|
||||||
|
/// a network port listens on 9100 (JetDirect) and forwards whatever arrives
|
||||||
|
/// straight to the print head. Sending five bytes to that socket is the whole
|
||||||
|
/// protocol.
|
||||||
|
///
|
||||||
|
/// A USB-only printer has no such path from Flutter and reports
|
||||||
|
/// [DrawerResult.notConfigured] rather than pretending.
|
||||||
|
class CashDrawerService {
|
||||||
|
CashDrawerService({
|
||||||
|
Future<Socket> Function(String host, int port, {Duration? timeout})? connect,
|
||||||
|
}) : _connect = connect ?? _defaultConnect;
|
||||||
|
|
||||||
|
final Future<Socket> Function(String host, int port, {Duration? timeout})
|
||||||
|
_connect;
|
||||||
|
|
||||||
|
static Future<Socket> _defaultConnect(
|
||||||
|
String host,
|
||||||
|
int port, {
|
||||||
|
Duration? timeout,
|
||||||
|
}) =>
|
||||||
|
Socket.connect(host, port, timeout: timeout ?? const Duration(seconds: 3));
|
||||||
|
|
||||||
|
/// `ESC p 0 25 250` — pin 2, 50ms on, 500ms off.
|
||||||
|
///
|
||||||
|
/// Pin 2 is the near-universal wiring. A drawer on pin 5 wants `27 112 1 …`,
|
||||||
|
/// which is the one thing worth checking if the printer clicks and nothing
|
||||||
|
/// opens.
|
||||||
|
static const List<int> kickCommand = [27, 112, 0, 25, 250];
|
||||||
|
|
||||||
|
/// Short on purpose. This runs while the cashier is taking cash, and a drawer
|
||||||
|
/// that opens three seconds late has already been opened by hand.
|
||||||
|
static const Duration timeout = Duration(seconds: 3);
|
||||||
|
|
||||||
|
Future<DrawerResult> open({String? host, int port = 9100}) async {
|
||||||
|
if (host == null || host.trim().isEmpty) return DrawerResult.notConfigured;
|
||||||
|
|
||||||
|
Socket? socket;
|
||||||
|
try {
|
||||||
|
socket = await _connect(host.trim(), port, timeout: timeout);
|
||||||
|
socket.add(kickCommand);
|
||||||
|
await socket.flush().timeout(timeout);
|
||||||
|
return DrawerResult.opened;
|
||||||
|
} on SocketException catch (e) {
|
||||||
|
debugPrint('Cash drawer at $host:$port unreachable: ${e.message}');
|
||||||
|
return DrawerResult.unreachable;
|
||||||
|
} on TimeoutException {
|
||||||
|
debugPrint('Cash drawer at $host:$port did not accept the kick in time');
|
||||||
|
return DrawerResult.refused;
|
||||||
|
} on Object catch (e) {
|
||||||
|
debugPrint('Cash drawer kick failed: $e');
|
||||||
|
return DrawerResult.refused;
|
||||||
|
} finally {
|
||||||
|
// Never awaited: a printer that accepted the bytes but will not close the
|
||||||
|
// socket must not hold up the sale.
|
||||||
|
unawaited(socket?.close().catchError((_) {}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable outcome, for the Settings test button.
|
||||||
|
extension DrawerResultMessage on DrawerResult {
|
||||||
|
String get message => switch (this) {
|
||||||
|
DrawerResult.opened => 'Drawer opened.',
|
||||||
|
DrawerResult.notConfigured =>
|
||||||
|
'No drawer address set. Enter the receipt printer\'s IP address to '
|
||||||
|
'kick the drawer automatically after a cash sale.',
|
||||||
|
DrawerResult.unreachable =>
|
||||||
|
'Could not reach the printer. Check it is powered on and on the same '
|
||||||
|
'network as this terminal.',
|
||||||
|
DrawerResult.refused =>
|
||||||
|
'The printer accepted the connection but not the command. Check the '
|
||||||
|
'drawer is wired to its RJ11 port.',
|
||||||
|
};
|
||||||
|
|
||||||
|
bool get isSuccess => this == DrawerResult.opened;
|
||||||
|
}
|
||||||
76
lib/core/services/connectivity_service.dart
Normal file
76
lib/core/services/connectivity_service.dart
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
|
|
||||||
|
/// Tells the terminal when it is worth trying the network.
|
||||||
|
///
|
||||||
|
/// This is a *hint*, not proof. The platform reports whether an interface is
|
||||||
|
/// up, which on shop wifi is routinely true while the line itself is dead. So
|
||||||
|
/// nothing here decides that a sync succeeded — only the transport's answer
|
||||||
|
/// does. What this buys is the moment to try: the difference between a bill
|
||||||
|
/// going up the second the router comes back and it waiting for the next poll.
|
||||||
|
///
|
||||||
|
/// The Settings "Simulate offline" switch is folded in here so there is one
|
||||||
|
/// answer to "are we online", rather than a real state and a demo state that
|
||||||
|
/// can disagree.
|
||||||
|
class ConnectivityService {
|
||||||
|
ConnectivityService({
|
||||||
|
Connectivity? connectivity,
|
||||||
|
bool Function()? isSimulatedOffline,
|
||||||
|
}) : _connectivity = connectivity ?? Connectivity(),
|
||||||
|
_isSimulatedOffline = isSimulatedOffline ?? (() => false);
|
||||||
|
|
||||||
|
final Connectivity _connectivity;
|
||||||
|
final bool Function() _isSimulatedOffline;
|
||||||
|
|
||||||
|
final _controller = StreamController<bool>.broadcast();
|
||||||
|
StreamSubscription<List<ConnectivityResult>>? _subscription;
|
||||||
|
|
||||||
|
bool _hasInterface = true;
|
||||||
|
bool _started = false;
|
||||||
|
|
||||||
|
/// Fires only when the answer changes, so a subscriber can treat every event
|
||||||
|
/// as an edge.
|
||||||
|
Stream<bool> get onlineChanges => _controller.stream;
|
||||||
|
|
||||||
|
bool get isOnline => _hasInterface && !_isSimulatedOffline();
|
||||||
|
|
||||||
|
Future<void> start() async {
|
||||||
|
if (_started) return;
|
||||||
|
_started = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_hasInterface = _hasAny(await _connectivity.checkConnectivity());
|
||||||
|
} on Exception {
|
||||||
|
// No platform channel — a test host, or a desktop build without the
|
||||||
|
// plugin registered. Assume online and let the transport be the judge;
|
||||||
|
// refusing to try would be worse than trying and failing.
|
||||||
|
_hasInterface = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_subscription = _connectivity.onConnectivityChanged.listen((results) {
|
||||||
|
_update(_hasAny(results));
|
||||||
|
});
|
||||||
|
} on Exception {
|
||||||
|
// As above: without the stream the periodic poll still drains the queue.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-evaluates after the Settings switch is flipped.
|
||||||
|
void refresh() => _update(_hasInterface);
|
||||||
|
|
||||||
|
void _update(bool hasInterface) {
|
||||||
|
final was = isOnline;
|
||||||
|
_hasInterface = hasInterface;
|
||||||
|
if (isOnline != was) _controller.add(isOnline);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _hasAny(List<ConnectivityResult> results) =>
|
||||||
|
results.any((r) => r != ConnectivityResult.none);
|
||||||
|
|
||||||
|
Future<void> dispose() async {
|
||||||
|
await _subscription?.cancel();
|
||||||
|
await _controller.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'cash_drawer_service.dart';
|
||||||
import 'package:pdf/pdf.dart';
|
import 'package:pdf/pdf.dart';
|
||||||
import 'package:pdf/widgets.dart' as pw;
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
import 'package:printing/printing.dart';
|
import 'package:printing/printing.dart';
|
||||||
@@ -224,6 +226,11 @@ class ReceiptService {
|
|||||||
pw.SizedBox(height: 2),
|
pw.SizedBox(height: 2),
|
||||||
_amount('Gross Sales Value', gross),
|
_amount('Gross Sales Value', gross),
|
||||||
if (discount > 0) _amount('Total Discount', discount),
|
if (discount > 0) _amount('Total Discount', discount),
|
||||||
|
// Named on the printed bill too. A shopper who came in for an advertised
|
||||||
|
// offer needs to see it on the receipt, not just a total that happens to
|
||||||
|
// be lower than the shelf price.
|
||||||
|
for (final applied in cart.appliedPromos)
|
||||||
|
_amount(' ${applied.promo.name}', applied.amount),
|
||||||
_amount('Net Sales Value (Inclusive of GST)', cart.netAmount),
|
_amount('Net Sales Value (Inclusive of GST)', cart.netAmount),
|
||||||
if (cart.roundOff != 0) _amount('Round Off', cart.roundOff),
|
if (cart.roundOff != 0) _amount('Round Off', cart.roundOff),
|
||||||
_amount('Total Amount Paid', txn.total, bold: true),
|
_amount('Total Amount Paid', txn.total, bold: true),
|
||||||
@@ -578,6 +585,10 @@ class ReceiptService {
|
|||||||
|
|
||||||
if (cart.billDiscountTotal > 0) {
|
if (cart.billDiscountTotal > 0) {
|
||||||
b.writeln('Discount: -${Formatters.money(cart.billDiscountTotal)}');
|
b.writeln('Discount: -${Formatters.money(cart.billDiscountTotal)}');
|
||||||
|
for (final applied in cart.appliedPromos) {
|
||||||
|
b.writeln(' ${applied.promo.name}: '
|
||||||
|
'-${Formatters.money(applied.amount)}');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
b
|
b
|
||||||
@@ -597,26 +608,17 @@ class ReceiptService {
|
|||||||
return b.toString();
|
return b.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ESC/POS drawer kick: `ESC p m t1 t2` on pin 2.
|
/// Opens the till drawer, if one is configured.
|
||||||
///
|
///
|
||||||
/// Most drawers are wired to the printer's RJ11 port and open when the
|
/// Delegates to [CashDrawerService], which talks raw ESC/POS over a socket.
|
||||||
/// printer receives this. It cannot be sent through the PDF pipeline — a
|
/// The PDF pipeline cannot carry the command — a PDF is rendered by the
|
||||||
/// PDF is rendered by the driver, not passed through as bytes — so this
|
/// platform driver, which will not pass arbitrary bytes through to the
|
||||||
/// needs a raw channel to the printer.
|
/// device.
|
||||||
///
|
Future<DrawerResult> openCashDrawer({
|
||||||
/// On desktop with a driver-installed printer there is no raw path from
|
String? host,
|
||||||
/// Flutter, so this stays a no-op. Wire it up when you move to ESC/POS:
|
int port = 9100,
|
||||||
/// send [drawerKickCommand] over the same socket or Bluetooth link that
|
}) =>
|
||||||
/// carries the receipt.
|
CashDrawerService().open(host: host, port: port);
|
||||||
Future<void> openCashDrawer() async {
|
|
||||||
debugPrint(
|
|
||||||
'Cash drawer kick requested — needs a raw ESC/POS channel, '
|
|
||||||
'not the PDF driver. Command: ${drawerKickCommand.join(' ')}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `ESC p 0 25 250` — pin 2, 50ms on, 500ms off.
|
|
||||||
static const List<int> drawerKickCommand = [27, 112, 0, 25, 250];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One GST rate's slice of a bill.
|
/// One GST rate's slice of a bill.
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ class SoundService {
|
|||||||
static final SoundService instance = SoundService._();
|
static final SoundService instance = SoundService._();
|
||||||
|
|
||||||
final AudioPlayer _player = AudioPlayer(playerId: 'nearle_pos_sfx');
|
final AudioPlayer _player = AudioPlayer(playerId: 'nearle_pos_sfx');
|
||||||
bool _enabled = true;
|
/// Muted from Settings when a shop finds the beeps intrusive.
|
||||||
|
bool enabled = true;
|
||||||
bool get enabled => _enabled;
|
|
||||||
set enabled(bool value) => _enabled = value;
|
|
||||||
|
|
||||||
Future<void> preload() async {
|
Future<void> preload() async {
|
||||||
try {
|
try {
|
||||||
@@ -40,7 +38,7 @@ class SoundService {
|
|||||||
bool haptic = false,
|
bool haptic = false,
|
||||||
bool heavy = false,
|
bool heavy = false,
|
||||||
}) async {
|
}) async {
|
||||||
if (!_enabled) return;
|
if (!enabled) return;
|
||||||
|
|
||||||
// Haptics matter on tablets where the speaker may be muted on the floor.
|
// Haptics matter on tablets where the speaker may be muted on the floor.
|
||||||
if (heavy) {
|
if (heavy) {
|
||||||
|
|||||||
@@ -59,9 +59,59 @@ class Formatters {
|
|||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
static String invoiceNumber(int sequence, DateTime date) {
|
/// `INV-2608-T4A9-00042`.
|
||||||
|
///
|
||||||
|
/// The terminal code is not decoration. The sequence counter lives in each
|
||||||
|
/// till's own database, so without it every terminal in the fleet mints
|
||||||
|
/// `INV-2608-00001` for its first sale of the month — 100 different bills
|
||||||
|
/// sharing one number, and no way to tell them apart on a receipt or in an
|
||||||
|
/// audit. The order's UUID keeps the *data* distinct; this keeps the number
|
||||||
|
/// a human quotes distinct too.
|
||||||
|
///
|
||||||
|
/// [terminalCode] is optional only so older call sites and fixtures keep
|
||||||
|
/// working; anything that writes a real bill passes it.
|
||||||
|
static String invoiceNumber(
|
||||||
|
int sequence,
|
||||||
|
DateTime date, {
|
||||||
|
String? terminalCode,
|
||||||
|
}) {
|
||||||
final y = date.year.toString().substring(2);
|
final y = date.year.toString().substring(2);
|
||||||
final m = date.month.toString().padLeft(2, '0');
|
final m = date.month.toString().padLeft(2, '0');
|
||||||
return 'INV-$y$m-${sequence.toString().padLeft(5, '0')}';
|
final seq = sequence.toString().padLeft(5, '0');
|
||||||
|
final code = (terminalCode == null || terminalCode.isEmpty)
|
||||||
|
? ''
|
||||||
|
: '$terminalCode-';
|
||||||
|
return 'INV-$y$m-$code$seq';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A timestamp the back office cannot misread, with its UTC offset attached.
|
||||||
|
///
|
||||||
|
/// `DateTime.toIso8601String()` on a local time emits no zone marker at all —
|
||||||
|
/// `2026-08-05T12:49:28.245`. That is not wrong, it is *silent*, and the
|
||||||
|
/// receiver has to guess. Go's `time.Parse` guesses UTC, so a bill rung at
|
||||||
|
/// 12:49 in Coimbatore was stored as 12:49 UTC: five and a half hours in the
|
||||||
|
/// future, and reading as *later than the moment it was received*.
|
||||||
|
///
|
||||||
|
/// The daily figures survived that by luck. `businessdate` is derived from
|
||||||
|
/// the wall clock either way, and the wall clock was always the till's own —
|
||||||
|
/// so a day's takings landed on the right day even while the instant was
|
||||||
|
/// wrong. Anything comparing `billedat` to real time did not survive it.
|
||||||
|
///
|
||||||
|
/// Emitting the offset ends the guessing: `2026-08-05T12:49:28.245+05:30`
|
||||||
|
/// parses to the correct instant *and* still formats to the correct local
|
||||||
|
/// date, so both readings stay right.
|
||||||
|
static String isoWithOffset(DateTime time) {
|
||||||
|
final local = time.toLocal();
|
||||||
|
final offset = local.timeZoneOffset;
|
||||||
|
|
||||||
|
final sign = offset.isNegative ? '-' : '+';
|
||||||
|
final magnitude = offset.abs();
|
||||||
|
final hours = magnitude.inHours.toString().padLeft(2, '0');
|
||||||
|
// India is +05:30, so the minutes are load-bearing here in a way they are
|
||||||
|
// not in a whole-hour zone. Taken from the total rather than assumed zero.
|
||||||
|
final minutes =
|
||||||
|
(magnitude.inMinutes % 60).toString().padLeft(2, '0');
|
||||||
|
|
||||||
|
return '${local.toIso8601String()}$sign$hours:$minutes';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,12 @@ import '../../domain/entities/sync_event.dart';
|
|||||||
import '../local/app_database.dart';
|
import '../local/app_database.dart';
|
||||||
import '../local/catalogue_dao.dart';
|
import '../local/catalogue_dao.dart';
|
||||||
import '../local/order_dao.dart';
|
import '../local/order_dao.dart';
|
||||||
|
import '../local/promo_dao.dart';
|
||||||
|
import '../local/staff_dao.dart';
|
||||||
|
import '../local/sync_config_store.dart';
|
||||||
import '../local/sync_log_dao.dart';
|
import '../local/sync_log_dao.dart';
|
||||||
|
import '../local/terminal_identity.dart';
|
||||||
|
import '../local/void_pin_store.dart';
|
||||||
|
|
||||||
/// Terminal-side storage facade.
|
/// Terminal-side storage facade.
|
||||||
///
|
///
|
||||||
@@ -20,6 +25,14 @@ class LocalStore {
|
|||||||
late CatalogueDao catalogue;
|
late CatalogueDao catalogue;
|
||||||
late OrderDao orders;
|
late OrderDao orders;
|
||||||
late SyncLogDao syncLog;
|
late SyncLogDao syncLog;
|
||||||
|
late StaffDao staff;
|
||||||
|
late PromoDao promos;
|
||||||
|
late SyncConfigStore syncConfig;
|
||||||
|
late TerminalIdentityStore identityStore;
|
||||||
|
late VoidPinStore voidPin;
|
||||||
|
|
||||||
|
/// Who this till is. Minted on first run, then stable forever.
|
||||||
|
late TerminalIdentity terminal;
|
||||||
|
|
||||||
final Map<String, Product> _products = {};
|
final Map<String, Product> _products = {};
|
||||||
final Map<String, Customer> _customers = {};
|
final Map<String, Customer> _customers = {};
|
||||||
@@ -28,6 +41,7 @@ class LocalStore {
|
|||||||
DateTime? _lastImportAt;
|
DateTime? _lastImportAt;
|
||||||
String? _catalogueRevision;
|
String? _catalogueRevision;
|
||||||
int _unsyncedOrders = 0;
|
int _unsyncedOrders = 0;
|
||||||
|
int _unsyncedCustomers = 0;
|
||||||
|
|
||||||
bool _ready = false;
|
bool _ready = false;
|
||||||
|
|
||||||
@@ -44,6 +58,19 @@ class LocalStore {
|
|||||||
catalogue = CatalogueDao(AppDatabase.instance.db);
|
catalogue = CatalogueDao(AppDatabase.instance.db);
|
||||||
orders = OrderDao(AppDatabase.instance.db);
|
orders = OrderDao(AppDatabase.instance.db);
|
||||||
syncLog = SyncLogDao(AppDatabase.instance.db);
|
syncLog = SyncLogDao(AppDatabase.instance.db);
|
||||||
|
staff = StaffDao(AppDatabase.instance.db);
|
||||||
|
promos = PromoDao(AppDatabase.instance.db);
|
||||||
|
syncConfig = SyncConfigStore(catalogue);
|
||||||
|
identityStore = TerminalIdentityStore(catalogue);
|
||||||
|
voidPin = VoidPinStore(catalogue, staff);
|
||||||
|
|
||||||
|
// A terminal with no staff cannot be signed into at all, so this runs
|
||||||
|
// before anything else can ask who is on shift.
|
||||||
|
await staff.seedIfEmpty();
|
||||||
|
|
||||||
|
// Before anything can be written or published: a bill stamped with the
|
||||||
|
// wrong terminal cannot be traced back to the till that rang it.
|
||||||
|
terminal = await identityStore.load();
|
||||||
|
|
||||||
await hydrate();
|
await hydrate();
|
||||||
_ready = true;
|
_ready = true;
|
||||||
@@ -67,7 +94,9 @@ class LocalStore {
|
|||||||
? null
|
? null
|
||||||
: DateTime.fromMillisecondsSinceEpoch(int.parse(stamp));
|
: DateTime.fromMillisecondsSinceEpoch(int.parse(stamp));
|
||||||
_catalogueRevision = await catalogue.meta(MetaKeys.catalogueRevision);
|
_catalogueRevision = await catalogue.meta(MetaKeys.catalogueRevision);
|
||||||
|
terminal = await identityStore.load();
|
||||||
_unsyncedOrders = await orders.unsyncedCount();
|
_unsyncedOrders = await orders.unsyncedCount();
|
||||||
|
_unsyncedCustomers = await catalogue.unsyncedCustomerCount();
|
||||||
|
|
||||||
_syncEvents
|
_syncEvents
|
||||||
..clear()
|
..clear()
|
||||||
@@ -89,6 +118,12 @@ class LocalStore {
|
|||||||
if (!_ready) await init(inMemory: true);
|
if (!_ready) await init(inMemory: true);
|
||||||
await AppDatabase.instance.clear();
|
await AppDatabase.instance.clear();
|
||||||
|
|
||||||
|
// Staff and identity are cleared with everything else, and a terminal
|
||||||
|
// without them cannot be signed into or stamp a bill. Re-minted here so a
|
||||||
|
// reset leaves a usable till rather than a half-built one.
|
||||||
|
await staff.seedIfEmpty();
|
||||||
|
terminal = await identityStore.load();
|
||||||
|
|
||||||
if (withCatalogue) {
|
if (withCatalogue) {
|
||||||
// Imported here rather than at the top of the file so the seed data is
|
// Imported here rather than at the top of the file so the seed data is
|
||||||
// only pulled in by tests and the simulated remote source.
|
// only pulled in by tests and the simulated remote source.
|
||||||
@@ -114,18 +149,62 @@ class LocalStore {
|
|||||||
String? get catalogueRevision => _catalogueRevision;
|
String? get catalogueRevision => _catalogueRevision;
|
||||||
int get unsyncedOrders => _unsyncedOrders;
|
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({
|
Future<void> importCatalogue({
|
||||||
required List<Product> products,
|
required List<Product> products,
|
||||||
required List<Customer> customers,
|
required List<Customer> customers,
|
||||||
required String revision,
|
required String revision,
|
||||||
required DateTime at,
|
required DateTime at,
|
||||||
|
bool isDelta = false,
|
||||||
|
List<String> retiredProductIds = const [],
|
||||||
}) async {
|
}) async {
|
||||||
await catalogue.replaceCatalogue(products: products, customers: customers);
|
if (isDelta) {
|
||||||
|
// A change set must not withdraw what it does not mention. Applied as a
|
||||||
|
// full snapshot, the first morning price change would empty the shelf.
|
||||||
|
await catalogue.applyCatalogueDelta(
|
||||||
|
products: products,
|
||||||
|
customers: customers,
|
||||||
|
retiredProductIds: retiredProductIds,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await catalogue.replaceCatalogue(
|
||||||
|
products: products,
|
||||||
|
customers: customers,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// The server's stock figure predates any sale this terminal has made but
|
// The server's stock figure predates any sale this terminal has made but
|
||||||
// not yet uploaded, so those units would reappear on the shelf. Replay them
|
// not yet uploaded, so those units would reappear on the shelf. Replay them
|
||||||
// before anyone can bill against the inflated count.
|
// before anyone can bill against the inflated count.
|
||||||
final committed = await orders.unsyncedStockCommitments();
|
//
|
||||||
|
// Scoped to the products the pull actually overwrote. A full snapshot
|
||||||
|
// rewrites every row, so the replay covers everything; a delta rewrites
|
||||||
|
// only what it carried, and replaying the rest would subtract those units a
|
||||||
|
// second time from a count that was never reset — quietly emptying a shelf
|
||||||
|
// that is full.
|
||||||
|
var committed = await orders.unsyncedStockCommitments();
|
||||||
|
if (isDelta) {
|
||||||
|
final touched = products.map((p) => p.id).toSet();
|
||||||
|
committed = {
|
||||||
|
for (final entry in committed.entries)
|
||||||
|
if (touched.contains(entry.key)) entry.key: entry.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (committed.isNotEmpty) {
|
if (committed.isNotEmpty) {
|
||||||
await catalogue.decrementStock(committed);
|
await catalogue.decrementStock(committed);
|
||||||
}
|
}
|
||||||
@@ -181,11 +260,20 @@ class LocalStore {
|
|||||||
Future<void> putCustomer(Customer c) async {
|
Future<void> putCustomer(Customer c) async {
|
||||||
await catalogue.upsertCustomer(c);
|
await catalogue.upsertCustomer(c);
|
||||||
_customers[c.id] = c;
|
_customers[c.id] = c;
|
||||||
|
await refreshUnsyncedCustomerCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mirrors a customer already written to disk into the memory cache.
|
/// Mirrors a customer already written to disk into the memory cache.
|
||||||
void cacheCustomer(Customer c) => _customers[c.id] = c;
|
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
|
// ----------------------------------------------------------------- Orders
|
||||||
/// Refreshes the cached unsynced tally after a write or a sync.
|
/// Refreshes the cached unsynced tally after a write or a sync.
|
||||||
Future<int> refreshUnsyncedCount() async {
|
Future<int> refreshUnsyncedCount() async {
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
import '../../domain/entities/customer.dart';
|
|
||||||
import '../../domain/entities/product.dart';
|
|
||||||
import 'local_store.dart';
|
|
||||||
import 'seed_data.dart';
|
|
||||||
|
|
||||||
/// What one catalogue pull returns.
|
|
||||||
class CatalogueSnapshot {
|
|
||||||
const CatalogueSnapshot({
|
|
||||||
required this.products,
|
|
||||||
required this.customers,
|
|
||||||
required this.fetchedAt,
|
|
||||||
required this.revision,
|
|
||||||
});
|
|
||||||
|
|
||||||
final List<Product> products;
|
|
||||||
final List<Customer> customers;
|
|
||||||
final DateTime fetchedAt;
|
|
||||||
|
|
||||||
/// Server-side catalogue version, shown so the cashier can tell whether a
|
|
||||||
/// re-import actually changed anything.
|
|
||||||
final String revision;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Raised when the catalogue cannot be pulled.
|
|
||||||
class CatalogueSyncException implements Exception {
|
|
||||||
const CatalogueSyncException(this.message);
|
|
||||||
|
|
||||||
final String message;
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => message;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stands in for the back-office catalogue API.
|
|
||||||
///
|
|
||||||
/// The real implementation would issue an HTTP request; the contract is the
|
|
||||||
/// same, so only this class changes.
|
|
||||||
class RemoteCatalogueSource {
|
|
||||||
RemoteCatalogueSource({required this.isOffline}) {
|
|
||||||
LocalStore.registerSeed(
|
|
||||||
products: SeedData.products,
|
|
||||||
customers: SeedData.customers,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads the Settings switch on every call.
|
|
||||||
///
|
|
||||||
/// Deliberately a callback rather than a stored bool: a copied flag can fall
|
|
||||||
/// out of step with the switch, which makes the terminal behave as offline
|
|
||||||
/// while showing that it is not.
|
|
||||||
final bool Function() isOffline;
|
|
||||||
|
|
||||||
/// Streams progress so the import screen can show a real bar rather than an
|
|
||||||
/// indeterminate spinner.
|
|
||||||
Future<CatalogueSnapshot> fetch({
|
|
||||||
void Function(double progress, String stage)? onProgress,
|
|
||||||
}) async {
|
|
||||||
const stages = [
|
|
||||||
(0.15, 'Contacting server…'),
|
|
||||||
(0.35, 'Authorising terminal…'),
|
|
||||||
(0.60, 'Downloading products…'),
|
|
||||||
(0.85, 'Downloading customers…'),
|
|
||||||
(1.00, 'Writing to local storage…'),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (final (progress, stage) in stages) {
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 320));
|
|
||||||
|
|
||||||
if (isOffline()) {
|
|
||||||
throw const CatalogueSyncException(
|
|
||||||
'Simulate offline is ON in Settings, so the catalogue pull was '
|
|
||||||
'failed on purpose. Turn it off to import.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
onProgress?.call(progress, stage);
|
|
||||||
}
|
|
||||||
|
|
||||||
return CatalogueSnapshot(
|
|
||||||
products: SeedData.products(),
|
|
||||||
customers: SeedData.customers(),
|
|
||||||
fetchedAt: DateTime.now(),
|
|
||||||
revision: 'rev-${DateTime.now().millisecondsSinceEpoch % 100000}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stands in for the back-office order intake API.
|
|
||||||
class RemoteOrderSink {
|
|
||||||
RemoteOrderSink({required this.isOffline});
|
|
||||||
|
|
||||||
final bool Function() isOffline;
|
|
||||||
|
|
||||||
/// Uploads a batch of orders and returns the ids the server accepted.
|
|
||||||
///
|
|
||||||
/// Throws on transport failure so the caller leaves every row at
|
|
||||||
/// `sync_status = 0` rather than marking anything sent.
|
|
||||||
Future<List<String>> pushOrders(List<Map<String, Object?>> orders) async {
|
|
||||||
await Future<void>.delayed(
|
|
||||||
Duration(milliseconds: 400 + orders.length * 60),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isOffline()) {
|
|
||||||
throw const CatalogueSyncException(
|
|
||||||
'Simulate offline is ON in Settings, so the upload was failed on '
|
|
||||||
'purpose. Every bill is still stored on this terminal.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return orders.map((o) => o['id']! as String).toList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,7 @@ class AppDatabase {
|
|||||||
static final AppDatabase instance = AppDatabase._();
|
static final AppDatabase instance = AppDatabase._();
|
||||||
|
|
||||||
static const String _fileName = 'nearle_pos.db';
|
static const String _fileName = 'nearle_pos.db';
|
||||||
static const int _version = 4;
|
static const int _version = 8;
|
||||||
|
|
||||||
Database? _db;
|
Database? _db;
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ class AppDatabase {
|
|||||||
path,
|
path,
|
||||||
options: OpenDatabaseOptions(
|
options: OpenDatabaseOptions(
|
||||||
version: _version,
|
version: _version,
|
||||||
onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
onConfigure: _configure,
|
||||||
onCreate: (db, version) async => _createSchema(db),
|
onCreate: (db, version) async => _createSchema(db),
|
||||||
onUpgrade: (db, from, to) async {
|
onUpgrade: (db, from, to) async {
|
||||||
if (from < 2) await db.execute(_createDayArchive);
|
if (from < 2) await db.execute(_createDayArchive);
|
||||||
@@ -70,6 +70,14 @@ class AppDatabase {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (from < 4) await _upgradeToV4(db, from: from);
|
if (from < 4) await _upgradeToV4(db, from: from);
|
||||||
|
if (from < 5) await db.execute(_createStaff);
|
||||||
|
if (from < 6) await db.execute(_createPromos);
|
||||||
|
if (from < 7) {
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE ${Tables.orders} ADD COLUMN promos_json TEXT',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (from < 8) await _upgradeToV8(db);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -84,12 +92,35 @@ class AppDatabase {
|
|||||||
inMemoryDatabasePath,
|
inMemoryDatabasePath,
|
||||||
options: OpenDatabaseOptions(
|
options: OpenDatabaseOptions(
|
||||||
version: _version,
|
version: _version,
|
||||||
onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
onConfigure: _configure,
|
||||||
onCreate: (db, version) async => _createSchema(db),
|
onCreate: (db, version) async => _createSchema(db),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pragmas applied on every connection, before any query runs.
|
||||||
|
///
|
||||||
|
/// Defaults are wrong for a till:
|
||||||
|
///
|
||||||
|
/// * **WAL** lets a read proceed while a write is in flight. On the rollback
|
||||||
|
/// journal the product grid refreshing would block the sale being written.
|
||||||
|
/// It also survives a power cut better: the database file is never left
|
||||||
|
/// mid-rewrite.
|
||||||
|
/// * **busy_timeout** makes a contended lock wait instead of throwing
|
||||||
|
/// `database is locked` — which, at checkout, is a failed sale.
|
||||||
|
/// * **synchronous = NORMAL** is the right trade under WAL: an fsync per
|
||||||
|
/// transaction costs more than a POS can spare, and WAL still recovers a
|
||||||
|
/// committed transaction after a crash. Only a host OS crash or power loss
|
||||||
|
/// can lose the last commits, which is what the UPS is for.
|
||||||
|
static Future<void> _configure(Database db) async {
|
||||||
|
await db.execute('PRAGMA foreign_keys = ON');
|
||||||
|
await db.execute('PRAGMA busy_timeout = 5000');
|
||||||
|
|
||||||
|
// In-memory databases have no WAL; asking for it is harmless but pointless.
|
||||||
|
await db.execute('PRAGMA journal_mode = WAL');
|
||||||
|
await db.execute('PRAGMA synchronous = NORMAL');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
await _db?.close();
|
await _db?.close();
|
||||||
_db = null;
|
_db = null;
|
||||||
@@ -106,6 +137,8 @@ class AppDatabase {
|
|||||||
Tables.customers,
|
Tables.customers,
|
||||||
Tables.parkedBills,
|
Tables.parkedBills,
|
||||||
Tables.syncLog,
|
Tables.syncLog,
|
||||||
|
Tables.staff,
|
||||||
|
Tables.promos,
|
||||||
Tables.meta,
|
Tables.meta,
|
||||||
]) {
|
]) {
|
||||||
batch.delete(t);
|
batch.delete(t);
|
||||||
@@ -156,12 +189,24 @@ class AppDatabase {
|
|||||||
lifetime_spend REAL NOT NULL DEFAULT 0,
|
lifetime_spend REAL NOT NULL DEFAULT 0,
|
||||||
visit_count INTEGER NOT NULL DEFAULT 0,
|
visit_count INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at INTEGER,
|
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(
|
await db.execute(
|
||||||
'CREATE UNIQUE INDEX idx_customers_mobile ON ${Tables.customers}(mobile)',
|
'CREATE UNIQUE INDEX idx_customers_mobile ON ${Tables.customers}(mobile)',
|
||||||
);
|
);
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_customers_sync ON ${Tables.customers}(sync_status)',
|
||||||
|
);
|
||||||
|
|
||||||
// -------------------------------------------------------------- orders
|
// -------------------------------------------------------------- orders
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
@@ -186,6 +231,10 @@ class AppDatabase {
|
|||||||
points_earned INTEGER NOT NULL DEFAULT 0,
|
points_earned INTEGER NOT NULL DEFAULT 0,
|
||||||
points_redeemed INTEGER NOT NULL DEFAULT 0,
|
points_redeemed INTEGER NOT NULL DEFAULT 0,
|
||||||
payments_json TEXT NOT NULL,
|
payments_json TEXT NOT NULL,
|
||||||
|
-- Which campaigns fired, and for how much. Stored as amounts rather
|
||||||
|
-- than ids: a bill read back next year must show what was actually
|
||||||
|
-- given, not what today's rules would give.
|
||||||
|
promos_json TEXT,
|
||||||
status TEXT NOT NULL DEFAULT 'completed',
|
status TEXT NOT NULL DEFAULT 'completed',
|
||||||
|
|
||||||
-- 0 = held on this terminal, 1 = accepted by the server
|
-- 0 = held on this terminal, 1 = accepted by the server
|
||||||
@@ -242,6 +291,8 @@ class AppDatabase {
|
|||||||
|
|
||||||
// ------------------------------------------------------------- archive
|
// ------------------------------------------------------------- archive
|
||||||
await db.execute(_createDayArchive);
|
await db.execute(_createDayArchive);
|
||||||
|
await db.execute(_createStaff);
|
||||||
|
await db.execute(_createPromos);
|
||||||
|
|
||||||
// ----------------------------------------------------------------- meta
|
// ----------------------------------------------------------------- meta
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
@@ -284,6 +335,43 @@ Future<void> _upgradeToV4(Database db, {required int from}) async {
|
|||||||
await db.execute('DROP TABLE _day_archive_v3');
|
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 = '''
|
const String _createSyncLog = '''
|
||||||
CREATE TABLE sync_log (
|
CREATE TABLE sync_log (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -304,6 +392,55 @@ const String _createSyncLog = '''
|
|||||||
/// here first — otherwise "Bills Today" would collapse to zero the moment a
|
/// here first — otherwise "Bills Today" would collapse to zero the moment a
|
||||||
/// mid-shift sync ran. Keyed by cashier as well as date, because once the
|
/// mid-shift sync ran. Keyed by cashier as well as date, because once the
|
||||||
/// orders are gone this row is the only thing left to settle a till against.
|
/// orders are gone this row is the only thing left to settle a till against.
|
||||||
|
/// Staff who can sign in at this terminal.
|
||||||
|
///
|
||||||
|
/// Replaces three `StaffUser` literals with plaintext PINs that shipped inside
|
||||||
|
/// every build. Only the PBKDF2 hash and its salt are stored — the PIN itself
|
||||||
|
/// exists nowhere, including here.
|
||||||
|
const String _createStaff = '''
|
||||||
|
CREATE TABLE staff (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
pin_hash TEXT NOT NULL,
|
||||||
|
pin_salt TEXT NOT NULL,
|
||||||
|
-- Set on a seeded or reset account, cleared once the person picks their
|
||||||
|
-- own, so a shop running a default PIN is at least visibly nagged.
|
||||||
|
must_change_pin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
''';
|
||||||
|
|
||||||
|
/// Campaigns the till applies automatically.
|
||||||
|
///
|
||||||
|
/// Kept local like everything else: a shop mid-promotion with a dead line still
|
||||||
|
/// has to honour the price on the shelf edge.
|
||||||
|
const String _createPromos = '''
|
||||||
|
CREATE TABLE promos (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
value REAL NOT NULL DEFAULT 0,
|
||||||
|
target_id TEXT,
|
||||||
|
target_label TEXT,
|
||||||
|
buy_quantity INTEGER NOT NULL DEFAULT 0,
|
||||||
|
free_quantity INTEGER NOT NULL DEFAULT 0,
|
||||||
|
min_bill_value REAL NOT NULL DEFAULT 0,
|
||||||
|
max_discount REAL,
|
||||||
|
valid_from INTEGER,
|
||||||
|
valid_to INTEGER,
|
||||||
|
-- Comma-separated DateTime.weekday values. Empty means every day.
|
||||||
|
days_of_week TEXT NOT NULL DEFAULT '',
|
||||||
|
stackable INTEGER NOT NULL DEFAULT 0,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 100,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
''';
|
||||||
|
|
||||||
const String _createDayArchive = '''
|
const String _createDayArchive = '''
|
||||||
CREATE TABLE day_archive (
|
CREATE TABLE day_archive (
|
||||||
business_date TEXT NOT NULL,
|
business_date TEXT NOT NULL,
|
||||||
@@ -335,6 +472,8 @@ class Tables {
|
|||||||
static const String orderItems = 'order_items';
|
static const String orderItems = 'order_items';
|
||||||
static const String parkedBills = 'parked_bills';
|
static const String parkedBills = 'parked_bills';
|
||||||
static const String syncLog = 'sync_log';
|
static const String syncLog = 'sync_log';
|
||||||
|
static const String staff = 'staff';
|
||||||
|
static const String promos = 'promos';
|
||||||
static const String meta = 'app_meta';
|
static const String meta = 'app_meta';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,10 +484,54 @@ class MetaKeys {
|
|||||||
static const String catalogueRevision = 'catalogue_revision';
|
static const String catalogueRevision = 'catalogue_revision';
|
||||||
static const String invoiceSequence = 'invoice_sequence';
|
static const String invoiceSequence = 'invoice_sequence';
|
||||||
|
|
||||||
|
/// Fleet identity. Minted once on first run and never changed — it is what
|
||||||
|
/// ties a bill, an MQTT topic and a presence record to one physical till.
|
||||||
|
static const String deviceId = 'terminal_device_id';
|
||||||
|
|
||||||
|
/// Short code stamped into invoice numbers, e.g. `T4A9`. Unique per device
|
||||||
|
/// so two tills in the same shop cannot mint the same invoice.
|
||||||
|
static const String terminalCode = 'terminal_code';
|
||||||
|
|
||||||
|
/// Human label shown in Settings and on the fleet board, e.g. "Counter 2".
|
||||||
|
static const String terminalName = 'terminal_name';
|
||||||
|
|
||||||
|
static const String storeId = 'store_id';
|
||||||
|
|
||||||
|
/// Printed on every invoice, so they are a legal requirement rather than
|
||||||
|
/// decoration — and must be editable without a rebuild.
|
||||||
|
static const String storeName = 'store_name';
|
||||||
|
static const String storeAddress = 'store_address';
|
||||||
|
static const String storeGstin = 'store_gstin';
|
||||||
|
static const String storePhone = 'store_phone';
|
||||||
|
static const String storePlan = 'store_plan';
|
||||||
|
|
||||||
|
/// How this terminal reaches the back office. Non-secret only — the username,
|
||||||
|
/// password and API key go to the platform keystore, not here.
|
||||||
|
static const String syncTransport = 'sync_transport';
|
||||||
|
static const String syncBrokerHost = 'sync_broker_host';
|
||||||
|
static const String syncBrokerPort = 'sync_broker_port';
|
||||||
|
static const String syncUseTls = 'sync_use_tls';
|
||||||
|
static const String syncHttpBaseUrl = 'sync_http_base_url';
|
||||||
|
|
||||||
|
/// Network printer that owns the cash drawer, if it is not the receipt
|
||||||
|
/// printer itself.
|
||||||
|
static const String drawerHost = 'drawer_host';
|
||||||
|
static const String drawerPort = 'drawer_port';
|
||||||
|
|
||||||
/// Printer chosen in Settings. Stored as the printer's `url`, which is what
|
/// Printer chosen in Settings. Stored as the printer's `url`, which is what
|
||||||
/// `Printing.directPrintPdf` needs to target it without a dialog.
|
/// `Printing.directPrintPdf` needs to target it without a dialog.
|
||||||
static const String printerUrl = 'printer_url';
|
static const String printerUrl = 'printer_url';
|
||||||
static const String printerName = 'printer_name';
|
static const String printerName = 'printer_name';
|
||||||
static const String autoPrint = 'auto_print';
|
static const String autoPrint = 'auto_print';
|
||||||
static const String openDrawer = 'open_cash_drawer';
|
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) {
|
for (final c in customers) {
|
||||||
batch.insert(
|
batch.insert(
|
||||||
Tables.customers,
|
Tables.customers,
|
||||||
customerToRow(c),
|
_importedCustomerRow(c),
|
||||||
conflictAlgorithm: ConflictAlgorithm.ignore,
|
conflictAlgorithm: ConflictAlgorithm.ignore,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -173,6 +173,90 @@ class CatalogueDao {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A customer row that arrived from the back office, marked as already sent.
|
||||||
|
///
|
||||||
|
/// Stated outright rather than left to the column default, because the
|
||||||
|
/// default existing to protect imports is not obvious from this call site —
|
||||||
|
/// and getting it wrong would post the whole customer book straight back to
|
||||||
|
/// the server that just sent it.
|
||||||
|
static Map<String, Object?> _importedCustomerRow(Customer c) => {
|
||||||
|
...customerToRow(c),
|
||||||
|
'sync_status': syncedCustomer,
|
||||||
|
'synced_at': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Applies a change set, leaving everything it does not mention alone.
|
||||||
|
///
|
||||||
|
/// The counterpart to [replaceCatalogue], and the difference matters: a full
|
||||||
|
/// snapshot withdraws anything it omits, a delta must not. Reading a delta as
|
||||||
|
/// if it were a snapshot would empty the shelf on the first morning price
|
||||||
|
/// change.
|
||||||
|
///
|
||||||
|
/// Stock is deliberately *not* taken from a delta unless the back office
|
||||||
|
/// sends it. A price change that carried a stale count would undo every sale
|
||||||
|
/// the terminal has rung since the last pull.
|
||||||
|
Future<void> applyCatalogueDelta({
|
||||||
|
required List<Product> products,
|
||||||
|
required List<Customer> customers,
|
||||||
|
List<String> retiredProductIds = const [],
|
||||||
|
}) async {
|
||||||
|
await _db.transaction((txn) async {
|
||||||
|
final batch = txn.batch();
|
||||||
|
|
||||||
|
for (final id in retiredProductIds) {
|
||||||
|
// Withdrawn rather than deleted: an order line already recorded points
|
||||||
|
// at this product, and a hard delete would orphan a bill's history.
|
||||||
|
batch.update(
|
||||||
|
Tables.products,
|
||||||
|
{
|
||||||
|
'is_active': 0,
|
||||||
|
'updated_at': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final p in products) {
|
||||||
|
batch.insert(
|
||||||
|
Tables.products,
|
||||||
|
productToRow(p),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// As in a full pull: locally registered shoppers must survive, so an
|
||||||
|
// existing row is left alone rather than overwritten.
|
||||||
|
for (final c in customers) {
|
||||||
|
batch.insert(
|
||||||
|
Tables.customers,
|
||||||
|
_importedCustomerRow(c),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.ignore,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await batch.commit(noResult: true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops every product and forgets when the catalogue was last imported.
|
||||||
|
///
|
||||||
|
/// Used at sign-out. Leaves customers, staff, orders and every other table
|
||||||
|
/// untouched — this is about the shelf, not the terminal's history — so the
|
||||||
|
/// next session starts with nothing to sell until it pulls a fresh copy from
|
||||||
|
/// the back office rather than carrying over whatever this session ended
|
||||||
|
/// with.
|
||||||
|
Future<void> clearCatalogue() async {
|
||||||
|
await _db.transaction((txn) async {
|
||||||
|
await txn.delete(Tables.products);
|
||||||
|
await txn.delete(
|
||||||
|
Tables.meta,
|
||||||
|
where: 'key IN (?, ?)',
|
||||||
|
whereArgs: [MetaKeys.lastImportAt, MetaKeys.catalogueRevision],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Applies stock movement after a sale, clamped at zero.
|
/// Applies stock movement after a sale, clamped at zero.
|
||||||
Future<void> decrementStock(Map<String, double> quantities) async {
|
Future<void> decrementStock(Map<String, double> quantities) async {
|
||||||
if (quantities.isEmpty) return;
|
if (quantities.isEmpty) return;
|
||||||
@@ -200,11 +284,10 @@ class CatalogueDao {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Customer?> customerByMobile(String mobile) async {
|
Future<Customer?> customerByMobile(String mobile) async {
|
||||||
final digits = mobile.replaceAll(RegExp(r'\D'), '');
|
|
||||||
final rows = await _db.query(
|
final rows = await _db.query(
|
||||||
Tables.customers,
|
Tables.customers,
|
||||||
where: 'mobile = ?',
|
where: 'mobile = ?',
|
||||||
whereArgs: [digits],
|
whereArgs: [Customer.normaliseMobile(mobile)],
|
||||||
limit: 1,
|
limit: 1,
|
||||||
);
|
);
|
||||||
return rows.isEmpty ? null : customerFromRow(rows.first);
|
return rows.isEmpty ? null : customerFromRow(rows.first);
|
||||||
@@ -220,14 +303,59 @@ class CatalogueDao {
|
|||||||
return rows.isEmpty ? null : customerFromRow(rows.first);
|
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 {
|
Future<void> upsertCustomer(Customer c) async {
|
||||||
await _db.insert(
|
await _db.insert(
|
||||||
Tables.customers,
|
Tables.customers,
|
||||||
customerToRow(c),
|
{...customerToRow(c), 'sync_status': pendingCustomer, 'synced_at': null},
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
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
|
// ------------------------------------------------------------------ Meta
|
||||||
Future<String?> meta(String key) async {
|
Future<String?> meta(String key) async {
|
||||||
final rows = await _db.query(
|
final rows = await _db.query(
|
||||||
@@ -239,6 +367,16 @@ class CatalogueDao {
|
|||||||
return rows.isEmpty ? null : rows.first['value'] as String?;
|
return rows.isEmpty ? null : rows.first['value'] as String?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every stored setting, for support and for tests that assert what is *not*
|
||||||
|
/// in here — credentials, most of all.
|
||||||
|
Future<Map<String, String>> allMeta() async {
|
||||||
|
final rows = await _db.query(Tables.meta);
|
||||||
|
return {
|
||||||
|
for (final row in rows)
|
||||||
|
row['key']! as String: (row['value'] as String?) ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setMeta(String key, String value) async {
|
Future<void> setMeta(String key, String value) async {
|
||||||
await _db.insert(
|
await _db.insert(
|
||||||
Tables.meta,
|
Tables.meta,
|
||||||
|
|||||||
@@ -5,15 +5,20 @@ import 'package:sqflite/sqflite.dart';
|
|||||||
import '../../domain/entities/cart.dart';
|
import '../../domain/entities/cart.dart';
|
||||||
import '../../domain/entities/customer.dart';
|
import '../../domain/entities/customer.dart';
|
||||||
import '../../domain/entities/product.dart';
|
import '../../domain/entities/product.dart';
|
||||||
|
import '../../domain/entities/promo.dart';
|
||||||
import '../../domain/entities/transaction.dart';
|
import '../../domain/entities/transaction.dart';
|
||||||
import 'app_database.dart';
|
import 'app_database.dart';
|
||||||
import 'catalogue_dao.dart';
|
import 'catalogue_dao.dart';
|
||||||
|
|
||||||
/// Persists bills.
|
/// Persists bills.
|
||||||
///
|
///
|
||||||
/// Every completed sale lands here with `sync_status = 0`. The end-of-day
|
/// Every completed sale lands here with `sync_status = 0`, which makes this
|
||||||
/// upload selects those rows, sends them, and flips the accepted ones to 1.
|
/// table the terminal's outbox: the drain engine selects those rows, sends
|
||||||
/// Nothing is ever deleted as part of syncing.
|
/// them, and flips the ones the back office confirmed to 1.
|
||||||
|
///
|
||||||
|
/// Confirmed rows are kept for [retentionWindow] so a batch the server later
|
||||||
|
/// loses can be re-sent in full, then retired by [purgeSyncedBefore]. Syncing
|
||||||
|
/// itself never deletes anything.
|
||||||
class OrderDao {
|
class OrderDao {
|
||||||
const OrderDao(this._db);
|
const OrderDao(this._db);
|
||||||
|
|
||||||
@@ -22,6 +27,9 @@ class OrderDao {
|
|||||||
static const int pending = 0;
|
static const int pending = 0;
|
||||||
static const int synced = 1;
|
static const int synced = 1;
|
||||||
|
|
||||||
|
/// How long an accepted bill stays re-sendable on the terminal.
|
||||||
|
static const Duration retentionWindow = Duration(days: 7);
|
||||||
|
|
||||||
static String businessDateOf(DateTime dt) =>
|
static String businessDateOf(DateTime dt) =>
|
||||||
'${dt.year.toString().padLeft(4, '0')}-'
|
'${dt.year.toString().padLeft(4, '0')}-'
|
||||||
'${dt.month.toString().padLeft(2, '0')}-'
|
'${dt.month.toString().padLeft(2, '0')}-'
|
||||||
@@ -53,10 +61,64 @@ class OrderDao {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
if (customerRow != null) {
|
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,
|
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);
|
await batch.commit(noResult: true);
|
||||||
@@ -85,6 +147,17 @@ class OrderDao {
|
|||||||
'subtotal': cart.subtotal,
|
'subtotal': cart.subtotal,
|
||||||
'line_discount': cart.lineDiscountTotal,
|
'line_discount': cart.lineDiscountTotal,
|
||||||
'bill_discount': cart.billDiscountTotal,
|
'bill_discount': cart.billDiscountTotal,
|
||||||
|
'promos_json': cart.appliedPromos.isEmpty
|
||||||
|
? null
|
||||||
|
: jsonEncode([
|
||||||
|
for (final applied in cart.appliedPromos)
|
||||||
|
{
|
||||||
|
'id': applied.promo.id,
|
||||||
|
'name': applied.promo.name,
|
||||||
|
'type': applied.promo.type.name,
|
||||||
|
'amount': applied.amount,
|
||||||
|
},
|
||||||
|
]),
|
||||||
'loyalty_value': cart.loyaltyRedemptionValue,
|
'loyalty_value': cart.loyaltyRedemptionValue,
|
||||||
'taxable_amount': cart.taxableAmount,
|
'taxable_amount': cart.taxableAmount,
|
||||||
'tax_amount': cart.taxAmount,
|
'tax_amount': cart.taxAmount,
|
||||||
@@ -132,20 +205,26 @@ class OrderDao {
|
|||||||
Future<List<SaleTransaction>> recent({int limit = 100}) =>
|
Future<List<SaleTransaction>> recent({int limit = 100}) =>
|
||||||
_query(orderBy: 'created_at DESC', limit: limit);
|
_query(orderBy: 'created_at DESC', limit: limit);
|
||||||
|
|
||||||
/// Bills for a day, optionally narrowed to one operator.
|
/// Bills for a day that have *not* yet been accepted by the server.
|
||||||
///
|
///
|
||||||
/// A shift report that is settled against a till has to cover exactly the
|
/// A shift report that is settled against a till has to cover exactly the
|
||||||
/// bills that cashier rang, not everything the terminal did that day.
|
/// bills that cashier rang, not everything the terminal did that day.
|
||||||
|
///
|
||||||
|
/// Restricted to pending rows on purpose. The moment a bill is accepted its
|
||||||
|
/// figures are folded into [Tables.dayArchive], and the report adds the two
|
||||||
|
/// together — so an accepted row still sitting here during its retention
|
||||||
|
/// window would be counted twice and inflate the day's takings.
|
||||||
Future<List<SaleTransaction>> forBusinessDate(
|
Future<List<SaleTransaction>> forBusinessDate(
|
||||||
DateTime day, {
|
DateTime day, {
|
||||||
String? cashierName,
|
String? cashierName,
|
||||||
}) =>
|
}) =>
|
||||||
_query(
|
_query(
|
||||||
where: cashierName == null
|
where: cashierName == null
|
||||||
? 'business_date = ?'
|
? 'business_date = ? AND sync_status = ?'
|
||||||
: 'business_date = ? AND cashier_name = ?',
|
: 'business_date = ? AND sync_status = ? AND cashier_name = ?',
|
||||||
whereArgs: [
|
whereArgs: [
|
||||||
businessDateOf(day),
|
businessDateOf(day),
|
||||||
|
pending,
|
||||||
if (cashierName != null) cashierName,
|
if (cashierName != null) cashierName,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -224,31 +303,43 @@ class OrderDao {
|
|||||||
return rows.isEmpty ? null : rows.first;
|
return rows.isEmpty ? null : rows.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Map<String, Object?>>> syncRows({int limit = 200}) => _db.query(
|
/// Rows for the sync log, with each bill's unit count folded in.
|
||||||
Tables.orders,
|
///
|
||||||
columns: [
|
/// The count comes from a correlated sum over [Tables.orderItems] rather
|
||||||
'id',
|
/// than a column on the order: quantity can be fractional (loose weight), so
|
||||||
'invoice_number',
|
/// there is no line count that answers "how many units were on this bill".
|
||||||
'total',
|
/// A single aggregate keeps this to one query rather than one per row.
|
||||||
'created_at',
|
Future<List<Map<String, Object?>>> syncRows({int limit = 200}) =>
|
||||||
'sync_status',
|
_db.rawQuery(
|
||||||
'synced_at',
|
'''
|
||||||
'sync_attempts',
|
SELECT o.id, o.invoice_number, o.total, o.created_at, o.sync_status,
|
||||||
'sync_error',
|
o.synced_at, o.sync_attempts, o.sync_error,
|
||||||
],
|
COALESCE(
|
||||||
orderBy: 'created_at DESC',
|
(SELECT SUM(i.quantity) FROM ${Tables.orderItems} i
|
||||||
limit: limit,
|
WHERE i.order_id = o.id),
|
||||||
|
0
|
||||||
|
) AS item_count
|
||||||
|
FROM ${Tables.orders} o
|
||||||
|
ORDER BY o.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
''',
|
||||||
|
[limit],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ----------------------------------------------------------------- Sync
|
// ----------------------------------------------------------------- Sync
|
||||||
/// Folds accepted orders into the day archive, then deletes them.
|
/// Folds accepted orders into the day archive and marks them synced.
|
||||||
///
|
///
|
||||||
/// Once the server holds a bill the terminal has no reason to keep it, so
|
/// Their figures are added to [Tables.dayArchive] so the shift totals a
|
||||||
/// the rows go. Their figures are added to [Tables.dayArchive] first, so the
|
/// cashier sees do not collapse after a mid-shift sync, and the rows
|
||||||
/// shift totals a cashier sees do not collapse after a mid-shift sync.
|
/// themselves are kept — at `sync_status = 1` — until [purgeSyncedBefore]
|
||||||
/// Both steps run in one transaction: if the delete fails the archive is
|
/// retires them. Keeping them buys a recovery window: if the back office
|
||||||
/// rolled back with it, and nothing is counted twice.
|
/// loses a batch, the full bills are still on the terminal and can be sent
|
||||||
Future<void> archiveAndDelete(List<SaleTransaction> orders) async {
|
/// again. Once deleted only the archived totals survive, and a lost bill's
|
||||||
|
/// line items are gone for good.
|
||||||
|
///
|
||||||
|
/// Both steps run in one transaction: if the status flip fails the archive
|
||||||
|
/// rolls back with it, and nothing is counted twice.
|
||||||
|
Future<void> archiveAccepted(List<SaleTransaction> orders) async {
|
||||||
if (orders.isEmpty) return;
|
if (orders.isEmpty) return;
|
||||||
|
|
||||||
await _db.transaction((txn) async {
|
await _db.transaction((txn) async {
|
||||||
@@ -331,17 +422,30 @@ class OrderDao {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// order_items goes with it via ON DELETE CASCADE.
|
|
||||||
final ids = orders.map((o) => o.id).toList();
|
final ids = orders.map((o) => o.id).toList();
|
||||||
final placeholders = List.filled(ids.length, '?').join(',');
|
final placeholders = List.filled(ids.length, '?').join(',');
|
||||||
await txn.delete(
|
await txn.rawUpdate(
|
||||||
Tables.orders,
|
'UPDATE ${Tables.orders} '
|
||||||
where: 'id IN ($placeholders)',
|
'SET sync_status = ?, synced_at = ?, sync_error = NULL '
|
||||||
whereArgs: ids,
|
'WHERE id IN ($placeholders)',
|
||||||
|
[synced, DateTime.now().millisecondsSinceEpoch, ...ids],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retires bills the server took delivery of more than [retention] ago.
|
||||||
|
///
|
||||||
|
/// Their archived totals are untouched and stay forever — this drops only the
|
||||||
|
/// re-sendable copy once the recovery window has closed, so a terminal that
|
||||||
|
/// trades for years does not carry every bill it has ever rung.
|
||||||
|
///
|
||||||
|
/// Returns how many rows went. `order_items` follows via ON DELETE CASCADE.
|
||||||
|
Future<int> purgeSyncedBefore(DateTime cutoff) => _db.delete(
|
||||||
|
Tables.orders,
|
||||||
|
where: 'sync_status = ? AND synced_at IS NOT NULL AND synced_at < ?',
|
||||||
|
whereArgs: [synced, cutoff.millisecondsSinceEpoch],
|
||||||
|
);
|
||||||
|
|
||||||
/// Archived figures for a business day, one row per cashier.
|
/// Archived figures for a business day, one row per cashier.
|
||||||
///
|
///
|
||||||
/// Empty when nothing has synced yet. Pass [cashierName] to scope it to a
|
/// Empty when nothing has synced yet. Pass [cashierName] to scope it to a
|
||||||
@@ -407,6 +511,36 @@ class OrderDao {
|
|||||||
await _db.delete(Tables.parkedBills, where: 'id = ?', whereArgs: [id]);
|
await _db.delete(Tables.parkedBills, where: 'id = ?', whereArgs: [id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rebuilds the campaigns recorded against a bill.
|
||||||
|
///
|
||||||
|
/// The stored rows carry the promo's name and amount rather than a live
|
||||||
|
/// lookup, so a campaign that has since been edited or deleted still prints
|
||||||
|
/// on a reissued receipt exactly as it was given.
|
||||||
|
static List<AppliedPromo> _promosFromRow(String? json) {
|
||||||
|
if (json == null || json.isEmpty) return const [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (jsonDecode(json) as List)
|
||||||
|
.cast<Map<String, Object?>>()
|
||||||
|
.map((p) => AppliedPromo(
|
||||||
|
promo: Promo(
|
||||||
|
id: (p['id'] as String?) ?? '',
|
||||||
|
name: (p['name'] as String?) ?? 'Promotion',
|
||||||
|
type: PromoType.values
|
||||||
|
.where((t) => t.name == p['type'])
|
||||||
|
.firstOrNull ??
|
||||||
|
PromoType.flatOffBill,
|
||||||
|
),
|
||||||
|
amount: (p['amount'] as num?)?.toDouble() ?? 0,
|
||||||
|
),)
|
||||||
|
.toList();
|
||||||
|
} on Object {
|
||||||
|
// A bill that cannot name its campaigns is still a valid bill; the total
|
||||||
|
// is on the row itself and does not depend on this.
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------- Internals
|
// -------------------------------------------------------------- Internals
|
||||||
Future<List<SaleTransaction>> _query({
|
Future<List<SaleTransaction>> _query({
|
||||||
String? where,
|
String? where,
|
||||||
@@ -499,19 +633,28 @@ class OrderDao {
|
|||||||
// payload, the day archive, the shift report — is overstated.
|
// payload, the day archive, the shift report — is overstated.
|
||||||
final billDiscount = (o['bill_discount']! as num).toDouble();
|
final billDiscount = (o['bill_discount']! as num).toDouble();
|
||||||
|
|
||||||
|
// Campaigns are restored so a reprinted receipt still names what the
|
||||||
|
// shopper was given. Their amounts are then *subtracted* from the manual
|
||||||
|
// discount, because `bill_discount` already contains them — restoring both
|
||||||
|
// at full value would discount the bill twice on the way back in.
|
||||||
|
final promos = _promosFromRow(o['promos_json'] as String?);
|
||||||
|
final promoTotal = promos.fold<double>(0, (sum, p) => sum + p.amount);
|
||||||
|
final manualDiscount = (billDiscount - promoTotal).clamp(0, billDiscount);
|
||||||
|
|
||||||
return SaleTransaction(
|
return SaleTransaction(
|
||||||
id: o['id']! as String,
|
id: o['id']! as String,
|
||||||
invoiceNumber: o['invoice_number']! as String,
|
invoiceNumber: o['invoice_number']! as String,
|
||||||
cart: Cart(
|
cart: Cart(
|
||||||
lines: lines,
|
lines: lines,
|
||||||
customer: customer,
|
customer: customer,
|
||||||
billDiscount: billDiscount > 0
|
billDiscount: manualDiscount > 0
|
||||||
? Discount(
|
? Discount(
|
||||||
type: DiscountType.flat,
|
type: DiscountType.flat,
|
||||||
value: billDiscount,
|
value: manualDiscount.toDouble(),
|
||||||
reason: 'Bill discount',
|
reason: 'Bill discount',
|
||||||
)
|
)
|
||||||
: Discount.none,
|
: Discount.none,
|
||||||
|
appliedPromos: promos,
|
||||||
pointsRedeemed: (o['points_redeemed'] as int?) ?? 0,
|
pointsRedeemed: (o['points_redeemed'] as int?) ?? 0,
|
||||||
),
|
),
|
||||||
payments: payments,
|
payments: payments,
|
||||||
|
|||||||
185
lib/data/local/promo_dao.dart
Normal file
185
lib/data/local/promo_dao.dart
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../../domain/entities/promo.dart';
|
||||||
|
import 'app_database.dart';
|
||||||
|
|
||||||
|
/// Raised when a campaign would be saved in a state the till cannot apply.
|
||||||
|
class PromoException implements Exception {
|
||||||
|
const PromoException(this.message);
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores campaigns on the terminal.
|
||||||
|
///
|
||||||
|
/// Local like everything else the till needs: a shop mid-promotion with a dead
|
||||||
|
/// line still has to honour the price on the shelf edge.
|
||||||
|
class PromoDao {
|
||||||
|
const PromoDao(this._db);
|
||||||
|
|
||||||
|
final Database _db;
|
||||||
|
|
||||||
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
|
Future<List<Promo>> all({bool activeOnly = false}) async {
|
||||||
|
final rows = await _db.query(
|
||||||
|
Tables.promos,
|
||||||
|
where: activeOnly ? 'is_active = 1' : null,
|
||||||
|
orderBy: 'priority ASC, created_at ASC',
|
||||||
|
);
|
||||||
|
return rows.map(_fromRow).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Promo?> findById(String id) async {
|
||||||
|
final rows = await _db.query(
|
||||||
|
Tables.promos,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
return rows.isEmpty ? null : _fromRow(rows.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts or updates. Returns the stored campaign, with its id.
|
||||||
|
Future<Promo> save(Promo promo) async {
|
||||||
|
_validate(promo);
|
||||||
|
|
||||||
|
final id = promo.id.isEmpty ? _uuid.v4() : promo.id;
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final existing = await findById(id);
|
||||||
|
|
||||||
|
await _db.insert(
|
||||||
|
Tables.promos,
|
||||||
|
{
|
||||||
|
'id': id,
|
||||||
|
'name': promo.name.trim(),
|
||||||
|
'type': promo.type.name,
|
||||||
|
'value': promo.value,
|
||||||
|
'target_id': promo.targetId,
|
||||||
|
'target_label': promo.targetLabel,
|
||||||
|
'buy_quantity': promo.buyQuantity,
|
||||||
|
'free_quantity': promo.freeQuantity,
|
||||||
|
'min_bill_value': promo.minBillValue,
|
||||||
|
'max_discount': promo.maxDiscount,
|
||||||
|
'valid_from': promo.validFrom?.millisecondsSinceEpoch,
|
||||||
|
'valid_to': promo.validTo?.millisecondsSinceEpoch,
|
||||||
|
'days_of_week': (promo.daysOfWeek.toList()..sort()).join(','),
|
||||||
|
'stackable': promo.stackable ? 1 : 0,
|
||||||
|
'priority': promo.priority,
|
||||||
|
'is_active': promo.isActive ? 1 : 0,
|
||||||
|
// Preserved on update so the list keeps a stable order.
|
||||||
|
'created_at': existing == null ? now : await _createdAt(id) ?? now,
|
||||||
|
'updated_at': now,
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Read back rather than echoing the input, so the caller gets exactly what
|
||||||
|
// the database holds — including the id minted for a new campaign.
|
||||||
|
return (await findById(id))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int?> _createdAt(String id) async {
|
||||||
|
final rows = await _db.query(
|
||||||
|
Tables.promos,
|
||||||
|
columns: ['created_at'],
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
return rows.isEmpty ? null : rows.first['created_at'] as int?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setActive(String id, {required bool active}) async {
|
||||||
|
await _db.update(
|
||||||
|
Tables.promos,
|
||||||
|
{
|
||||||
|
'is_active': active ? 1 : 0,
|
||||||
|
'updated_at': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hard delete. Unlike staff, nothing already recorded points at a promo row
|
||||||
|
/// — a bill stores the amount it was given, not a reference to the campaign,
|
||||||
|
/// so deleting one cannot change a past total.
|
||||||
|
Future<void> delete(String id) async {
|
||||||
|
await _db.delete(Tables.promos, where: 'id = ?', whereArgs: [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- Internals
|
||||||
|
static void _validate(Promo promo) {
|
||||||
|
if (promo.name.trim().isEmpty) {
|
||||||
|
throw const PromoException('A campaign needs a name.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promo.type.needsTarget &&
|
||||||
|
(promo.targetId == null || promo.targetId!.isEmpty)) {
|
||||||
|
throw const PromoException(
|
||||||
|
'This campaign needs a product or category to apply to.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promo.type == PromoType.buyXGetY) {
|
||||||
|
if (promo.buyQuantity < 1 || promo.freeQuantity < 1) {
|
||||||
|
throw const PromoException(
|
||||||
|
'Buy and free quantities must both be at least one.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (promo.value <= 0) {
|
||||||
|
throw const PromoException('A campaign must give something away.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promo.type.isPercentage && promo.value > 100) {
|
||||||
|
// Over 100% is a refund with extra steps.
|
||||||
|
throw const PromoException('A percentage cannot exceed 100.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final from = promo.validFrom;
|
||||||
|
final to = promo.validTo;
|
||||||
|
if (from != null && to != null && to.isBefore(from)) {
|
||||||
|
throw const PromoException('The end date is before the start date.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promo.daysOfWeek.any((d) => d < 1 || d > 7)) {
|
||||||
|
throw const PromoException('Days of the week must be 1 (Mon) to 7 (Sun).');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Promo _fromRow(Map<String, Object?> row) {
|
||||||
|
final days = (row['days_of_week'] as String? ?? '')
|
||||||
|
.split(',')
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.map(int.parse)
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
return Promo(
|
||||||
|
id: row['id']! as String,
|
||||||
|
name: row['name']! as String,
|
||||||
|
type: PromoType.values.byName(row['type']! as String),
|
||||||
|
value: (row['value'] as num? ?? 0).toDouble(),
|
||||||
|
targetId: row['target_id'] as String?,
|
||||||
|
targetLabel: row['target_label'] as String?,
|
||||||
|
buyQuantity: (row['buy_quantity'] as int?) ?? 0,
|
||||||
|
freeQuantity: (row['free_quantity'] as int?) ?? 0,
|
||||||
|
minBillValue: (row['min_bill_value'] as num? ?? 0).toDouble(),
|
||||||
|
maxDiscount: (row['max_discount'] as num?)?.toDouble(),
|
||||||
|
validFrom: row['valid_from'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.fromMillisecondsSinceEpoch(row['valid_from']! as int),
|
||||||
|
validTo: row['valid_to'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.fromMillisecondsSinceEpoch(row['valid_to']! as int),
|
||||||
|
daysOfWeek: days,
|
||||||
|
stackable: (row['stackable'] as int? ?? 0) == 1,
|
||||||
|
priority: (row['priority'] as int?) ?? 100,
|
||||||
|
isActive: (row['is_active'] as int? ?? 1) == 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
279
lib/data/local/staff_dao.dart
Normal file
279
lib/data/local/staff_dao.dart
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../../core/security/pin_hasher.dart';
|
||||||
|
import '../../domain/entities/store_account.dart';
|
||||||
|
import 'app_database.dart';
|
||||||
|
|
||||||
|
/// Raised when a staff change would leave the terminal unusable or unowned.
|
||||||
|
class StaffException implements Exception {
|
||||||
|
const StaffException(this.message);
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who can sign in at this till.
|
||||||
|
///
|
||||||
|
/// The PIN is never stored, only a PBKDF2 hash and its salt — so a stolen
|
||||||
|
/// database file does not hand over the terminal, and neither does an unzipped
|
||||||
|
/// APK, which is what the previous hardcoded literals did.
|
||||||
|
class StaffDao {
|
||||||
|
const StaffDao(this._db);
|
||||||
|
|
||||||
|
final Database _db;
|
||||||
|
|
||||||
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
|
/// The accounts a shop starts with.
|
||||||
|
///
|
||||||
|
/// Deliberately not 1234/2345/3456: those are the first thing anyone tries,
|
||||||
|
/// and [_assertPinIsAcceptable] refuses them for exactly that reason — a
|
||||||
|
/// seed the rule itself would reject is not a defensible default.
|
||||||
|
///
|
||||||
|
/// They are still known values in source, which is why every one is flagged
|
||||||
|
/// [StaffUser.mustChangePin]. They get a shop trading on day one and are
|
||||||
|
/// replaced at first sign-in, rather than becoming the permanent credentials
|
||||||
|
/// the way the old hardcoded PINs did.
|
||||||
|
static const seedAccounts = [
|
||||||
|
(name: 'Suriya', role: StaffRole.admin, pin: '4821'),
|
||||||
|
(name: 'Divya', role: StaffRole.manager, pin: '5093'),
|
||||||
|
(name: 'Rahul', role: StaffRole.cashier, pin: '6274'),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Creates the starting accounts the first time a terminal runs.
|
||||||
|
///
|
||||||
|
/// Idempotent: a terminal that already has staff is left alone, so an upgrade
|
||||||
|
/// never resurrects a deleted account or resets a PIN someone chose.
|
||||||
|
Future<void> seedIfEmpty() async {
|
||||||
|
final existing = await _db.rawQuery(
|
||||||
|
'SELECT COUNT(*) AS c FROM ${Tables.staff}',
|
||||||
|
);
|
||||||
|
if ((existing.first['c']! as int) > 0) return;
|
||||||
|
|
||||||
|
for (final account in seedAccounts) {
|
||||||
|
await create(
|
||||||
|
name: account.name,
|
||||||
|
role: account.role,
|
||||||
|
pin: account.pin,
|
||||||
|
mustChangePin: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<StaffUser>> all({bool includeInactive = false}) async {
|
||||||
|
final rows = await _db.query(
|
||||||
|
Tables.staff,
|
||||||
|
where: includeInactive ? null : 'is_active = 1',
|
||||||
|
orderBy: 'created_at ASC',
|
||||||
|
);
|
||||||
|
return rows.map(_fromRow).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StaffUser?> findById(String id) async {
|
||||||
|
final rows = await _db.query(
|
||||||
|
Tables.staff,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
return rows.isEmpty ? null : _fromRow(rows.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks a PIN and returns whose it is.
|
||||||
|
///
|
||||||
|
/// Every active account is tried, because a cashier types only a PIN — there
|
||||||
|
/// is no username at the till. Returns null on no match, without saying
|
||||||
|
/// whether the PIN was close.
|
||||||
|
Future<StaffUser?> authenticate(String pin) async {
|
||||||
|
final rows = await _db.query(Tables.staff, where: 'is_active = 1');
|
||||||
|
|
||||||
|
for (final row in rows) {
|
||||||
|
final matches = PinHasher.verify(
|
||||||
|
pin,
|
||||||
|
salt: row['pin_salt']! as String,
|
||||||
|
hash: row['pin_hash']! as String,
|
||||||
|
);
|
||||||
|
if (matches) return _fromRow(row);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StaffUser> create({
|
||||||
|
required String name,
|
||||||
|
required StaffRole role,
|
||||||
|
required String pin,
|
||||||
|
bool mustChangePin = false,
|
||||||
|
}) async {
|
||||||
|
_assertPinIsAcceptable(pin);
|
||||||
|
|
||||||
|
final trimmed = name.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
throw const StaffException('A staff member needs a name.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two people sharing a PIN would make the till attribute bills to whichever
|
||||||
|
// row happened to be checked first.
|
||||||
|
if (await authenticate(pin) != null) {
|
||||||
|
throw const StaffException(
|
||||||
|
'Another staff member already uses that PIN. Choose a different one.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final salt = PinHasher.newSalt();
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final id = _uuid.v4();
|
||||||
|
|
||||||
|
await _db.insert(Tables.staff, {
|
||||||
|
'id': id,
|
||||||
|
'name': trimmed,
|
||||||
|
'role': role.name,
|
||||||
|
'pin_hash': PinHasher.hash(pin, salt),
|
||||||
|
'pin_salt': salt,
|
||||||
|
'must_change_pin': mustChangePin ? 1 : 0,
|
||||||
|
'is_active': 1,
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return StaffUser(
|
||||||
|
id: id,
|
||||||
|
name: trimmed,
|
||||||
|
role: role,
|
||||||
|
mustChangePin: mustChangePin,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateDetails({
|
||||||
|
required String id,
|
||||||
|
String? name,
|
||||||
|
StaffRole? role,
|
||||||
|
}) async {
|
||||||
|
if (role != null) await _assertNotLastAdmin(id, newRole: role);
|
||||||
|
|
||||||
|
await _db.update(
|
||||||
|
Tables.staff,
|
||||||
|
{
|
||||||
|
if (name != null) 'name': name.trim(),
|
||||||
|
if (role != null) 'role': role.name,
|
||||||
|
'updated_at': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets a new PIN. [mustChangePin] is for an admin resetting someone else's;
|
||||||
|
/// a person choosing their own clears the flag.
|
||||||
|
Future<void> setPin(
|
||||||
|
String id,
|
||||||
|
String pin, {
|
||||||
|
bool mustChangePin = false,
|
||||||
|
}) async {
|
||||||
|
_assertPinIsAcceptable(pin);
|
||||||
|
|
||||||
|
final owner = await authenticate(pin);
|
||||||
|
if (owner != null && owner.id != id) {
|
||||||
|
throw const StaffException(
|
||||||
|
'Another staff member already uses that PIN. Choose a different one.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final salt = PinHasher.newSalt();
|
||||||
|
await _db.update(
|
||||||
|
Tables.staff,
|
||||||
|
{
|
||||||
|
'pin_hash': PinHasher.hash(pin, salt),
|
||||||
|
'pin_salt': salt,
|
||||||
|
'must_change_pin': mustChangePin ? 1 : 0,
|
||||||
|
'updated_at': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deactivates rather than deletes.
|
||||||
|
///
|
||||||
|
/// Bills carry the cashier's name, and reports are settled against it. A hard
|
||||||
|
/// delete would leave yesterday's takings attributed to nobody.
|
||||||
|
Future<void> deactivate(String id) async {
|
||||||
|
await _assertNotLastAdmin(id, deactivating: true);
|
||||||
|
|
||||||
|
await _db.update(
|
||||||
|
Tables.staff,
|
||||||
|
{
|
||||||
|
'is_active': 0,
|
||||||
|
'updated_at': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reactivate(String id) async {
|
||||||
|
await _db.update(
|
||||||
|
Tables.staff,
|
||||||
|
{
|
||||||
|
'is_active': 1,
|
||||||
|
'updated_at': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- Internals
|
||||||
|
static void _assertPinIsAcceptable(String pin) {
|
||||||
|
if (pin.length < 4 || int.tryParse(pin) == null) {
|
||||||
|
throw const StaffException('A PIN must be at least four digits.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not security theatre: on a keypad behind a counter these are the ones a
|
||||||
|
// queue can read off the operator's hand.
|
||||||
|
const tooObvious = {'0000', '1111', '2222', '3333', '4444', '5555', '6666',
|
||||||
|
'7777', '8888', '9999', '1234', '4321', '0123',};
|
||||||
|
if (tooObvious.contains(pin)) {
|
||||||
|
throw const StaffException(
|
||||||
|
'That PIN is too easy to guess from across the counter. '
|
||||||
|
'Choose another.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A till with no admin cannot be administered — including to make someone an
|
||||||
|
/// admin again. Recovering from it means editing the database by hand.
|
||||||
|
Future<void> _assertNotLastAdmin(
|
||||||
|
String id, {
|
||||||
|
StaffRole? newRole,
|
||||||
|
bool deactivating = false,
|
||||||
|
}) async {
|
||||||
|
final target = await findById(id);
|
||||||
|
if (target == null || target.role != StaffRole.admin) return;
|
||||||
|
|
||||||
|
final losingAdmin = deactivating || (newRole != StaffRole.admin);
|
||||||
|
if (!losingAdmin) return;
|
||||||
|
|
||||||
|
final admins = await _db.rawQuery(
|
||||||
|
'SELECT COUNT(*) AS c FROM ${Tables.staff} '
|
||||||
|
"WHERE role = 'admin' AND is_active = 1",
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((admins.first['c']! as int) <= 1) {
|
||||||
|
throw const StaffException(
|
||||||
|
'This is the only admin left. Promote someone else first, or the '
|
||||||
|
'terminal cannot be administered at all.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static StaffUser _fromRow(Map<String, Object?> row) => StaffUser(
|
||||||
|
id: row['id']! as String,
|
||||||
|
name: row['name']! as String,
|
||||||
|
role: StaffRole.values.byName(row['role']! as String),
|
||||||
|
mustChangePin: (row['must_change_pin'] as int? ?? 0) == 1,
|
||||||
|
isActive: (row['is_active'] as int? ?? 1) == 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
105
lib/data/local/sync_config_store.dart
Normal file
105
lib/data/local/sync_config_store.dart
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
import '../../core/config/sync_config.dart';
|
||||||
|
import 'app_database.dart';
|
||||||
|
import 'catalogue_dao.dart';
|
||||||
|
|
||||||
|
/// Persists how this terminal reaches the back office.
|
||||||
|
///
|
||||||
|
/// Split deliberately across two stores. Which broker, on which port, over TLS
|
||||||
|
/// — that is configuration, and it goes in the database where it can be read
|
||||||
|
/// during support. The username, password and API key are credentials, and go
|
||||||
|
/// to the platform keystore: Keychain on macOS, Credential Manager on Windows,
|
||||||
|
/// the Android Keystore on a tablet.
|
||||||
|
///
|
||||||
|
/// Writing them into SQLite would put them in the same file as the bills, on a
|
||||||
|
/// machine behind a shop counter, readable by anything that can open it.
|
||||||
|
class SyncConfigStore {
|
||||||
|
SyncConfigStore(this._catalogue, {FlutterSecureStorage? secureStorage})
|
||||||
|
: _secure = secureStorage ?? const FlutterSecureStorage();
|
||||||
|
|
||||||
|
final CatalogueDao _catalogue;
|
||||||
|
final FlutterSecureStorage _secure;
|
||||||
|
|
||||||
|
static const _kUsername = 'sync.username';
|
||||||
|
static const _kPassword = 'sync.password';
|
||||||
|
static const _kApiKey = 'sync.api_key';
|
||||||
|
|
||||||
|
/// Reads the stored configuration, falling back to [fallback] per field.
|
||||||
|
///
|
||||||
|
/// The fallback carries the terminal's own store and terminal ids, which are
|
||||||
|
/// never overwritten from here — they belong to the device's identity.
|
||||||
|
Future<SyncConfig> load(SyncConfig fallback) async {
|
||||||
|
final transport = await _catalogue.meta(MetaKeys.syncTransport);
|
||||||
|
final host = await _catalogue.meta(MetaKeys.syncBrokerHost);
|
||||||
|
final port = await _catalogue.meta(MetaKeys.syncBrokerPort);
|
||||||
|
final tls = await _catalogue.meta(MetaKeys.syncUseTls);
|
||||||
|
final httpUrl = await _catalogue.meta(MetaKeys.syncHttpBaseUrl);
|
||||||
|
|
||||||
|
final credentials = await _readCredentials();
|
||||||
|
|
||||||
|
return fallback.copyWith(
|
||||||
|
transport: TransportKind.values
|
||||||
|
.where((k) => k.name == transport)
|
||||||
|
.firstOrNull ??
|
||||||
|
fallback.transport,
|
||||||
|
brokerHost: host ?? fallback.brokerHost,
|
||||||
|
brokerPort: int.tryParse(port ?? '') ?? fallback.brokerPort,
|
||||||
|
useTls: tls == null ? fallback.useTls : tls == '1',
|
||||||
|
httpBaseUrl: httpUrl ?? fallback.httpBaseUrl,
|
||||||
|
username: credentials.username,
|
||||||
|
password: credentials.password,
|
||||||
|
apiKey: credentials.apiKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> save(SyncConfig config) async {
|
||||||
|
await _catalogue.setMeta(MetaKeys.syncTransport, config.transport.name);
|
||||||
|
await _catalogue.setMeta(MetaKeys.syncBrokerHost, config.brokerHost);
|
||||||
|
await _catalogue.setMeta(MetaKeys.syncBrokerPort, '${config.brokerPort}');
|
||||||
|
await _catalogue.setMeta(MetaKeys.syncUseTls, config.useTls ? '1' : '0');
|
||||||
|
await _catalogue.setMeta(MetaKeys.syncHttpBaseUrl, config.httpBaseUrl);
|
||||||
|
|
||||||
|
await _writeSecret(_kUsername, config.username);
|
||||||
|
await _writeSecret(_kPassword, config.password);
|
||||||
|
await _writeSecret(_kApiKey, config.apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<({String? username, String? password, String? apiKey})>
|
||||||
|
_readCredentials() async {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
username: await _secure.read(key: _kUsername),
|
||||||
|
password: await _secure.read(key: _kPassword),
|
||||||
|
apiKey: await _secure.read(key: _kApiKey),
|
||||||
|
);
|
||||||
|
} on Object catch (e) {
|
||||||
|
// No keystore — a headless test host, or a Linux box with no secret
|
||||||
|
// service. The terminal still runs; it just cannot authenticate until
|
||||||
|
// someone re-enters the credentials, which is the safe way to fail.
|
||||||
|
debugPrint('Secure storage unavailable, credentials not loaded: $e');
|
||||||
|
return (username: null, password: null, apiKey: null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _writeSecret(String key, String? value) async {
|
||||||
|
try {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
await _secure.delete(key: key);
|
||||||
|
} else {
|
||||||
|
await _secure.write(key: key, value: value);
|
||||||
|
}
|
||||||
|
} on Object catch (e) {
|
||||||
|
debugPrint('Could not persist $key to secure storage: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wipes stored credentials. Used when a terminal is handed on or re-pointed
|
||||||
|
/// at a different back office.
|
||||||
|
Future<void> clearCredentials() async {
|
||||||
|
for (final key in [_kUsername, _kPassword, _kApiKey]) {
|
||||||
|
await _writeSecret(key, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
lib/data/local/terminal_identity.dart
Normal file
112
lib/data/local/terminal_identity.dart
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import 'app_database.dart';
|
||||||
|
import 'catalogue_dao.dart';
|
||||||
|
|
||||||
|
/// Who this till is, across a fleet.
|
||||||
|
///
|
||||||
|
/// Everything that has to be distinguishable between 100 installed terminals
|
||||||
|
/// hangs off this: MQTT topics, presence records, invoice numbers, and which
|
||||||
|
/// cashier's shift a bill belongs to. Before it existed every device called
|
||||||
|
/// itself `TERM-01`, so their topics collided, the fleet board showed one
|
||||||
|
/// terminal, and 100 tills minted the same invoice number.
|
||||||
|
class TerminalIdentity {
|
||||||
|
const TerminalIdentity({
|
||||||
|
required this.deviceId,
|
||||||
|
required this.code,
|
||||||
|
required this.name,
|
||||||
|
required this.storeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Minted once, on first run, and never changed. The stable machine identity
|
||||||
|
/// — reinstalling the app on the same till keeps it, because it lives in the
|
||||||
|
/// database rather than in memory.
|
||||||
|
final String deviceId;
|
||||||
|
|
||||||
|
/// Short, unique, and safe inside an invoice number: `T4A9`.
|
||||||
|
final String code;
|
||||||
|
|
||||||
|
/// What a person calls it. Free text, and duplicates are the shop's problem,
|
||||||
|
/// not the system's — nothing keys on it.
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
final String storeId;
|
||||||
|
|
||||||
|
/// Used as the MQTT client id and in every topic. Stable across restarts so
|
||||||
|
/// the broker can resume a session rather than treating each launch as a new
|
||||||
|
/// client.
|
||||||
|
String get clientId => 'pos-$storeId-$code';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$name ($code)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the terminal's identity from the database, minting it on first run.
|
||||||
|
class TerminalIdentityStore {
|
||||||
|
const TerminalIdentityStore(this._catalogue);
|
||||||
|
|
||||||
|
final CatalogueDao _catalogue;
|
||||||
|
|
||||||
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
|
/// Loads the identity, creating one the first time this device is started.
|
||||||
|
///
|
||||||
|
/// The mint is idempotent: an existing device id is never replaced, so a
|
||||||
|
/// terminal cannot silently change identity and orphan its own history.
|
||||||
|
///
|
||||||
|
/// [defaultStoreId] matches the store this build's default HTTP endpoint
|
||||||
|
/// serves — see `syncConfigProvider` — so a fresh terminal's first import
|
||||||
|
/// pulls that store's real catalogue without anyone visiting Settings
|
||||||
|
/// first. Settings → Connectivity & sync → Configure changes it per
|
||||||
|
/// terminal from there.
|
||||||
|
Future<TerminalIdentity> load({String defaultStoreId = '1135'}) async {
|
||||||
|
var deviceId = await _catalogue.meta(MetaKeys.deviceId);
|
||||||
|
var code = await _catalogue.meta(MetaKeys.terminalCode);
|
||||||
|
|
||||||
|
if (deviceId == null || deviceId.isEmpty) {
|
||||||
|
deviceId = _uuid.v4();
|
||||||
|
await _catalogue.setMeta(MetaKeys.deviceId, deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code == null || code.isEmpty) {
|
||||||
|
code = codeFor(deviceId);
|
||||||
|
await _catalogue.setMeta(MetaKeys.terminalCode, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
final name = await _catalogue.meta(MetaKeys.terminalName);
|
||||||
|
final storeId = await _catalogue.meta(MetaKeys.storeId);
|
||||||
|
|
||||||
|
return TerminalIdentity(
|
||||||
|
deviceId: deviceId,
|
||||||
|
code: code,
|
||||||
|
name: (name == null || name.isEmpty) ? 'Terminal $code' : name,
|
||||||
|
storeId: (storeId == null || storeId.isEmpty) ? defaultStoreId : storeId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `T` plus four hex characters of the device id.
|
||||||
|
///
|
||||||
|
/// Short enough to read off a screen and repeat over the phone, and with
|
||||||
|
/// 65,536 values a 100-terminal fleet has roughly a 7% chance of a collision
|
||||||
|
/// somewhere in it — which is why [rename] exists and why the back office
|
||||||
|
/// should reject a duplicate rather than assume uniqueness.
|
||||||
|
static String codeFor(String deviceId) {
|
||||||
|
final hex = deviceId.replaceAll('-', '');
|
||||||
|
return 'T${hex.substring(0, 4).toUpperCase()}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-codes a terminal, for when head office wants readable numbers or two
|
||||||
|
/// devices in one shop happened to collide.
|
||||||
|
///
|
||||||
|
/// The device id is deliberately untouched: history already written under the
|
||||||
|
/// old code keeps pointing at the same physical till.
|
||||||
|
Future<void> rename({String? code, String? name, String? storeId}) async {
|
||||||
|
if (code != null && code.isNotEmpty) {
|
||||||
|
await _catalogue.setMeta(MetaKeys.terminalCode, code.toUpperCase());
|
||||||
|
}
|
||||||
|
if (name != null) await _catalogue.setMeta(MetaKeys.terminalName, name);
|
||||||
|
if (storeId != null && storeId.isNotEmpty) {
|
||||||
|
await _catalogue.setMeta(MetaKeys.storeId, storeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
77
lib/data/remote/catalogue_source.dart
Normal file
77
lib/data/remote/catalogue_source.dart
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import '../../domain/entities/customer.dart';
|
||||||
|
import '../../domain/entities/product.dart';
|
||||||
|
|
||||||
|
/// What one catalogue pull returned.
|
||||||
|
class CatalogueSnapshot {
|
||||||
|
const CatalogueSnapshot({
|
||||||
|
required this.products,
|
||||||
|
required this.customers,
|
||||||
|
required this.fetchedAt,
|
||||||
|
required this.revision,
|
||||||
|
this.isDelta = false,
|
||||||
|
this.retiredProductIds = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<Product> products;
|
||||||
|
final List<Customer> customers;
|
||||||
|
final DateTime fetchedAt;
|
||||||
|
|
||||||
|
/// Server-side catalogue version, so the cashier can tell whether a
|
||||||
|
/// re-import actually changed anything, and so the next pull can ask for
|
||||||
|
/// only what has moved since.
|
||||||
|
final String revision;
|
||||||
|
|
||||||
|
/// Whether this is a change set rather than the whole catalogue.
|
||||||
|
///
|
||||||
|
/// The distinction matters at the point of applying it: a full snapshot
|
||||||
|
/// withdraws anything it does not mention, a delta must not.
|
||||||
|
final bool isDelta;
|
||||||
|
|
||||||
|
/// Products the back office has withdrawn. Only meaningful on a delta —
|
||||||
|
/// a full snapshot expresses the same thing by omission.
|
||||||
|
final List<String> retiredProductIds;
|
||||||
|
|
||||||
|
bool get isEmpty =>
|
||||||
|
products.isEmpty && customers.isEmpty && retiredProductIds.isEmpty;
|
||||||
|
|
||||||
|
int get changeCount =>
|
||||||
|
products.length + customers.length + retiredProductIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raised when the catalogue cannot be pulled.
|
||||||
|
class CatalogueSyncException implements Exception {
|
||||||
|
const CatalogueSyncException(this.message, {this.retryable = true});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
/// False for a bad credential or an unconfigured endpoint — retrying cannot
|
||||||
|
/// fix either, and the cashier needs to be told rather than watched to spin.
|
||||||
|
final bool retryable;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the terminal gets its products and customers.
|
||||||
|
///
|
||||||
|
/// The counterpart to `OrderTransport`: that one is how bills leave, this is
|
||||||
|
/// how the catalogue arrives. Same shape, for the same reason — the repository
|
||||||
|
/// should not know or care whether the answer came from a real endpoint or a
|
||||||
|
/// local stub.
|
||||||
|
abstract class CatalogueSource {
|
||||||
|
/// Shown in the events log, so a cashier reporting a problem can say which
|
||||||
|
/// route the terminal was using.
|
||||||
|
String get label;
|
||||||
|
|
||||||
|
/// Pulls the catalogue.
|
||||||
|
///
|
||||||
|
/// Passing [since] asks for only what has changed, and an implementation
|
||||||
|
/// that cannot do deltas is free to ignore it and return everything — the
|
||||||
|
/// caller checks [CatalogueSnapshot.isDelta] rather than assuming.
|
||||||
|
Future<CatalogueSnapshot> fetch({
|
||||||
|
String? since,
|
||||||
|
void Function(double progress, String stage)? onProgress,
|
||||||
|
});
|
||||||
|
|
||||||
|
void dispose() {}
|
||||||
|
}
|
||||||
178
lib/data/remote/catalogue_wire.dart
Normal file
178
lib/data/remote/catalogue_wire.dart
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import '../../core/constants/app_constants.dart';
|
||||||
|
import '../../domain/entities/customer.dart';
|
||||||
|
import '../../domain/entities/product.dart';
|
||||||
|
|
||||||
|
/// Raised when the back office sends something this build cannot read.
|
||||||
|
class CatalogueFormatException implements Exception {
|
||||||
|
const CatalogueFormatException(this.message);
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translates between the back office's JSON and the terminal's entities.
|
||||||
|
///
|
||||||
|
/// Deliberately tolerant in one direction and strict in the other. A missing
|
||||||
|
/// optional field takes a sensible default, because a catalogue of 4,000
|
||||||
|
/// products should not fail to import over one absent emoji. A missing
|
||||||
|
/// *required* field throws, because a product with no price or no barcode
|
||||||
|
/// cannot be sold and silently dropping it would leave a shelf item that
|
||||||
|
/// scans to nothing.
|
||||||
|
class CatalogueWire {
|
||||||
|
const CatalogueWire._();
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- Products
|
||||||
|
static Product productFromJson(Map<String, Object?> json) {
|
||||||
|
final id = _requireString(json, 'id');
|
||||||
|
|
||||||
|
return Product(
|
||||||
|
id: id,
|
||||||
|
name: _requireString(json, 'name', context: id),
|
||||||
|
barcode: _requireString(json, 'barcode', context: id),
|
||||||
|
sku: (json['sku'] as String?) ?? id,
|
||||||
|
category: _category(json['category']),
|
||||||
|
price: _requireNumber(json, 'price', context: id),
|
||||||
|
// Absent stock means "not tracked", not "none left" — a terminal that
|
||||||
|
// read it as zero would refuse to sell the item at all.
|
||||||
|
stock: (json['stock'] as num?)?.toDouble() ?? 0,
|
||||||
|
mrp: (json['mrp'] as num?)?.toDouble(),
|
||||||
|
emoji: (json['emoji'] as String?) ?? '📦',
|
||||||
|
imageUrl: json['image_url'] as String?,
|
||||||
|
unit: _unit(json['unit']),
|
||||||
|
gstRate: _gstRate(json['gst_rate']),
|
||||||
|
hsnCode: json['hsn_code'] as String?,
|
||||||
|
brand: json['brand'] as String?,
|
||||||
|
// Absent means active. A back office that omits the flag is not saying
|
||||||
|
// its whole catalogue is withdrawn.
|
||||||
|
isActive: json['is_active'] as bool? ?? true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, Object?> productToJson(Product p) => {
|
||||||
|
'id': p.id,
|
||||||
|
'name': p.name,
|
||||||
|
'barcode': p.barcode,
|
||||||
|
'sku': p.sku,
|
||||||
|
'category': p.category.name,
|
||||||
|
'price': p.price,
|
||||||
|
'mrp': p.mrp,
|
||||||
|
'stock': p.stock,
|
||||||
|
'emoji': p.emoji,
|
||||||
|
'image_url': p.imageUrl,
|
||||||
|
'unit': p.unit.name,
|
||||||
|
'gst_rate': p.gstRate,
|
||||||
|
'hsn_code': p.hsnCode,
|
||||||
|
'brand': p.brand,
|
||||||
|
'is_active': p.isActive,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ Customers
|
||||||
|
static Customer customerFromJson(Map<String, Object?> json) {
|
||||||
|
final id = _requireString(json, 'id');
|
||||||
|
|
||||||
|
return Customer(
|
||||||
|
id: id,
|
||||||
|
name: _requireString(json, 'name', context: id),
|
||||||
|
mobile: _requireString(json, 'mobile', context: id),
|
||||||
|
email: json['email'] as String?,
|
||||||
|
gender: Gender.values
|
||||||
|
.where((g) => g.name == json['gender'])
|
||||||
|
.firstOrNull ??
|
||||||
|
Gender.unspecified,
|
||||||
|
dateOfBirth: _date(json['date_of_birth']),
|
||||||
|
loyaltyPoints: (json['loyalty_points'] as num?)?.toInt() ?? 0,
|
||||||
|
lifetimeSpend: (json['lifetime_spend'] as num?)?.toDouble() ?? 0,
|
||||||
|
visitCount: (json['visit_count'] as num?)?.toInt() ?? 0,
|
||||||
|
createdAt: _date(json['created_at']),
|
||||||
|
lastVisitAt: _date(json['last_visit_at']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, Object?> customerToJson(Customer c) => {
|
||||||
|
'id': c.id,
|
||||||
|
'name': c.name,
|
||||||
|
'mobile': c.mobile,
|
||||||
|
'email': c.email,
|
||||||
|
'gender': c.gender.name,
|
||||||
|
'date_of_birth': c.dateOfBirth?.toIso8601String(),
|
||||||
|
'loyalty_points': c.loyaltyPoints,
|
||||||
|
'lifetime_spend': c.lifetimeSpend,
|
||||||
|
'visit_count': c.visitCount,
|
||||||
|
'created_at': c.createdAt?.toIso8601String(),
|
||||||
|
'last_visit_at': c.lastVisitAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ Internals
|
||||||
|
static String _requireString(
|
||||||
|
Map<String, Object?> json,
|
||||||
|
String key, {
|
||||||
|
String? context,
|
||||||
|
}) {
|
||||||
|
final value = json[key];
|
||||||
|
if (value is String && value.trim().isNotEmpty) return value.trim();
|
||||||
|
|
||||||
|
throw CatalogueFormatException(
|
||||||
|
'Product or customer${context == null ? '' : ' $context'} has no "$key". '
|
||||||
|
'The terminal cannot sell or identify a record without it.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _requireNumber(
|
||||||
|
Map<String, Object?> json,
|
||||||
|
String key, {
|
||||||
|
String? context,
|
||||||
|
}) {
|
||||||
|
final value = json[key];
|
||||||
|
if (value is num) return value.toDouble();
|
||||||
|
|
||||||
|
throw CatalogueFormatException(
|
||||||
|
'Product${context == null ? '' : ' $context'} has no numeric "$key".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Falls back rather than throwing.
|
||||||
|
///
|
||||||
|
/// A category the terminal does not recognise is a display problem — the
|
||||||
|
/// item still scans, still prices, still bills. Refusing the whole import
|
||||||
|
/// over one would be a far worse outcome than filing it under Grocery.
|
||||||
|
static ProductCategory _category(Object? raw) {
|
||||||
|
if (raw is! String) return ProductCategory.grocery;
|
||||||
|
final needle = raw.trim().toLowerCase();
|
||||||
|
|
||||||
|
return ProductCategory.values.firstWhere(
|
||||||
|
(c) => c.name.toLowerCase() == needle || c.label.toLowerCase() == needle,
|
||||||
|
orElse: () => ProductCategory.grocery,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static UnitOfMeasure _unit(Object? raw) {
|
||||||
|
if (raw is! String) return UnitOfMeasure.piece;
|
||||||
|
final needle = raw.trim().toLowerCase();
|
||||||
|
|
||||||
|
return UnitOfMeasure.values.firstWhere(
|
||||||
|
(u) =>
|
||||||
|
u.name.toLowerCase() == needle ||
|
||||||
|
u.symbol.toLowerCase() == needle,
|
||||||
|
orElse: () => UnitOfMeasure.piece,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepts a fraction (0.18) or a percentage (18), because back offices
|
||||||
|
/// disagree about which they mean and getting it wrong silently changes the
|
||||||
|
/// tax on every line.
|
||||||
|
static double _gstRate(Object? raw) {
|
||||||
|
if (raw is! num) return AppConstants.defaultGstRate;
|
||||||
|
final value = raw.toDouble();
|
||||||
|
if (value < 0) return AppConstants.defaultGstRate;
|
||||||
|
return value > 1 ? value / 100 : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime? _date(Object? raw) {
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (raw is num) return DateTime.fromMillisecondsSinceEpoch(raw.toInt());
|
||||||
|
if (raw is String) return DateTime.tryParse(raw);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
213
lib/data/remote/http_catalogue_source.dart
Normal file
213
lib/data/remote/http_catalogue_source.dart
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../core/config/sync_config.dart';
|
||||||
|
import '../../domain/entities/customer.dart';
|
||||||
|
import '../../domain/entities/product.dart';
|
||||||
|
import 'catalogue_source.dart';
|
||||||
|
import 'catalogue_wire.dart';
|
||||||
|
|
||||||
|
/// Pulls the catalogue from the back office over HTTP.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// GET {base}/catalogue?since={revision}&page={n}&page_size={pageSize}&store_id={storeId}
|
||||||
|
/// Authorization: Bearer {apiKey}
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Pages are 0-indexed — the first page requested is `page=0` — matching the
|
||||||
|
/// back office's own convention rather than the more common 1-indexed one.
|
||||||
|
///
|
||||||
|
/// ```json
|
||||||
|
/// {
|
||||||
|
/// "revision": "rev-8821",
|
||||||
|
/// "is_delta": true,
|
||||||
|
/// "has_more": false,
|
||||||
|
/// "products": [ … ],
|
||||||
|
/// "customers": [ … ],
|
||||||
|
/// "retired_product_ids": ["sku-9912"]
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ### Why paged
|
||||||
|
///
|
||||||
|
/// A supermarket catalogue is tens of thousands of rows. Asking for it in one
|
||||||
|
/// response means a request that times out on a shop's line and a JSON decode
|
||||||
|
/// that stalls the UI thread for seconds. Pages arrive, get counted, and the
|
||||||
|
/// progress bar moves — which is also the difference between a cashier waiting
|
||||||
|
/// and a cashier restarting the app.
|
||||||
|
///
|
||||||
|
/// ### Why deltas
|
||||||
|
///
|
||||||
|
/// `since` carries the revision the terminal already holds. The back office
|
||||||
|
/// answers with what has moved since, which on a normal morning is a handful of
|
||||||
|
/// price changes rather than the whole book. A server that cannot do deltas
|
||||||
|
/// ignores the parameter and answers `is_delta: false`; the terminal reads the
|
||||||
|
/// flag rather than assuming, so both work.
|
||||||
|
class HttpCatalogueSource implements CatalogueSource {
|
||||||
|
HttpCatalogueSource({required this.config, http.Client? client})
|
||||||
|
: _client = client ?? http.Client();
|
||||||
|
|
||||||
|
final SyncConfig config;
|
||||||
|
final http.Client _client;
|
||||||
|
|
||||||
|
/// Guards against a server that always answers `has_more: true`. Without it
|
||||||
|
/// a bad deployment turns into an infinite request loop against a shop's
|
||||||
|
/// connection.
|
||||||
|
static const int maxPages = 200;
|
||||||
|
|
||||||
|
/// Rows requested per page. Sent as `page_size` on every request so the
|
||||||
|
/// back office doesn't fall back to its own (smaller) default.
|
||||||
|
static const int pageSize = 500;
|
||||||
|
|
||||||
|
static const Duration _timeout = Duration(seconds: 30);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get label => 'HTTP ${config.httpBaseUrl}';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CatalogueSnapshot> fetch({
|
||||||
|
String? since,
|
||||||
|
void Function(double progress, String stage)? onProgress,
|
||||||
|
}) async {
|
||||||
|
if (config.httpBaseUrl.isEmpty) {
|
||||||
|
throw const CatalogueSyncException(
|
||||||
|
'No back-office URL is configured for this terminal. Set one in '
|
||||||
|
'Settings → Connectivity → Configure.',
|
||||||
|
retryable: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final products = <Product>[];
|
||||||
|
final customers = <Customer>[];
|
||||||
|
final retired = <String>[];
|
||||||
|
|
||||||
|
var revision = since ?? '';
|
||||||
|
var isDelta = false;
|
||||||
|
var page = 0;
|
||||||
|
|
||||||
|
onProgress?.call(0.05, 'Contacting the back office…');
|
||||||
|
|
||||||
|
while (page <= maxPages) {
|
||||||
|
final body = await _fetchPage(since: since, page: page);
|
||||||
|
|
||||||
|
revision = (body['revision'] as String?) ?? revision;
|
||||||
|
isDelta = body['is_delta'] as bool? ?? false;
|
||||||
|
|
||||||
|
products.addAll(
|
||||||
|
_decodeList(body['products'], CatalogueWire.productFromJson),
|
||||||
|
);
|
||||||
|
customers.addAll(
|
||||||
|
_decodeList(body['customers'], CatalogueWire.customerFromJson),
|
||||||
|
);
|
||||||
|
retired.addAll(
|
||||||
|
(body['retired_product_ids'] as List<Object?>? ?? const [])
|
||||||
|
.whereType<String>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final hasMore = body['has_more'] as bool? ?? false;
|
||||||
|
if (!hasMore) break;
|
||||||
|
|
||||||
|
page++;
|
||||||
|
// The total is unknown until the last page, so this walks towards 0.9
|
||||||
|
// instead of pretending to know how far along it is.
|
||||||
|
onProgress?.call(
|
||||||
|
(0.1 + page * 0.05).clamp(0.1, 0.9),
|
||||||
|
'${products.length} products…',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page > maxPages) {
|
||||||
|
throw const CatalogueSyncException(
|
||||||
|
'The back office kept asking for another page past $maxPages. '
|
||||||
|
'Stopping rather than looping — nothing was changed on this terminal.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.call(1, 'Writing to local storage…');
|
||||||
|
|
||||||
|
return CatalogueSnapshot(
|
||||||
|
products: products,
|
||||||
|
customers: customers,
|
||||||
|
fetchedAt: DateTime.now(),
|
||||||
|
revision: revision.isEmpty ? 'rev-unknown' : revision,
|
||||||
|
isDelta: isDelta,
|
||||||
|
retiredProductIds: retired,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, Object?>> _fetchPage({
|
||||||
|
required String? since,
|
||||||
|
required int page,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('${config.httpBaseUrl}/catalogue').replace(
|
||||||
|
queryParameters: {
|
||||||
|
if (since != null && since.isNotEmpty) 'since': since,
|
||||||
|
'page': '$page',
|
||||||
|
'page_size': '$pageSize',
|
||||||
|
'store_id': config.storeId,
|
||||||
|
'terminal_id': config.terminalId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
http.Response response;
|
||||||
|
try {
|
||||||
|
response = await _client.get(
|
||||||
|
uri,
|
||||||
|
headers: {
|
||||||
|
'accept': 'application/json',
|
||||||
|
if (config.apiKey != null) 'authorization': 'Bearer ${config.apiKey}',
|
||||||
|
},
|
||||||
|
).timeout(_timeout);
|
||||||
|
} on Exception catch (e) {
|
||||||
|
throw CatalogueSyncException('Could not reach the back office: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||||
|
throw CatalogueSyncException(
|
||||||
|
'The back office rejected this terminal\'s credentials '
|
||||||
|
'(${response.statusCode}). The catalogue already on this terminal is '
|
||||||
|
'untouched, so billing continues.',
|
||||||
|
retryable: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode >= 300) {
|
||||||
|
throw CatalogueSyncException(
|
||||||
|
'Back office returned ${response.statusCode}: ${_trim(response.body)}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return jsonDecode(response.body) as Map<String, Object?>;
|
||||||
|
} on Object {
|
||||||
|
throw CatalogueSyncException(
|
||||||
|
'The back office answered with something this terminal could not '
|
||||||
|
'read: ${_trim(response.body)}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a list, letting one bad record fail the import rather than
|
||||||
|
/// silently vanish.
|
||||||
|
///
|
||||||
|
/// A dropped product is a shelf item that scans to nothing, which a cashier
|
||||||
|
/// discovers with a queue waiting. Better to refuse the import and leave the
|
||||||
|
/// working catalogue in place.
|
||||||
|
static List<T> _decodeList<T>(
|
||||||
|
Object? raw,
|
||||||
|
T Function(Map<String, Object?>) decode,
|
||||||
|
) {
|
||||||
|
if (raw is! List) return const [];
|
||||||
|
return raw
|
||||||
|
.whereType<Map<String, Object?>>()
|
||||||
|
.map(decode)
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _trim(String body) =>
|
||||||
|
body.length <= 200 ? body : '${body.substring(0, 200)}…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() => _client.close();
|
||||||
|
}
|
||||||
211
lib/data/remote/http_order_transport.dart
Normal file
211
lib/data/remote/http_order_transport.dart
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../../core/config/sync_config.dart';
|
||||||
|
import 'order_transport.dart';
|
||||||
|
|
||||||
|
/// Ships bills over plain HTTP.
|
||||||
|
///
|
||||||
|
/// No downlink and no persistent connection — but a failure is a status code,
|
||||||
|
/// a request is reproducible with curl, and there is no broker to run. Worth
|
||||||
|
/// having as the route to bring up first, and as the fallback when a broker is
|
||||||
|
/// unreachable but the internet is not.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// POST {base}/orders
|
||||||
|
/// { "schema": 1, "batch_id": "…", "store_id": "…", "terminal_id": "…",
|
||||||
|
/// "orders": [ … ] }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The endpoint must answer with the ids it committed:
|
||||||
|
///
|
||||||
|
/// ```json
|
||||||
|
/// { "accepted": ["<order-id>", ...], "rejected": { "<order-id>": "reason" } }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// A bare `200 OK` is not treated as success for any bill. Silence about which
|
||||||
|
/// rows landed is not the same as landing them, and guessing here would retire
|
||||||
|
/// a day's takings on an empty response.
|
||||||
|
class HttpOrderTransport implements OrderTransport {
|
||||||
|
HttpOrderTransport({required this.config, http.Client? client})
|
||||||
|
: _client = client ?? http.Client();
|
||||||
|
|
||||||
|
final SyncConfig config;
|
||||||
|
final http.Client _client;
|
||||||
|
|
||||||
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
|
final _connection = StreamController<bool>.broadcast();
|
||||||
|
|
||||||
|
bool _reachable = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get label => 'HTTP ${config.httpBaseUrl}';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isConnected => _reachable;
|
||||||
|
|
||||||
|
/// Nothing to receive: HTTP is a route out, not in.
|
||||||
|
@override
|
||||||
|
Stream<DownlinkMessage> get downlink => const Stream.empty();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<bool> get connectionState => _connection.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> connect() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) =>
|
||||||
|
_post(path: 'orders', key: 'orders', items: orders);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PushReceipt> pushCustomers(List<Map<String, Object?>> customers) =>
|
||||||
|
_post(path: 'customers', key: 'customers', items: customers);
|
||||||
|
|
||||||
|
/// One heartbeat, posted to the back office.
|
||||||
|
///
|
||||||
|
/// Nothing is read back and nothing is retried. A heartbeat is only true for
|
||||||
|
/// the thirty seconds until the next one, so a failed beat is already stale
|
||||||
|
/// by the time a retry could land — the correct response is to let the board
|
||||||
|
/// go blank and say so with the next one.
|
||||||
|
///
|
||||||
|
/// Every failure is swallowed for the reason the whole reporter swallows
|
||||||
|
/// them: a till that cannot say how it is must still sell. Stopping a shop
|
||||||
|
/// because a dashboard was unreachable would be a self-inflicted outage.
|
||||||
|
@override
|
||||||
|
Future<void> publishHealth(String payload) async {
|
||||||
|
if (config.httpBaseUrl.isEmpty) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _client
|
||||||
|
.post(
|
||||||
|
Uri.parse('${config.httpBaseUrl}/health'),
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
if (config.apiKey != null)
|
||||||
|
'authorization': 'Bearer ${config.apiKey}',
|
||||||
|
},
|
||||||
|
body: payload,
|
||||||
|
)
|
||||||
|
// Deliberately shorter than ackTimeout. A bill is worth waiting
|
||||||
|
// twenty seconds for; a heartbeat that takes that long would still
|
||||||
|
// be in flight when the next one is due.
|
||||||
|
.timeout(const Duration(seconds: 5));
|
||||||
|
} on Object {
|
||||||
|
// See above.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PushReceipt> _post({
|
||||||
|
required String path,
|
||||||
|
required String key,
|
||||||
|
required List<Map<String, Object?>> items,
|
||||||
|
}) async {
|
||||||
|
if (items.isEmpty) return const PushReceipt(accepted: []);
|
||||||
|
|
||||||
|
if (config.httpBaseUrl.isEmpty) {
|
||||||
|
throw const TransportException(
|
||||||
|
'No back-office URL configured for this terminal.',
|
||||||
|
retryable: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final uri = Uri.parse('${config.httpBaseUrl}/$path');
|
||||||
|
|
||||||
|
// Deterministic from the set of ids in this batch — not a fresh random
|
||||||
|
// id per attempt — so a retry after a timeout (the same rows, because
|
||||||
|
// nothing was marked sent) carries the exact same batch_id as the
|
||||||
|
// attempt that may already have landed. That is what lets the back
|
||||||
|
// office collapse a retried batch server-side instead of re-billing it.
|
||||||
|
final batchId = _uuid.v5(
|
||||||
|
Uuid.NAMESPACE_URL,
|
||||||
|
items.map((o) => o['id']).join('|'),
|
||||||
|
);
|
||||||
|
|
||||||
|
http.Response response;
|
||||||
|
try {
|
||||||
|
response = await _client
|
||||||
|
.post(
|
||||||
|
uri,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
if (config.apiKey != null)
|
||||||
|
'authorization': 'Bearer ${config.apiKey}',
|
||||||
|
'idempotency-key': batchId,
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'schema': 1,
|
||||||
|
'batch_id': batchId,
|
||||||
|
'store_id': config.storeId,
|
||||||
|
'terminal_id': config.terminalId,
|
||||||
|
key: items,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.timeout(config.ackTimeout);
|
||||||
|
} on Exception catch (e) {
|
||||||
|
_setReachable(false);
|
||||||
|
throw TransportException('Upload failed: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||||
|
_setReachable(false);
|
||||||
|
throw TransportException(
|
||||||
|
'The back office rejected this terminal\'s credentials '
|
||||||
|
'(${response.statusCode}). Bills are safe locally, but sync will keep '
|
||||||
|
'failing until the terminal is re-authorised.',
|
||||||
|
retryable: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode >= 300) {
|
||||||
|
_setReachable(false);
|
||||||
|
throw TransportException(
|
||||||
|
'Back office returned ${response.statusCode}: '
|
||||||
|
'${_trim(response.body)}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_setReachable(true);
|
||||||
|
|
||||||
|
Map<String, Object?> body;
|
||||||
|
try {
|
||||||
|
body = jsonDecode(response.body) as Map<String, Object?>;
|
||||||
|
} on Exception {
|
||||||
|
throw TransportException(
|
||||||
|
'Back office answered 200 with a body this terminal could not read, '
|
||||||
|
'so no bill was marked synced: ${_trim(response.body)}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final accepted = (body['accepted'] as List<Object?>? ?? const [])
|
||||||
|
.whereType<String>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final rejected = <String, String>{};
|
||||||
|
final raw = body['rejected'];
|
||||||
|
if (raw is Map) {
|
||||||
|
raw.forEach((k, v) => rejected['$k'] = '$v');
|
||||||
|
}
|
||||||
|
|
||||||
|
return PushReceipt(accepted: accepted, rejected: rejected);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setReachable(bool value) {
|
||||||
|
if (_reachable == value) return;
|
||||||
|
_reachable = value;
|
||||||
|
_connection.add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _trim(String body) =>
|
||||||
|
body.length <= 200 ? body : '${body.substring(0, 200)}…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dispose() async {
|
||||||
|
_client.close();
|
||||||
|
await _connection.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
383
lib/data/remote/mqtt_order_transport.dart
Normal file
383
lib/data/remote/mqtt_order_transport.dart
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:mqtt_client/mqtt_client.dart';
|
||||||
|
import 'package:mqtt_client/mqtt_server_client.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../../core/config/sync_config.dart';
|
||||||
|
import 'order_transport.dart';
|
||||||
|
|
||||||
|
/// Ships bills over MQTT and listens for what head office pushes back.
|
||||||
|
///
|
||||||
|
/// ### Why the broker's acknowledgement is not enough
|
||||||
|
///
|
||||||
|
/// QoS 1 gives a PUBACK from the *broker*, meaning "I hold these bytes". It
|
||||||
|
/// says nothing about whether the back office parsed the batch, or whether the
|
||||||
|
/// ledger accepted it. Marking bills synced on PUBACK would retire a day's
|
||||||
|
/// takings on the word of a message queue.
|
||||||
|
///
|
||||||
|
/// So every publish carries a `batch_id` and this waits for the back office to
|
||||||
|
/// answer on [SyncConfig.ackTopic] naming the ids it actually committed. No
|
||||||
|
/// answer means no sync, and the bills are sent again.
|
||||||
|
///
|
||||||
|
/// ### Duplicates are expected
|
||||||
|
///
|
||||||
|
/// QoS 1 is at-least-once, and a batch whose ack is lost will be re-sent in
|
||||||
|
/// full. The back office must key on `order.id` and upsert. Every id is a UUID
|
||||||
|
/// minted at the till, so this costs the server one unique index.
|
||||||
|
class MqttOrderTransport implements OrderTransport {
|
||||||
|
MqttOrderTransport({
|
||||||
|
required this.config,
|
||||||
|
MqttClient Function(SyncConfig)? clientFactory,
|
||||||
|
}) : _clientFactory = clientFactory ?? _defaultClient;
|
||||||
|
|
||||||
|
final SyncConfig config;
|
||||||
|
final MqttClient Function(SyncConfig) _clientFactory;
|
||||||
|
|
||||||
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
|
MqttClient? _client;
|
||||||
|
|
||||||
|
/// Batches published but not yet answered, keyed by `batch_id`.
|
||||||
|
final _awaitingAck = <String, Completer<PushReceipt>>{};
|
||||||
|
|
||||||
|
final _downlink = StreamController<DownlinkMessage>.broadcast();
|
||||||
|
final _connection = StreamController<bool>.broadcast();
|
||||||
|
|
||||||
|
/// Guards against two drains racing to open the same connection.
|
||||||
|
Future<void>? _connecting;
|
||||||
|
|
||||||
|
StreamSubscription<List<MqttReceivedMessage<MqttMessage>>>? _updates;
|
||||||
|
|
||||||
|
static MqttClient _defaultClient(SyncConfig config) {
|
||||||
|
final client = MqttServerClient.withPort(
|
||||||
|
config.brokerHost,
|
||||||
|
config.clientId,
|
||||||
|
config.brokerPort,
|
||||||
|
);
|
||||||
|
client.secure = config.useTls;
|
||||||
|
// Well under the shortest NAT timeout a shop router is likely to impose;
|
||||||
|
// a connection that dies silently is worse than one that pings.
|
||||||
|
client.keepAlivePeriod = 20;
|
||||||
|
client.autoReconnect = true;
|
||||||
|
client.resubscribeOnAutoReconnect = true;
|
||||||
|
client.logging(on: false);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get label => 'MQTT ${config.brokerHost}:${config.brokerPort}';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isConnected =>
|
||||||
|
_client?.connectionStatus?.state == MqttConnectionState.connected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<DownlinkMessage> get downlink => _downlink.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<bool> get connectionState => _connection.stream;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- Connection
|
||||||
|
@override
|
||||||
|
Future<void> connect() {
|
||||||
|
if (isConnected) return Future.value();
|
||||||
|
return _connecting ??= _doConnect().whenComplete(() => _connecting = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _doConnect() async {
|
||||||
|
if (config.brokerHost.isEmpty) {
|
||||||
|
throw const TransportException(
|
||||||
|
'No MQTT broker configured for this terminal.',
|
||||||
|
retryable: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final client = _client ??= _clientFactory(config);
|
||||||
|
|
||||||
|
client.onDisconnected = () {
|
||||||
|
_connection.add(false);
|
||||||
|
// Nobody is coming to answer these. Failing them now returns the bills
|
||||||
|
// to pending immediately instead of holding the drain for the full ack
|
||||||
|
// timeout on a connection that is already gone.
|
||||||
|
_failAllAwaiting('Connection to the broker dropped.');
|
||||||
|
};
|
||||||
|
client.onConnected = () => _connection.add(true);
|
||||||
|
|
||||||
|
// Retained, so head office sees this terminal's last known state even if
|
||||||
|
// its dashboard connects hours later. The broker publishes it on our
|
||||||
|
// behalf if the till loses power mid-shift — which is the only way to tell
|
||||||
|
// "closed for the night" from "unplugged".
|
||||||
|
client.connectionMessage = MqttConnectMessage()
|
||||||
|
.withClientIdentifier(config.clientId)
|
||||||
|
.withWillTopic(config.statusTopic)
|
||||||
|
.withWillMessage(jsonEncode({'state': 'offline'}))
|
||||||
|
.withWillQos(MqttQos.atLeastOnce)
|
||||||
|
.withWillRetain();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect(config.username, config.password);
|
||||||
|
} on Exception catch (e) {
|
||||||
|
client.disconnect();
|
||||||
|
throw TransportException('Could not reach the broker: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.connectionStatus?.state != MqttConnectionState.connected) {
|
||||||
|
final status = client.connectionStatus;
|
||||||
|
client.disconnect();
|
||||||
|
throw TransportException(
|
||||||
|
'Broker refused the connection: '
|
||||||
|
'${status?.returnCode?.name ?? 'unknown'}',
|
||||||
|
// Bad credentials or an unauthorised client id will be refused just as
|
||||||
|
// firmly on the next attempt; backing off forever would only hide it.
|
||||||
|
retryable: status?.returnCode != MqttConnectReturnCode.notAuthorized,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
client
|
||||||
|
..subscribe(config.ackTopic, MqttQos.atLeastOnce)
|
||||||
|
..subscribe(config.catalogueTopic, MqttQos.atLeastOnce)
|
||||||
|
..subscribe(config.commandTopic, MqttQos.atLeastOnce);
|
||||||
|
|
||||||
|
await _updates?.cancel();
|
||||||
|
_updates = client.updates?.listen(_onUpdates);
|
||||||
|
|
||||||
|
_publish(
|
||||||
|
config.statusTopic,
|
||||||
|
jsonEncode({
|
||||||
|
'state': 'online',
|
||||||
|
'terminal_id': config.terminalId,
|
||||||
|
'at': DateTime.now().toIso8601String(),
|
||||||
|
}),
|
||||||
|
retain: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
_connection.add(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onUpdates(List<MqttReceivedMessage<MqttMessage>> events) {
|
||||||
|
for (final event in events) {
|
||||||
|
final message = event.payload;
|
||||||
|
if (message is! MqttPublishMessage) continue;
|
||||||
|
handleInbound(
|
||||||
|
event.topic,
|
||||||
|
MqttPublishPayload.bytesToStringAsString(message.payload.message),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- Uplink
|
||||||
|
@override
|
||||||
|
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) =>
|
||||||
|
_publishBatch(
|
||||||
|
topic: config.orderTopic,
|
||||||
|
key: 'orders',
|
||||||
|
items: orders,
|
||||||
|
noun: 'bills',
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PushReceipt> pushCustomers(List<Map<String, Object?>> customers) =>
|
||||||
|
_publishBatch(
|
||||||
|
topic: config.customerTopic,
|
||||||
|
key: 'customers',
|
||||||
|
items: customers,
|
||||||
|
noun: 'registrations',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Publishes one correlated batch and waits for the back office to answer it.
|
||||||
|
///
|
||||||
|
/// Shared by both uplinks because the rule they must obey is the same one,
|
||||||
|
/// and it is the rule the whole design rests on: a batch counts as delivered
|
||||||
|
/// only when the *application* names its ids, never when the broker
|
||||||
|
/// acknowledges the bytes.
|
||||||
|
Future<PushReceipt> _publishBatch({
|
||||||
|
required String topic,
|
||||||
|
required String key,
|
||||||
|
required List<Map<String, Object?>> items,
|
||||||
|
required String noun,
|
||||||
|
}) async {
|
||||||
|
if (items.isEmpty) return const PushReceipt(accepted: []);
|
||||||
|
|
||||||
|
await connect();
|
||||||
|
|
||||||
|
final batchId = _uuid.v4();
|
||||||
|
final completer = Completer<PushReceipt>();
|
||||||
|
_awaitingAck[batchId] = completer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_publish(
|
||||||
|
topic,
|
||||||
|
jsonEncode({
|
||||||
|
'schema': 1,
|
||||||
|
'batch_id': batchId,
|
||||||
|
'store_id': config.storeId,
|
||||||
|
'terminal_id': config.terminalId,
|
||||||
|
'sent_at': DateTime.now().toIso8601String(),
|
||||||
|
key: items,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return await completer.future.timeout(
|
||||||
|
config.ackTimeout,
|
||||||
|
onTimeout: () => throw TransportException(
|
||||||
|
'The back office did not confirm the batch within '
|
||||||
|
'${config.ackTimeout.inSeconds}s. The $noun are still on this '
|
||||||
|
'terminal and will be sent again.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
_awaitingAck.remove(batchId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _publish(String topic, String payload, {bool retain = false}) {
|
||||||
|
final builder = MqttClientPayloadBuilder()..addString(payload);
|
||||||
|
try {
|
||||||
|
_client!.publishMessage(
|
||||||
|
topic,
|
||||||
|
MqttQos.atLeastOnce,
|
||||||
|
builder.payload!,
|
||||||
|
retain: retain,
|
||||||
|
);
|
||||||
|
} on Exception catch (e) {
|
||||||
|
throw TransportException('Publish to $topic failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- Downlink
|
||||||
|
/// Publishes a presence record on the status topic, retained.
|
||||||
|
///
|
||||||
|
/// Retained so a dashboard connecting hours later still learns every
|
||||||
|
/// terminal's last known state, rather than showing a blank board until each
|
||||||
|
/// one happens to tick.
|
||||||
|
Future<void> publishStatus(String payload) async {
|
||||||
|
if (!isConnected) return;
|
||||||
|
_publish(config.statusTopic, payload, retain: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publishes a heartbeat, deliberately *not* retained.
|
||||||
|
///
|
||||||
|
/// The back office holds these in Redis under a TTL, so a terminal that
|
||||||
|
/// stops beating ages off the board by itself. A retained heartbeat would
|
||||||
|
/// survive on the broker after the till was unplugged and keep it looking
|
||||||
|
/// alive until something happened to overwrite it — which is exactly the
|
||||||
|
/// failure a health board exists to catch.
|
||||||
|
@override
|
||||||
|
Future<void> publishHealth(String payload) async {
|
||||||
|
if (!isConnected) return;
|
||||||
|
_publish(config.healthTopic, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers a batch as awaiting its ack, without publishing one.
|
||||||
|
///
|
||||||
|
/// Lets a test drive the correlation rules — which is where the logic that
|
||||||
|
/// decides whether a bill counts as banked actually lives — without standing
|
||||||
|
/// up a broker.
|
||||||
|
@visibleForTesting
|
||||||
|
Future<PushReceipt> awaitAck(String batchId) {
|
||||||
|
final completer = Completer<PushReceipt>();
|
||||||
|
_awaitingAck[batchId] = completer;
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routes one inbound message. Separated from the client so the correlation
|
||||||
|
/// and parsing rules can be tested without a broker.
|
||||||
|
@visibleForTesting
|
||||||
|
void handleInbound(String topic, String payload) {
|
||||||
|
Map<String, Object?> body;
|
||||||
|
try {
|
||||||
|
body = jsonDecode(payload) as Map<String, Object?>;
|
||||||
|
} on FormatException {
|
||||||
|
// A malformed message must not take down the connection: the next one
|
||||||
|
// may be a perfectly good ack releasing a day's bills.
|
||||||
|
debugPrint('Discarded unparseable MQTT message on $topic');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topic == config.ackTopic) {
|
||||||
|
_resolveAck(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topic == config.catalogueTopic) {
|
||||||
|
_downlink.add(
|
||||||
|
DownlinkMessage(kind: DownlinkKind.catalogueChanged, payload: body),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topic == config.commandTopic) {
|
||||||
|
final command = body['command'] as String?;
|
||||||
|
_downlink.add(
|
||||||
|
DownlinkMessage(
|
||||||
|
kind: command == 'sync'
|
||||||
|
? DownlinkKind.syncRequested
|
||||||
|
: DownlinkKind.unknown,
|
||||||
|
payload: body,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resolveAck(Map<String, Object?> body) {
|
||||||
|
final batchId = body['batch_id'] as String?;
|
||||||
|
if (batchId == null) return;
|
||||||
|
|
||||||
|
// An ack for a batch we are no longer waiting on — we timed out, or the
|
||||||
|
// terminal restarted. Harmless: those bills are still pending and will go
|
||||||
|
// up again, and the back office keys on order id.
|
||||||
|
final completer = _awaitingAck.remove(batchId);
|
||||||
|
if (completer == null || completer.isCompleted) return;
|
||||||
|
|
||||||
|
final accepted = (body['accepted'] as List<Object?>? ?? const [])
|
||||||
|
.whereType<String>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final rejected = <String, String>{};
|
||||||
|
final raw = body['rejected'];
|
||||||
|
if (raw is Map) {
|
||||||
|
raw.forEach((k, v) => rejected['$k'] = '$v');
|
||||||
|
} else if (raw is List) {
|
||||||
|
// Tolerates a back office that sends bare ids with no reason.
|
||||||
|
for (final id in raw.whereType<String>()) {
|
||||||
|
rejected[id] = 'Rejected by the back office';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completer.complete(PushReceipt(accepted: accepted, rejected: rejected));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _failAllAwaiting(String reason) {
|
||||||
|
final waiting = List.of(_awaitingAck.values);
|
||||||
|
_awaitingAck.clear();
|
||||||
|
for (final c in waiting) {
|
||||||
|
if (!c.isCompleted) c.completeError(TransportException(reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dispose() async {
|
||||||
|
_failAllAwaiting('The terminal is shutting down.');
|
||||||
|
await _updates?.cancel();
|
||||||
|
|
||||||
|
if (isConnected) {
|
||||||
|
// A clean goodbye, so head office does not see a till it thinks crashed.
|
||||||
|
try {
|
||||||
|
_publish(
|
||||||
|
config.statusTopic,
|
||||||
|
jsonEncode({'state': 'offline', 'clean': true}),
|
||||||
|
retain: true,
|
||||||
|
);
|
||||||
|
} on TransportException {
|
||||||
|
// Already going down; nothing useful to do about it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_client?.disconnect();
|
||||||
|
await _downlink.close();
|
||||||
|
await _connection.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
123
lib/data/remote/order_transport.dart
Normal file
123
lib/data/remote/order_transport.dart
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// Raised when a batch could not be handed to the back office.
|
||||||
|
///
|
||||||
|
/// Distinct from *rejection*: a transport failure means nobody knows whether
|
||||||
|
/// the bills arrived, so every row stays pending and is tried again. A
|
||||||
|
/// rejection means the back office looked at a bill and refused it, which
|
||||||
|
/// retrying will not fix.
|
||||||
|
class TransportException implements Exception {
|
||||||
|
const TransportException(this.message, {this.retryable = true});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
/// Whether trying again could plausibly succeed. A dropped connection is
|
||||||
|
/// retryable; a rejected certificate or a bad credential is not, and the
|
||||||
|
/// engine should stop rather than hammer the broker.
|
||||||
|
final bool retryable;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the back office said about one batch.
|
||||||
|
///
|
||||||
|
/// [accepted] is the contract that matters: only ids named here are marked
|
||||||
|
/// synced. Anything absent stays pending, whatever the transport reported at
|
||||||
|
/// its own layer.
|
||||||
|
class PushReceipt {
|
||||||
|
const PushReceipt({required this.accepted, this.rejected = const {}});
|
||||||
|
|
||||||
|
final List<String> accepted;
|
||||||
|
|
||||||
|
/// Order id → why the back office refused it. Retrying these unchanged will
|
||||||
|
/// fail again, so the engine surfaces them instead of looping.
|
||||||
|
final Map<String, String> rejected;
|
||||||
|
|
||||||
|
bool get isEmpty => accepted.isEmpty && rejected.isEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Something the cloud pushed down to this terminal.
|
||||||
|
///
|
||||||
|
/// Only a transport with a live connection can deliver these; request/response
|
||||||
|
/// transports expose an empty stream.
|
||||||
|
class DownlinkMessage {
|
||||||
|
const DownlinkMessage({required this.kind, this.payload = const {}});
|
||||||
|
|
||||||
|
final DownlinkKind kind;
|
||||||
|
final Map<String, Object?> payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DownlinkKind {
|
||||||
|
/// The catalogue changed at head office — re-import rather than wait for
|
||||||
|
/// tomorrow morning's pull.
|
||||||
|
catalogueChanged,
|
||||||
|
|
||||||
|
/// Head office is asking this terminal to upload now.
|
||||||
|
syncRequested,
|
||||||
|
|
||||||
|
/// Anything this build does not recognise. Kept rather than dropped so a
|
||||||
|
/// newer server talking to an older terminal is visible in the events log
|
||||||
|
/// instead of silently ignored.
|
||||||
|
unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How completed bills leave the terminal.
|
||||||
|
///
|
||||||
|
/// The drain engine owns *when* to send and what to do when sending fails;
|
||||||
|
/// this owns only the wire. Swapping HTTP for MQTT is a change of
|
||||||
|
/// implementation here and nothing else.
|
||||||
|
abstract class OrderTransport {
|
||||||
|
/// Shown in the events log so a cashier reporting a problem can say which
|
||||||
|
/// route the terminal was using.
|
||||||
|
String get label;
|
||||||
|
|
||||||
|
/// Opens the connection. Safe to call when already open.
|
||||||
|
///
|
||||||
|
/// A request/response transport has nothing to open and returns at once.
|
||||||
|
Future<void> connect();
|
||||||
|
|
||||||
|
/// Hands a batch over and reports what the back office committed.
|
||||||
|
///
|
||||||
|
/// Implementations must not report an id as accepted until the *application*
|
||||||
|
/// has confirmed it. A broker acknowledging receipt of the bytes is not the
|
||||||
|
/// back office confirming the sale.
|
||||||
|
///
|
||||||
|
/// Throws [TransportException] when the outcome is unknown.
|
||||||
|
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders);
|
||||||
|
|
||||||
|
/// Hands over shoppers registered at this till.
|
||||||
|
///
|
||||||
|
/// Same acceptance contract as [pushOrders] — only ids the back office names
|
||||||
|
/// are marked sent — but the payload is a registration rather than a
|
||||||
|
/// financial record, so the back office is expected to treat it as
|
||||||
|
/// insert-if-absent on id. Replaying one it already holds must be a no-op,
|
||||||
|
/// never an overwrite of a profile corrected at head office.
|
||||||
|
///
|
||||||
|
/// Throws [TransportException] when the outcome is unknown.
|
||||||
|
Future<PushReceipt> pushCustomers(List<Map<String, Object?>> customers);
|
||||||
|
|
||||||
|
/// Cloud-initiated messages. Empty for transports that cannot receive.
|
||||||
|
Stream<DownlinkMessage> get downlink;
|
||||||
|
|
||||||
|
/// Whether the route is currently usable. Drives the header's live pill and
|
||||||
|
/// wakes the drain engine when it flips to true.
|
||||||
|
Stream<bool> get connectionState;
|
||||||
|
|
||||||
|
bool get isConnected;
|
||||||
|
|
||||||
|
/// Sends one heartbeat, and never throws.
|
||||||
|
///
|
||||||
|
/// On the interface rather than on the broker transport alone, because it was
|
||||||
|
/// on the broker transport alone and that was the bug: the reporter was
|
||||||
|
/// started behind an `is MqttOrderTransport` check, so a shop on the HTTP
|
||||||
|
/// route uploaded every bill correctly and never once appeared on the fleet
|
||||||
|
/// board. Nothing logged it, because nothing had gone wrong — the feature
|
||||||
|
/// simply did not exist on that route.
|
||||||
|
///
|
||||||
|
/// A transport with nowhere to send it does nothing. That is a real answer,
|
||||||
|
/// not a stub: the simulated route has no back office to tell.
|
||||||
|
Future<void> publishHealth(String payload);
|
||||||
|
|
||||||
|
Future<void> dispose();
|
||||||
|
}
|
||||||
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();
|
||||||
|
}
|
||||||
55
lib/data/remote/simulated_catalogue_source.dart
Normal file
55
lib/data/remote/simulated_catalogue_source.dart
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import '../datasources/seed_data.dart';
|
||||||
|
import 'catalogue_source.dart';
|
||||||
|
|
||||||
|
/// Stands in for the back office when no endpoint is configured.
|
||||||
|
///
|
||||||
|
/// Keeps a fresh install demonstrable: products exist, a shift can be rehearsed
|
||||||
|
/// end to end, and the offline switch in Settings fails the pull exactly the
|
||||||
|
/// way a dead line would.
|
||||||
|
class SimulatedCatalogueSource implements CatalogueSource {
|
||||||
|
const SimulatedCatalogueSource({required this.isOffline});
|
||||||
|
|
||||||
|
/// Read on every call rather than copied, so the Settings switch takes effect
|
||||||
|
/// immediately instead of at the next restart.
|
||||||
|
final bool Function() isOffline;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get label => 'Simulated';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CatalogueSnapshot> fetch({
|
||||||
|
String? since,
|
||||||
|
void Function(double progress, String stage)? onProgress,
|
||||||
|
}) async {
|
||||||
|
const stages = [
|
||||||
|
(0.15, 'Contacting server…'),
|
||||||
|
(0.35, 'Authorising terminal…'),
|
||||||
|
(0.60, 'Downloading products…'),
|
||||||
|
(0.85, 'Downloading customers…'),
|
||||||
|
(1.00, 'Writing to local storage…'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final (progress, stage) in stages) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 320));
|
||||||
|
|
||||||
|
if (isOffline()) {
|
||||||
|
throw const CatalogueSyncException(
|
||||||
|
'Simulate offline is ON in Settings, so the catalogue pull was '
|
||||||
|
'failed on purpose. Turn it off to import.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.call(progress, stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CatalogueSnapshot(
|
||||||
|
products: SeedData.products(),
|
||||||
|
customers: SeedData.customers(),
|
||||||
|
fetchedAt: DateTime.now(),
|
||||||
|
revision: 'rev-${DateTime.now().millisecondsSinceEpoch % 100000}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {}
|
||||||
|
}
|
||||||
77
lib/data/remote/simulated_order_transport.dart
Normal file
77
lib/data/remote/simulated_order_transport.dart
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'order_transport.dart';
|
||||||
|
|
||||||
|
/// Stands in for the back office when no broker or endpoint is configured.
|
||||||
|
///
|
||||||
|
/// Keeps the terminal demonstrable on a bare laptop: bills queue, drain, and
|
||||||
|
/// respect the Settings offline switch exactly as they would against a real
|
||||||
|
/// server, so the drain engine can be exercised without one.
|
||||||
|
class SimulatedOrderTransport implements OrderTransport {
|
||||||
|
SimulatedOrderTransport({required this.isOffline});
|
||||||
|
|
||||||
|
/// Read on every call rather than copied, so the Settings switch takes effect
|
||||||
|
/// immediately instead of at the next restart.
|
||||||
|
final bool Function() isOffline;
|
||||||
|
|
||||||
|
final _downlink = StreamController<DownlinkMessage>.broadcast();
|
||||||
|
final _connection = StreamController<bool>.broadcast();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get label => 'Simulated';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isConnected => !isOffline();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<DownlinkMessage> get downlink => _downlink.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<bool> get connectionState => _connection.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> connect() async {}
|
||||||
|
|
||||||
|
/// Nowhere to send it. A fresh install has no back office configured, and
|
||||||
|
/// inventing a destination would only hide that.
|
||||||
|
@override
|
||||||
|
Future<void> publishHealth(String payload) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) =>
|
||||||
|
_accept(orders, 'bill');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PushReceipt> pushCustomers(List<Map<String, Object?>> customers) =>
|
||||||
|
_accept(customers, 'registration');
|
||||||
|
|
||||||
|
Future<PushReceipt> _accept(
|
||||||
|
List<Map<String, Object?>> items,
|
||||||
|
String noun,
|
||||||
|
) async {
|
||||||
|
await Future<void>.delayed(
|
||||||
|
Duration(milliseconds: 400 + items.length * 60),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isOffline()) {
|
||||||
|
throw TransportException(
|
||||||
|
'Simulate offline is ON in Settings, so the upload was failed on '
|
||||||
|
'purpose. Every $noun is still stored on this terminal.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return PushReceipt(
|
||||||
|
accepted: items.map((o) => o['id']! as String).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lets the events screen prove the downlink path end to end without a
|
||||||
|
/// broker.
|
||||||
|
void emit(DownlinkMessage message) => _downlink.add(message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dispose() async {
|
||||||
|
await _downlink.close();
|
||||||
|
await _connection.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'package:uuid/uuid.dart';
|
|
||||||
|
|
||||||
import '../../core/utils/extensions.dart';
|
import '../../core/utils/extensions.dart';
|
||||||
import '../../domain/entities/customer.dart';
|
import '../../domain/entities/customer.dart';
|
||||||
import '../../domain/repositories/customer_repository.dart';
|
import '../../domain/repositories/customer_repository.dart';
|
||||||
@@ -9,15 +7,15 @@ class CustomerRepositoryImpl implements CustomerRepository {
|
|||||||
CustomerRepositoryImpl(this._store);
|
CustomerRepositoryImpl(this._store);
|
||||||
|
|
||||||
final LocalStore _store;
|
final LocalStore _store;
|
||||||
static const _uuid = Uuid();
|
|
||||||
|
|
||||||
String _digits(String v) => v.replaceAll(RegExp(r'\D'), '');
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Customer?> findByMobile(String mobile) async {
|
Future<Customer?> findByMobile(String mobile) async {
|
||||||
final needle = _digits(mobile);
|
// Normalised on both sides, so a shopper stored from `9840012345` is still
|
||||||
return _store.customers
|
// found when a cashier at the next till types `+91 98400 12345`.
|
||||||
.firstWhereOrNull((c) => _digits(c.mobile) == needle);
|
final needle = Customer.normaliseMobile(mobile);
|
||||||
|
return _store.customers.firstWhereOrNull(
|
||||||
|
(c) => Customer.normaliseMobile(c.mobile) == needle,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -30,9 +28,14 @@ class CustomerRepositoryImpl implements CustomerRepository {
|
|||||||
throw StateError('A customer with this mobile number already exists.');
|
throw StateError('A customer with this mobile number already exists.');
|
||||||
}
|
}
|
||||||
final created = Customer(
|
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(),
|
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
|
email: customer.email?.trim().isEmpty ?? true
|
||||||
? null
|
? null
|
||||||
: customer.email!.trim(),
|
: 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
|
// 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.
|
// 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) {
|
return _store.customers.where((c) {
|
||||||
if (c.name.toLowerCase().contains(q)) return true;
|
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();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
77
lib/data/repositories/store_repository_impl.dart
Normal file
77
lib/data/repositories/store_repository_impl.dart
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import '../../core/constants/app_constants.dart';
|
||||||
|
import '../../domain/entities/store_account.dart';
|
||||||
|
import '../datasources/local_store.dart';
|
||||||
|
import '../local/app_database.dart';
|
||||||
|
|
||||||
|
/// The outlet this terminal belongs to, read from and written to its own
|
||||||
|
/// database.
|
||||||
|
///
|
||||||
|
/// Store details were compile-time constants, so the name, address and GSTIN
|
||||||
|
/// printed on every invoice could only be changed by rebuilding the app. On a
|
||||||
|
/// GST invoice those fields are a legal requirement, not decoration.
|
||||||
|
class StoreRepositoryImpl {
|
||||||
|
const StoreRepositoryImpl(this._store);
|
||||||
|
|
||||||
|
final LocalStore _store;
|
||||||
|
|
||||||
|
Future<StoreAccount> load({required String email}) async {
|
||||||
|
final catalogue = _store.catalogue;
|
||||||
|
|
||||||
|
// Seeded from the build's constants the first time, then owned by the
|
||||||
|
// database. Falling back to the constant on every read would silently undo
|
||||||
|
// an edit that failed to save.
|
||||||
|
return StoreAccount(
|
||||||
|
id: await catalogue.meta(MetaKeys.storeId) ?? 'store-001',
|
||||||
|
name: await catalogue.meta(MetaKeys.storeName) ?? AppConstants.storeName,
|
||||||
|
email: email,
|
||||||
|
address: await catalogue.meta(MetaKeys.storeAddress) ??
|
||||||
|
AppConstants.storeAddress,
|
||||||
|
gstin:
|
||||||
|
await catalogue.meta(MetaKeys.storeGstin) ?? AppConstants.storeGstin,
|
||||||
|
phone:
|
||||||
|
await catalogue.meta(MetaKeys.storePhone) ?? AppConstants.storePhone,
|
||||||
|
staff: await _store.staff.all(),
|
||||||
|
plan: await catalogue.meta(MetaKeys.storePlan) ?? 'Business',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> save({
|
||||||
|
required String name,
|
||||||
|
required String address,
|
||||||
|
required String gstin,
|
||||||
|
required String phone,
|
||||||
|
}) async {
|
||||||
|
final catalogue = _store.catalogue;
|
||||||
|
await catalogue.setMeta(MetaKeys.storeName, name.trim());
|
||||||
|
await catalogue.setMeta(MetaKeys.storeAddress, address.trim());
|
||||||
|
await catalogue.setMeta(MetaKeys.storeGstin, gstin.trim().toUpperCase());
|
||||||
|
await catalogue.setMeta(MetaKeys.storePhone, phone.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks a GSTIN's shape.
|
||||||
|
///
|
||||||
|
/// Not a lookup against the GST portal — this only catches a typo before it is
|
||||||
|
/// printed on a few hundred invoices. Format is 2 state digits, a 10-character
|
||||||
|
/// PAN, an entity digit, a literal Z, and a checksum character.
|
||||||
|
class GstinValidator {
|
||||||
|
const GstinValidator._();
|
||||||
|
|
||||||
|
static final RegExp _pattern = RegExp(
|
||||||
|
r'^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Returns an error message, or null when the GSTIN looks well formed.
|
||||||
|
static String? validate(String? value) {
|
||||||
|
final text = (value ?? '').trim().toUpperCase();
|
||||||
|
if (text.isEmpty) return 'A GSTIN is required on a tax invoice.';
|
||||||
|
if (text.length != 15) return 'A GSTIN is exactly 15 characters.';
|
||||||
|
if (!_pattern.hasMatch(text)) return 'That is not a valid GSTIN format.';
|
||||||
|
|
||||||
|
final stateCode = int.parse(text.substring(0, 2));
|
||||||
|
if (stateCode < 1 || stateCode > 38) {
|
||||||
|
return 'The first two digits are not a valid state code.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,20 +3,32 @@ import 'dart:convert';
|
|||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../../core/utils/formatters.dart';
|
import '../../core/utils/formatters.dart';
|
||||||
|
import '../../domain/entities/customer.dart';
|
||||||
import '../../domain/entities/shift_report.dart';
|
import '../../domain/entities/shift_report.dart';
|
||||||
import '../../domain/entities/sync_event.dart';
|
import '../../domain/entities/sync_event.dart';
|
||||||
import '../../domain/entities/transaction.dart';
|
import '../../domain/entities/transaction.dart';
|
||||||
import '../../domain/repositories/sync_repository.dart';
|
import '../../domain/repositories/sync_repository.dart';
|
||||||
import '../datasources/local_store.dart';
|
import '../datasources/local_store.dart';
|
||||||
import '../datasources/remote_catalogue_source.dart';
|
|
||||||
import '../local/order_dao.dart';
|
import '../local/order_dao.dart';
|
||||||
|
import '../remote/catalogue_source.dart';
|
||||||
|
import '../remote/order_transport.dart';
|
||||||
|
|
||||||
class SyncRepositoryImpl implements SyncRepository {
|
class SyncRepositoryImpl implements SyncRepository {
|
||||||
SyncRepositoryImpl(this._store, this._catalogue, this._orderSink);
|
SyncRepositoryImpl(
|
||||||
|
this._store,
|
||||||
|
this._catalogue,
|
||||||
|
this._transport, {
|
||||||
|
this.batchSize = 50,
|
||||||
|
});
|
||||||
|
|
||||||
final LocalStore _store;
|
final LocalStore _store;
|
||||||
final RemoteCatalogueSource _catalogue;
|
final CatalogueSource _catalogue;
|
||||||
final RemoteOrderSink _orderSink;
|
final OrderTransport _transport;
|
||||||
|
|
||||||
|
/// Bills per publish. Kept modest because an MQTT broker will refuse an
|
||||||
|
/// oversized message outright, and a terminal that has been offline for a
|
||||||
|
/// day can easily hold hundreds of bills.
|
||||||
|
final int batchSize;
|
||||||
|
|
||||||
static const _uuid = Uuid();
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
@@ -46,13 +58,21 @@ class SyncRepositoryImpl implements SyncRepository {
|
|||||||
final started = DateTime.now();
|
final started = DateTime.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final snapshot = await _catalogue.fetch(onProgress: onProgress);
|
// Carries the revision this terminal already holds, so the back office
|
||||||
|
// can answer with just what has moved. On a normal morning that is a
|
||||||
|
// handful of price changes rather than the whole book.
|
||||||
|
final snapshot = await _catalogue.fetch(
|
||||||
|
since: _store.catalogueRevision,
|
||||||
|
onProgress: onProgress,
|
||||||
|
);
|
||||||
|
|
||||||
await _store.importCatalogue(
|
await _store.importCatalogue(
|
||||||
products: snapshot.products,
|
products: snapshot.products,
|
||||||
customers: snapshot.customers,
|
customers: snapshot.customers,
|
||||||
revision: snapshot.revision,
|
revision: snapshot.revision,
|
||||||
at: snapshot.fetchedAt,
|
at: snapshot.fetchedAt,
|
||||||
|
isDelta: snapshot.isDelta,
|
||||||
|
retiredProductIds: snapshot.retiredProductIds,
|
||||||
);
|
);
|
||||||
|
|
||||||
final event = SyncEvent(
|
final event = SyncEvent(
|
||||||
@@ -61,12 +81,20 @@ class SyncRepositoryImpl implements SyncRepository {
|
|||||||
status: SyncStatus.synced,
|
status: SyncStatus.synced,
|
||||||
createdAt: started,
|
createdAt: started,
|
||||||
syncedAt: DateTime.now(),
|
syncedAt: DateTime.now(),
|
||||||
summary: '${snapshot.products.length} products saved to SQLite '
|
summary: snapshot.isDelta
|
||||||
|
? (snapshot.isEmpty
|
||||||
|
? 'Already up to date · ${snapshot.revision}'
|
||||||
|
: '${snapshot.changeCount} changes applied '
|
||||||
|
'· ${snapshot.revision}')
|
||||||
|
: '${snapshot.products.length} products saved to SQLite '
|
||||||
'· ${snapshot.revision}',
|
'· ${snapshot.revision}',
|
||||||
payload: {
|
payload: {
|
||||||
'products': snapshot.products.length,
|
'products': snapshot.products.length,
|
||||||
'customers': snapshot.customers.length,
|
'customers': snapshot.customers.length,
|
||||||
|
'retired': snapshot.retiredProductIds.length,
|
||||||
|
'delta': snapshot.isDelta,
|
||||||
'revision': snapshot.revision,
|
'revision': snapshot.revision,
|
||||||
|
'via': _catalogue.label,
|
||||||
},
|
},
|
||||||
attempts: 1,
|
attempts: 1,
|
||||||
);
|
);
|
||||||
@@ -150,6 +178,7 @@ class SyncRepositoryImpl implements SyncRepository {
|
|||||||
createdAt:
|
createdAt:
|
||||||
DateTime.fromMillisecondsSinceEpoch(r['created_at']! as int),
|
DateTime.fromMillisecondsSinceEpoch(r['created_at']! as int),
|
||||||
isSynced: (r['sync_status']! as int) == OrderDao.synced,
|
isSynced: (r['sync_status']! as int) == OrderDao.synced,
|
||||||
|
itemCount: (r['item_count'] as num?)?.toDouble() ?? 0,
|
||||||
syncedAt: r['synced_at'] == null
|
syncedAt: r['synced_at'] == null
|
||||||
? null
|
? null
|
||||||
: DateTime.fromMillisecondsSinceEpoch(r['synced_at']! as int),
|
: DateTime.fromMillisecondsSinceEpoch(r['synced_at']! as int),
|
||||||
@@ -176,13 +205,14 @@ class SyncRepositoryImpl implements SyncRepository {
|
|||||||
|
|
||||||
var attempted = 0;
|
var attempted = 0;
|
||||||
var uploaded = 0;
|
var uploaded = 0;
|
||||||
|
var refused = 0;
|
||||||
final syncedInvoices = <String>[];
|
final syncedInvoices = <String>[];
|
||||||
|
|
||||||
// `unsynced()` returns a bounded page. Draining it in a loop means a day
|
// `unsynced()` returns a bounded page. Draining it in a loop means a day
|
||||||
// with more bills than one page still uploads completely, instead of
|
// with more bills than one page still uploads completely, instead of
|
||||||
// reporting success with the remainder silently left behind.
|
// reporting success with the remainder silently left behind.
|
||||||
while (true) {
|
while (true) {
|
||||||
final batch = await _store.orders.unsynced();
|
final batch = await _store.orders.unsynced(limit: batchSize);
|
||||||
if (batch.isEmpty) break;
|
if (batch.isEmpty) break;
|
||||||
|
|
||||||
final ids = batch.map((o) => o.id).toList();
|
final ids = batch.map((o) => o.id).toList();
|
||||||
@@ -193,36 +223,19 @@ class SyncRepositoryImpl implements SyncRepository {
|
|||||||
'Uploading $attempted of $total bills…',
|
'Uploading $attempted of $total bills…',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
PushReceipt receipt;
|
||||||
try {
|
try {
|
||||||
final accepted = await _orderSink.pushOrders(
|
receipt = await _transport.pushOrders(
|
||||||
batch.map(_orderToPayload).toList(),
|
batch.map(_orderToPayload).toList(),
|
||||||
);
|
);
|
||||||
|
} on Object catch (e) {
|
||||||
// Only what the server confirmed is archived and removed. Anything it
|
// The outcome is unknown, so nothing is marked sent. The attempt is
|
||||||
// did not acknowledge stays on disk.
|
// recorded against the rows and every one of them stays at 0.
|
||||||
final acceptedOrders =
|
|
||||||
batch.where((o) => accepted.contains(o.id)).toList();
|
|
||||||
await _store.orders.archiveAndDelete(acceptedOrders);
|
|
||||||
await _store.refreshUnsyncedCount();
|
|
||||||
|
|
||||||
uploaded += acceptedOrders.length;
|
|
||||||
syncedInvoices.addAll(acceptedOrders.map((o) => o.invoiceNumber));
|
|
||||||
|
|
||||||
final rejected = ids.where((id) => !accepted.contains(id)).toList();
|
|
||||||
if (rejected.isNotEmpty) {
|
|
||||||
await _store.orders.markFailed(rejected, 'Rejected by server');
|
|
||||||
// Rejected rows stay pending, so the next page would return the same
|
|
||||||
// bills forever. Stop and let the cashier retry.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Transport failed: record the attempt but leave every row at 0.
|
|
||||||
await _store.orders.markFailed(ids, e.toString());
|
await _store.orders.markFailed(ids, e.toString());
|
||||||
await _store.refreshUnsyncedCount();
|
await _store.refreshUnsyncedCount();
|
||||||
|
|
||||||
final remaining = await _store.orders.unsyncedCount();
|
final remaining = await _store.orders.unsyncedCount();
|
||||||
final pendingValue =
|
final pendingValue = batch.fold<double>(0, (s, o) => s + o.total);
|
||||||
batch.fold<double>(0, (s, o) => s + o.total);
|
|
||||||
|
|
||||||
await _log(SyncEvent(
|
await _log(SyncEvent(
|
||||||
id: _uuid.v4(),
|
id: _uuid.v4(),
|
||||||
@@ -238,35 +251,199 @@ class SyncRepositoryImpl implements SyncRepository {
|
|||||||
return SyncOutcome(
|
return SyncOutcome(
|
||||||
attempted: attempted,
|
attempted: attempted,
|
||||||
uploaded: uploaded,
|
uploaded: uploaded,
|
||||||
|
rejected: refused,
|
||||||
error: e.toString(),
|
error: e.toString(),
|
||||||
|
isRetryable: e is! TransportException || e.retryable,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only what the back office named is archived and marked synced.
|
||||||
|
// Anything it stayed silent about is left pending — silence is not
|
||||||
|
// acceptance, whatever the transport reported at its own layer.
|
||||||
|
final acceptedIds = receipt.accepted.toSet();
|
||||||
|
final acceptedOrders =
|
||||||
|
batch.where((o) => acceptedIds.contains(o.id)).toList();
|
||||||
|
await _store.orders.archiveAccepted(acceptedOrders);
|
||||||
|
await _store.refreshUnsyncedCount();
|
||||||
|
|
||||||
|
uploaded += acceptedOrders.length;
|
||||||
|
syncedInvoices.addAll(acceptedOrders.map((o) => o.invoiceNumber));
|
||||||
|
|
||||||
|
final unconfirmed = ids.where((id) => !acceptedIds.contains(id)).toList();
|
||||||
|
if (unconfirmed.isNotEmpty) {
|
||||||
|
for (final id in unconfirmed) {
|
||||||
|
await _store.orders.markFailed(
|
||||||
|
[id],
|
||||||
|
receipt.rejected[id] ?? 'Not confirmed by the back office',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
refused += unconfirmed.length;
|
||||||
|
|
||||||
|
// These rows are still pending, so the next page would hand back the
|
||||||
|
// same bills forever. Stop, and let a person look at why.
|
||||||
|
final reasons = receipt.rejected.values.toSet().join('; ');
|
||||||
|
await _log(SyncEvent(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
type: SyncEventType.shiftReport,
|
||||||
|
status: SyncStatus.failed,
|
||||||
|
createdAt: started,
|
||||||
|
summary: '$uploaded uploaded, ${unconfirmed.length} refused',
|
||||||
|
error: reasons.isEmpty ? 'Not confirmed by the back office' : reasons,
|
||||||
|
attempts: 1,
|
||||||
|
),);
|
||||||
|
|
||||||
|
return SyncOutcome(
|
||||||
|
attempted: attempted,
|
||||||
|
uploaded: uploaded,
|
||||||
|
rejected: refused,
|
||||||
|
error: '${unconfirmed.length} bill(s) were not accepted'
|
||||||
|
'${reasons.isEmpty ? '' : ': $reasons'}',
|
||||||
|
// A refusal is a decision, not a fault. Retrying the same bytes gets
|
||||||
|
// the same answer, so the engine halts instead of looping.
|
||||||
|
isRetryable: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onProgress?.call(0.95, 'Tidying up…');
|
||||||
|
await purgeExpired();
|
||||||
onProgress?.call(1, 'Done');
|
onProgress?.call(1, 'Done');
|
||||||
|
|
||||||
// Synced bills are deleted from the terminal, so this log line is the only
|
// Once the retention window closes this log line is the only remaining
|
||||||
// remaining record on the device that they went up.
|
// record on the device that these bills went up.
|
||||||
await _log(SyncEvent(
|
await _log(SyncEvent(
|
||||||
id: _uuid.v4(),
|
id: _uuid.v4(),
|
||||||
type: SyncEventType.shiftReport,
|
type: SyncEventType.shiftReport,
|
||||||
status: SyncStatus.synced,
|
status: SyncStatus.synced,
|
||||||
createdAt: started,
|
createdAt: started,
|
||||||
syncedAt: DateTime.now(),
|
syncedAt: DateTime.now(),
|
||||||
summary: '$uploaded of $attempted bills uploaded',
|
summary: '$uploaded of $attempted bills uploaded '
|
||||||
|
'via ${_transport.label}',
|
||||||
payload: {'invoices': syncedInvoices},
|
payload: {'invoices': syncedInvoices},
|
||||||
attempts: 1,
|
attempts: 1,
|
||||||
),);
|
),);
|
||||||
|
|
||||||
|
return SyncOutcome(
|
||||||
|
attempted: attempted,
|
||||||
|
uploaded: uploaded,
|
||||||
|
rejected: refused,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> purgeExpired() => _store.orders.purgeSyncedBefore(
|
||||||
|
DateTime.now().subtract(OrderDao.retentionWindow),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ------------------------------------------------- Registrations: uplink
|
||||||
|
@override
|
||||||
|
Future<int> unsyncedCustomerCount() =>
|
||||||
|
_store.catalogue.unsyncedCustomerCount();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SyncOutcome> syncCustomers() async {
|
||||||
|
final started = DateTime.now();
|
||||||
|
var attempted = 0;
|
||||||
|
var uploaded = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
final batch = await _store.catalogue.unsyncedCustomers(limit: batchSize);
|
||||||
|
if (batch.isEmpty) break;
|
||||||
|
|
||||||
|
attempted += batch.length;
|
||||||
|
|
||||||
|
PushReceipt receipt;
|
||||||
|
try {
|
||||||
|
receipt = await _transport.pushCustomers(
|
||||||
|
batch.map(_customerToPayload).toList(),
|
||||||
|
);
|
||||||
|
} on Object catch (e) {
|
||||||
|
// Nothing is marked sent when the outcome is unknown. Unlike a bill,
|
||||||
|
// a registration is safe to send twice, so this simply waits for the
|
||||||
|
// next pass rather than needing a per-row attempt counter.
|
||||||
|
if (attempted > batch.length || uploaded > 0) {
|
||||||
|
await _store.refreshUnsyncedCustomerCount();
|
||||||
|
}
|
||||||
|
return SyncOutcome(
|
||||||
|
attempted: attempted,
|
||||||
|
uploaded: uploaded,
|
||||||
|
error: e.toString(),
|
||||||
|
isRetryable: e is! TransportException || e.retryable,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final acceptedIds = receipt.accepted.toSet();
|
||||||
|
await _store.catalogue.markCustomersSynced(acceptedIds.toList());
|
||||||
|
uploaded += acceptedIds.length;
|
||||||
|
|
||||||
|
// Nothing moved, so the next page would hand back the same rows for
|
||||||
|
// ever. Stop and let the events log show why.
|
||||||
|
if (acceptedIds.isEmpty) {
|
||||||
|
final reasons = receipt.rejected.values.toSet().join('; ');
|
||||||
|
await _log(SyncEvent(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
type: SyncEventType.catalogueImport,
|
||||||
|
status: SyncStatus.failed,
|
||||||
|
createdAt: started,
|
||||||
|
summary: '${batch.length} registrations were not accepted',
|
||||||
|
error:
|
||||||
|
reasons.isEmpty ? 'Not confirmed by the back office' : reasons,
|
||||||
|
attempts: 1,
|
||||||
|
),);
|
||||||
|
await _store.refreshUnsyncedCustomerCount();
|
||||||
|
return SyncOutcome(
|
||||||
|
attempted: attempted,
|
||||||
|
uploaded: uploaded,
|
||||||
|
rejected: batch.length,
|
||||||
|
error: 'No registration in this batch was accepted'
|
||||||
|
'${reasons.isEmpty ? '' : ': $reasons'}',
|
||||||
|
isRetryable: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _store.refreshUnsyncedCustomerCount();
|
||||||
|
|
||||||
|
if (uploaded > 0) {
|
||||||
|
await _log(SyncEvent(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
type: SyncEventType.catalogueImport,
|
||||||
|
status: SyncStatus.synced,
|
||||||
|
createdAt: started,
|
||||||
|
syncedAt: DateTime.now(),
|
||||||
|
summary: '$uploaded shopper registrations uploaded '
|
||||||
|
'via ${_transport.label}',
|
||||||
|
attempts: 1,
|
||||||
|
),);
|
||||||
|
}
|
||||||
|
|
||||||
return SyncOutcome(attempted: attempted, uploaded: uploaded);
|
return SyncOutcome(attempted: attempted, uploaded: uploaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The JSON body sent per order.
|
/// The JSON body sent per registration.
|
||||||
|
///
|
||||||
|
/// Identity and profile only. Points, spend and visit counts are deliberately
|
||||||
|
/// left out: they are derived from the bill stream, which is authoritative and
|
||||||
|
/// idempotent. Uploading a terminal's local balance would make the last till
|
||||||
|
/// to sync win, and a shopper who bought something at two counters on the same
|
||||||
|
/// day would end up with whichever figure arrived second.
|
||||||
|
Map<String, Object?> _customerToPayload(Customer c) => {
|
||||||
|
'id': c.id,
|
||||||
|
'mobile': c.mobile,
|
||||||
|
'name': c.name,
|
||||||
|
'email': c.email,
|
||||||
|
'gender': c.gender.name,
|
||||||
|
'date_of_birth': c.dateOfBirth?.toIso8601String(),
|
||||||
|
'registered_at': _iso(c.createdAt),
|
||||||
|
'registered_by_terminal': _store.terminal.code,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The JSON body sent per order. Matches the back office's `/orders`
|
||||||
|
/// schema field for field — nothing added beyond it.
|
||||||
Map<String, Object?> _orderToPayload(SaleTransaction t) => {
|
Map<String, Object?> _orderToPayload(SaleTransaction t) => {
|
||||||
'id': t.id,
|
'id': t.id,
|
||||||
'invoice_number': t.invoiceNumber,
|
'invoice_number': t.invoiceNumber,
|
||||||
'created_at': t.createdAt.toIso8601String(),
|
'created_at': Formatters.isoWithOffset(t.createdAt),
|
||||||
'terminal_id': t.terminalId,
|
|
||||||
'cashier': t.cashierName,
|
'cashier': t.cashierName,
|
||||||
'customer': t.customer == null
|
'customer': t.customer == null
|
||||||
? null
|
? null
|
||||||
@@ -278,6 +455,15 @@ class SyncRepositoryImpl implements SyncRepository {
|
|||||||
'subtotal': t.cart.subtotal,
|
'subtotal': t.cart.subtotal,
|
||||||
'discount': t.cart.billDiscountTotal + t.cart.lineDiscountTotal,
|
'discount': t.cart.billDiscountTotal + t.cart.lineDiscountTotal,
|
||||||
'tax': t.cart.taxAmount,
|
'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,
|
'round_off': t.cart.roundOff,
|
||||||
'total': t.total,
|
'total': t.total,
|
||||||
'points_earned': t.pointsEarned,
|
'points_earned': t.pointsEarned,
|
||||||
@@ -306,3 +492,12 @@ class SyncRepositoryImpl implements SyncRepository {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [Formatters.isoWithOffset], tolerating a null.
|
||||||
|
///
|
||||||
|
/// Several of these are genuinely absent — a till that has never uploaded has
|
||||||
|
/// no last upload, and one with an empty queue has no oldest pending bill.
|
||||||
|
/// Omitting the key is the honest answer; sending an epoch would put 1970 on a
|
||||||
|
/// dashboard and read as a real reading.
|
||||||
|
String? _iso(DateTime? time) =>
|
||||||
|
time == null ? null : Formatters.isoWithOffset(time);
|
||||||
|
|||||||
@@ -30,6 +30,35 @@ class TransactionRepositoryImpl implements TransactionRepository {
|
|||||||
await _store.refreshUnsyncedCount();
|
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
|
@override
|
||||||
Future<List<SaleTransaction>> history({int limit = 50}) =>
|
Future<List<SaleTransaction>> history({int limit = 50}) =>
|
||||||
_store.orders.recent(limit: limit);
|
_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);
|
||||||
109
lib/data/sync/presence_reporter.dart
Normal file
109
lib/data/sync/presence_reporter.dart
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import '../../core/config/sync_config.dart';
|
||||||
|
import '../local/terminal_identity.dart';
|
||||||
|
import '../remote/mqtt_order_transport.dart';
|
||||||
|
import 'sync_engine.dart';
|
||||||
|
|
||||||
|
/// Publishes what this till is doing, so a fleet of them can be watched.
|
||||||
|
///
|
||||||
|
/// The Last Will already answers "is it dead" — the broker publishes `offline`
|
||||||
|
/// when a terminal stops responding. That is not enough to run 100 shops on: a
|
||||||
|
/// till can be connected and still be broken, holding 200 unsent bills or
|
||||||
|
/// running last month's catalogue. This publishes the state that distinguishes
|
||||||
|
/// *reachable* from *healthy*.
|
||||||
|
///
|
||||||
|
/// Retained on purpose. A dashboard that connects at noon gets every terminal's
|
||||||
|
/// last report immediately, instead of a blank board until each one happens to
|
||||||
|
/// tick.
|
||||||
|
class PresenceReporter {
|
||||||
|
PresenceReporter({
|
||||||
|
required MqttOrderTransport transport,
|
||||||
|
required TerminalIdentity terminal,
|
||||||
|
required SyncConfig config,
|
||||||
|
required SyncEngine engine,
|
||||||
|
required this.appVersion,
|
||||||
|
required Future<String?> Function() catalogueRevision,
|
||||||
|
Duration interval = const Duration(minutes: 1),
|
||||||
|
DateTime Function()? clock,
|
||||||
|
Timer Function(Duration, void Function())? scheduleTimer,
|
||||||
|
}) : _transport = transport,
|
||||||
|
_terminal = terminal,
|
||||||
|
_config = config,
|
||||||
|
_engine = engine,
|
||||||
|
_catalogueRevision = catalogueRevision,
|
||||||
|
_interval = interval,
|
||||||
|
_now = clock ?? DateTime.now,
|
||||||
|
_schedule = scheduleTimer ?? Timer.new;
|
||||||
|
|
||||||
|
final MqttOrderTransport _transport;
|
||||||
|
final TerminalIdentity _terminal;
|
||||||
|
final SyncConfig _config;
|
||||||
|
final SyncEngine _engine;
|
||||||
|
final Future<String?> Function() _catalogueRevision;
|
||||||
|
final Duration _interval;
|
||||||
|
final DateTime Function() _now;
|
||||||
|
final Timer Function(Duration, void Function()) _schedule;
|
||||||
|
|
||||||
|
final String appVersion;
|
||||||
|
|
||||||
|
Timer? _timer;
|
||||||
|
bool _stopped = false;
|
||||||
|
|
||||||
|
Future<void> start() async {
|
||||||
|
if (_stopped) throw StateError('This PresenceReporter has been disposed.');
|
||||||
|
await publish();
|
||||||
|
_tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _tick() {
|
||||||
|
_timer = _schedule(_interval, () {
|
||||||
|
if (_stopped) return;
|
||||||
|
unawaited(publish());
|
||||||
|
_tick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One presence record.
|
||||||
|
///
|
||||||
|
/// Failures are swallowed. A terminal that cannot tell head office how it is
|
||||||
|
/// must still sell — losing a heartbeat is a monitoring gap, not a reason to
|
||||||
|
/// stop trading.
|
||||||
|
Future<void> publish() async {
|
||||||
|
if (!_transport.isConnected) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final state = _engine.state;
|
||||||
|
await _transport.publishStatus(
|
||||||
|
jsonEncode({
|
||||||
|
'schema': 1,
|
||||||
|
'state': 'online',
|
||||||
|
'device_id': _terminal.deviceId,
|
||||||
|
'terminal_code': _terminal.code,
|
||||||
|
'terminal_name': _terminal.name,
|
||||||
|
'store_id': _terminal.storeId,
|
||||||
|
'app_version': appVersion,
|
||||||
|
'reported_at': _now().toIso8601String(),
|
||||||
|
|
||||||
|
// The three numbers that separate a healthy till from a broken one.
|
||||||
|
'pending_bills': state.pending,
|
||||||
|
'last_upload_at': state.lastSuccessAt?.toIso8601String(),
|
||||||
|
'catalogue_revision': await _catalogueRevision(),
|
||||||
|
|
||||||
|
'sync_halted': state.isHalted,
|
||||||
|
'sync_error': state.lastError,
|
||||||
|
'consecutive_failures': state.consecutiveFailures,
|
||||||
|
'transport': _config.transport.name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} on Object {
|
||||||
|
// Deliberately silent — see above.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose() async {
|
||||||
|
_stopped = true;
|
||||||
|
_timer?.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
371
lib/data/sync/sync_engine.dart
Normal file
371
lib/data/sync/sync_engine.dart
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../../domain/repositories/sync_repository.dart';
|
||||||
|
import '../remote/order_transport.dart';
|
||||||
|
|
||||||
|
/// Why a drain was attempted. Shown in the events log, because "why did it try
|
||||||
|
/// then" is the first question when a sync misbehaves.
|
||||||
|
enum SyncTrigger {
|
||||||
|
startup,
|
||||||
|
saleCommitted,
|
||||||
|
connectivityRegained,
|
||||||
|
periodic,
|
||||||
|
retry,
|
||||||
|
headOfficeRequest,
|
||||||
|
manual,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the engine is doing, for the header and the events screen.
|
||||||
|
@immutable
|
||||||
|
class SyncEngineState {
|
||||||
|
const SyncEngineState({
|
||||||
|
this.isSyncing = false,
|
||||||
|
this.pending = 0,
|
||||||
|
this.online = true,
|
||||||
|
this.consecutiveFailures = 0,
|
||||||
|
this.lastSuccessAt,
|
||||||
|
this.lastAttemptAt,
|
||||||
|
this.nextAttemptAt,
|
||||||
|
this.lastError,
|
||||||
|
this.isHalted = false,
|
||||||
|
this.lastTrigger,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool isSyncing;
|
||||||
|
final int pending;
|
||||||
|
final bool online;
|
||||||
|
final int consecutiveFailures;
|
||||||
|
final DateTime? lastSuccessAt;
|
||||||
|
final DateTime? lastAttemptAt;
|
||||||
|
|
||||||
|
/// When the backoff timer will fire. Null when nothing is scheduled.
|
||||||
|
final DateTime? nextAttemptAt;
|
||||||
|
|
||||||
|
final String? lastError;
|
||||||
|
|
||||||
|
/// Set after a failure that retrying cannot fix — a bad credential, an
|
||||||
|
/// unconfigured endpoint. The engine stops its own scheduling so it does not
|
||||||
|
/// hammer a door that is locked; a manual sync or a config change resumes it.
|
||||||
|
final bool isHalted;
|
||||||
|
|
||||||
|
final SyncTrigger? lastTrigger;
|
||||||
|
|
||||||
|
bool get isHealthy => !isHalted && consecutiveFailures == 0;
|
||||||
|
|
||||||
|
SyncEngineState copyWith({
|
||||||
|
bool? isSyncing,
|
||||||
|
int? pending,
|
||||||
|
bool? online,
|
||||||
|
int? consecutiveFailures,
|
||||||
|
DateTime? lastSuccessAt,
|
||||||
|
DateTime? lastAttemptAt,
|
||||||
|
DateTime? nextAttemptAt,
|
||||||
|
String? lastError,
|
||||||
|
bool? isHalted,
|
||||||
|
SyncTrigger? lastTrigger,
|
||||||
|
bool clearNextAttempt = false,
|
||||||
|
bool clearError = false,
|
||||||
|
}) =>
|
||||||
|
SyncEngineState(
|
||||||
|
isSyncing: isSyncing ?? this.isSyncing,
|
||||||
|
pending: pending ?? this.pending,
|
||||||
|
online: online ?? this.online,
|
||||||
|
consecutiveFailures: consecutiveFailures ?? this.consecutiveFailures,
|
||||||
|
lastSuccessAt: lastSuccessAt ?? this.lastSuccessAt,
|
||||||
|
lastAttemptAt: lastAttemptAt ?? this.lastAttemptAt,
|
||||||
|
nextAttemptAt:
|
||||||
|
clearNextAttempt ? null : nextAttemptAt ?? this.nextAttemptAt,
|
||||||
|
lastError: clearError ? null : lastError ?? this.lastError,
|
||||||
|
isHalted: isHalted ?? this.isHalted,
|
||||||
|
lastTrigger: lastTrigger ?? this.lastTrigger,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decides *when* bills are uploaded.
|
||||||
|
///
|
||||||
|
/// The repository knows how to send one batch; this knows when to ask, and what
|
||||||
|
/// to do when the answer is no. Together they turn the orders table into a
|
||||||
|
/// queue that empties itself:
|
||||||
|
///
|
||||||
|
/// * a sale is committed — try immediately, so a bill is usually up within
|
||||||
|
/// seconds of the drawer closing;
|
||||||
|
/// * the network returns — try at once rather than waiting out a poll;
|
||||||
|
/// * nothing happened for a while — poll, because interface state lies;
|
||||||
|
/// * head office asked — the MQTT downlink can pull a shift up on demand;
|
||||||
|
/// * the cashier pressed sync — always allowed, even while halted.
|
||||||
|
///
|
||||||
|
/// ### Two guarantees worth stating
|
||||||
|
///
|
||||||
|
/// **Single flight.** Only one drain runs at a time. Without this, a busy till
|
||||||
|
/// firing a trigger per sale would have several passes reading the same pending
|
||||||
|
/// rows and publishing them concurrently — every bill sent two or three times.
|
||||||
|
/// A trigger arriving mid-drain sets a flag and is honoured once the current
|
||||||
|
/// pass finishes, so nothing is dropped either.
|
||||||
|
///
|
||||||
|
/// **Backoff with jitter.** A failed attempt waits, and waits longer each time,
|
||||||
|
/// to a ceiling. The jitter matters more than it looks: when a shop's line
|
||||||
|
/// drops, every terminal in the store fails at the same instant, and without it
|
||||||
|
/// they would all retry in lockstep and keep colliding on the way back up.
|
||||||
|
class SyncEngine {
|
||||||
|
SyncEngine({
|
||||||
|
required SyncRepository repository,
|
||||||
|
Stream<bool>? connectivity,
|
||||||
|
Stream<DownlinkMessage>? downlink,
|
||||||
|
Future<void> Function()? onCatalogueChanged,
|
||||||
|
Duration idlePoll = const Duration(minutes: 5),
|
||||||
|
Duration baseBackoff = const Duration(seconds: 2),
|
||||||
|
Duration maxBackoff = const Duration(minutes: 5),
|
||||||
|
Random? random,
|
||||||
|
DateTime Function()? clock,
|
||||||
|
Timer Function(Duration, void Function())? scheduleTimer,
|
||||||
|
}) : _repository = repository,
|
||||||
|
_connectivity = connectivity,
|
||||||
|
_downlink = downlink,
|
||||||
|
_onCatalogueChanged = onCatalogueChanged,
|
||||||
|
_idlePoll = idlePoll,
|
||||||
|
_baseBackoff = baseBackoff,
|
||||||
|
_maxBackoff = maxBackoff,
|
||||||
|
_random = random ?? Random(),
|
||||||
|
_now = clock ?? DateTime.now,
|
||||||
|
_schedule = scheduleTimer ?? Timer.new;
|
||||||
|
|
||||||
|
final SyncRepository _repository;
|
||||||
|
final Stream<bool>? _connectivity;
|
||||||
|
final Stream<DownlinkMessage>? _downlink;
|
||||||
|
final Future<void> Function()? _onCatalogueChanged;
|
||||||
|
|
||||||
|
final Duration _idlePoll;
|
||||||
|
final Duration _baseBackoff;
|
||||||
|
final Duration _maxBackoff;
|
||||||
|
final Random _random;
|
||||||
|
final DateTime Function() _now;
|
||||||
|
final Timer Function(Duration, void Function()) _schedule;
|
||||||
|
|
||||||
|
final _states = StreamController<SyncEngineState>.broadcast();
|
||||||
|
|
||||||
|
SyncEngineState _state = const SyncEngineState();
|
||||||
|
SyncEngineState get state => _state;
|
||||||
|
Stream<SyncEngineState> get states => _states.stream;
|
||||||
|
|
||||||
|
/// Held for the whole of a drain. The single-flight guarantee rests on this
|
||||||
|
/// being checked and set without an `await` in between.
|
||||||
|
bool _draining = false;
|
||||||
|
|
||||||
|
/// A trigger that arrived while a drain was already running.
|
||||||
|
SyncTrigger? _queuedTrigger;
|
||||||
|
|
||||||
|
Timer? _backoffTimer;
|
||||||
|
Timer? _pollTimer;
|
||||||
|
StreamSubscription<bool>? _connectivitySub;
|
||||||
|
StreamSubscription<DownlinkMessage>? _downlinkSub;
|
||||||
|
bool _stopped = false;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Life
|
||||||
|
Future<void> start() async {
|
||||||
|
if (_stopped) throw StateError('This SyncEngine has been disposed.');
|
||||||
|
|
||||||
|
_connectivitySub = _connectivity?.listen((online) {
|
||||||
|
_emit(_state.copyWith(online: online));
|
||||||
|
// Coming back is the single best moment to try; going offline is not
|
||||||
|
// worth an attempt that is certain to fail.
|
||||||
|
if (online) nudge(SyncTrigger.connectivityRegained);
|
||||||
|
});
|
||||||
|
|
||||||
|
_downlinkSub = _downlink?.listen(_onDownlink);
|
||||||
|
|
||||||
|
_pollTimer = _startPoll();
|
||||||
|
|
||||||
|
await _refreshPending();
|
||||||
|
nudge(SyncTrigger.startup);
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer _startPoll() => _schedule(_idlePoll, () {
|
||||||
|
if (_stopped) return;
|
||||||
|
_pollTimer = _startPoll();
|
||||||
|
nudge(SyncTrigger.periodic);
|
||||||
|
});
|
||||||
|
|
||||||
|
void _onDownlink(DownlinkMessage message) {
|
||||||
|
switch (message.kind) {
|
||||||
|
case DownlinkKind.syncRequested:
|
||||||
|
nudge(SyncTrigger.headOfficeRequest);
|
||||||
|
case DownlinkKind.catalogueChanged:
|
||||||
|
// Sending what we owe before pulling new prices keeps the bills we
|
||||||
|
// already rang priced as they were rung.
|
||||||
|
nudge(SyncTrigger.headOfficeRequest);
|
||||||
|
unawaited(_onCatalogueChanged?.call() ?? Future<void>.value());
|
||||||
|
case DownlinkKind.unknown:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- Triggers
|
||||||
|
/// Asks for a drain. Cheap, non-blocking, and safe to call on every sale.
|
||||||
|
void nudge(SyncTrigger trigger) {
|
||||||
|
if (_stopped) return;
|
||||||
|
|
||||||
|
if (_draining) {
|
||||||
|
// Remember it rather than dropping it: bills committed during this pass
|
||||||
|
// were not in the set it read, and would otherwise wait for the poll.
|
||||||
|
_queuedTrigger = trigger;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_state.isHalted && trigger != SyncTrigger.manual) return;
|
||||||
|
if (!_state.online && trigger != SyncTrigger.manual) return;
|
||||||
|
|
||||||
|
unawaited(_drain(trigger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cashier pressed sync. Runs even when halted or believed offline —
|
||||||
|
/// they may know something the engine does not, and being told why it failed
|
||||||
|
/// beats a button that does nothing.
|
||||||
|
Future<SyncOutcome> syncNow({
|
||||||
|
void Function(double progress, String stage)? onProgress,
|
||||||
|
}) async {
|
||||||
|
_backoffTimer?.cancel();
|
||||||
|
_emit(_state.copyWith(isHalted: false, clearNextAttempt: true));
|
||||||
|
return _drain(SyncTrigger.manual, onProgress: onProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- Drain
|
||||||
|
Future<SyncOutcome> _drain(
|
||||||
|
SyncTrigger trigger, {
|
||||||
|
void Function(double progress, String stage)? onProgress,
|
||||||
|
}) async {
|
||||||
|
if (_draining) return const SyncOutcome(attempted: 0, uploaded: 0);
|
||||||
|
_draining = true;
|
||||||
|
|
||||||
|
_backoffTimer?.cancel();
|
||||||
|
_emit(_state.copyWith(
|
||||||
|
isSyncing: true,
|
||||||
|
lastTrigger: trigger,
|
||||||
|
lastAttemptAt: _now(),
|
||||||
|
clearNextAttempt: true,
|
||||||
|
),);
|
||||||
|
|
||||||
|
SyncOutcome outcome;
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
// Registrations first, so a bill naming a shopper the back office has
|
||||||
|
// never heard of arrives after the shopper does. A failure here is
|
||||||
|
// logged and swallowed: shoppers waiting to go up must never be the
|
||||||
|
// reason a day's takings stay on the terminal.
|
||||||
|
try {
|
||||||
|
await _repository.syncCustomers();
|
||||||
|
} on Object {
|
||||||
|
// Deliberately ignored — the next pass tries again, and the events
|
||||||
|
// log already carries the reason.
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome = await _repository.syncOrders(onProgress: onProgress);
|
||||||
|
} on Object catch (e) {
|
||||||
|
// The repository is meant to fold failures into the outcome; anything
|
||||||
|
// escaping it is a defect, not a network fault. Treated as a retryable
|
||||||
|
// failure so a bad build still empties its queue once fixed.
|
||||||
|
outcome = SyncOutcome(attempted: 0, uploaded: 0, error: e.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
await _refreshPending();
|
||||||
|
|
||||||
|
if (outcome.isSuccess) {
|
||||||
|
_emit(_state.copyWith(
|
||||||
|
isSyncing: false,
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
lastSuccessAt: _now(),
|
||||||
|
clearError: true,
|
||||||
|
clearNextAttempt: true,
|
||||||
|
),);
|
||||||
|
} else {
|
||||||
|
_onFailure(outcome);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Held until the bookkeeping is done, not just until the send is. Freed
|
||||||
|
// any earlier, a queued trigger could start a second drain whose
|
||||||
|
// `isSyncing: true` this one would then overwrite with `false`, leaving
|
||||||
|
// the header claiming idle while an upload is in flight.
|
||||||
|
_draining = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Honour anything that arrived while we were busy. Bills committed during
|
||||||
|
// the pass were not in the set it read.
|
||||||
|
final queued = _queuedTrigger;
|
||||||
|
_queuedTrigger = null;
|
||||||
|
if (queued != null && outcome.isSuccess && _state.pending > 0) {
|
||||||
|
nudge(queued);
|
||||||
|
}
|
||||||
|
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFailure(SyncOutcome outcome) {
|
||||||
|
final failures = _state.consecutiveFailures + 1;
|
||||||
|
|
||||||
|
if (!outcome.isRetryable) {
|
||||||
|
_emit(_state.copyWith(
|
||||||
|
isSyncing: false,
|
||||||
|
consecutiveFailures: failures,
|
||||||
|
lastError: outcome.error,
|
||||||
|
isHalted: true,
|
||||||
|
clearNextAttempt: true,
|
||||||
|
),);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final delay = backoffFor(failures);
|
||||||
|
_emit(_state.copyWith(
|
||||||
|
isSyncing: false,
|
||||||
|
consecutiveFailures: failures,
|
||||||
|
lastError: outcome.error,
|
||||||
|
nextAttemptAt: _now().add(delay),
|
||||||
|
),);
|
||||||
|
|
||||||
|
_backoffTimer = _schedule(delay, () {
|
||||||
|
if (_stopped) return;
|
||||||
|
nudge(SyncTrigger.retry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Doubles per failure to a ceiling, then ±20% so a store's terminals do not
|
||||||
|
/// come back in lockstep.
|
||||||
|
@visibleForTesting
|
||||||
|
Duration backoffFor(int failures) {
|
||||||
|
final exponent = (failures - 1).clamp(0, 30);
|
||||||
|
final raw = _baseBackoff * pow(2, exponent).toDouble();
|
||||||
|
final capped = raw > _maxBackoff ? _maxBackoff : raw;
|
||||||
|
final jitter = 0.8 + _random.nextDouble() * 0.4;
|
||||||
|
return Duration(
|
||||||
|
milliseconds: (capped.inMilliseconds * jitter).round(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshPending() async {
|
||||||
|
try {
|
||||||
|
// Read first, then emit. Written as `copyWith(pending: await …)` the
|
||||||
|
// receiver `_state` is evaluated before the await completes, so anything
|
||||||
|
// that changed during the wait — connectivity dropping, most of all —
|
||||||
|
// would be overwritten by the stale snapshot.
|
||||||
|
final count = await _repository.unsyncedCount();
|
||||||
|
_emit(_state.copyWith(pending: count));
|
||||||
|
} on Object {
|
||||||
|
// A count is decoration; failing to read it must not fail the drain.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _emit(SyncEngineState next) {
|
||||||
|
_state = next;
|
||||||
|
if (!_states.isClosed) _states.add(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose() async {
|
||||||
|
_stopped = true;
|
||||||
|
_backoffTimer?.cancel();
|
||||||
|
_pollTimer?.cancel();
|
||||||
|
await _connectivitySub?.cancel();
|
||||||
|
await _downlinkSub?.cancel();
|
||||||
|
await _states.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import '../../core/constants/app_constants.dart';
|
|||||||
import '../../core/utils/extensions.dart';
|
import '../../core/utils/extensions.dart';
|
||||||
import 'customer.dart';
|
import 'customer.dart';
|
||||||
import 'product.dart';
|
import 'product.dart';
|
||||||
|
import 'promo.dart';
|
||||||
|
|
||||||
/// How a discount value should be interpreted.
|
/// How a discount value should be interpreted.
|
||||||
enum DiscountType { none, percentage, flat }
|
enum DiscountType { none, percentage, flat }
|
||||||
@@ -100,6 +101,7 @@ class Cart extends Equatable {
|
|||||||
this.billDiscount = Discount.none,
|
this.billDiscount = Discount.none,
|
||||||
this.pointsRedeemed = 0,
|
this.pointsRedeemed = 0,
|
||||||
this.note,
|
this.note,
|
||||||
|
this.appliedPromos = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<CartLine> lines;
|
final List<CartLine> lines;
|
||||||
@@ -108,6 +110,14 @@ class Cart extends Equatable {
|
|||||||
final int pointsRedeemed;
|
final int pointsRedeemed;
|
||||||
final String? note;
|
final String? note;
|
||||||
|
|
||||||
|
/// Campaigns that fired on this bill.
|
||||||
|
///
|
||||||
|
/// Resolved by `PromoEngine` and handed in, rather than computed here: which
|
||||||
|
/// campaigns exist is policy that changes weekly, and Cart owns arithmetic
|
||||||
|
/// that must never be wrong. Stored as amounts so a bill read back years
|
||||||
|
/// later shows what was actually given, not what today's rules would give.
|
||||||
|
final List<AppliedPromo> appliedPromos;
|
||||||
|
|
||||||
static const Cart empty = Cart();
|
static const Cart empty = Cart();
|
||||||
|
|
||||||
bool get isEmpty => lines.isEmpty;
|
bool get isEmpty => lines.isEmpty;
|
||||||
@@ -143,9 +153,17 @@ class Cart extends Equatable {
|
|||||||
|
|
||||||
double get manualBillDiscountAmount => billDiscount.amountOn(subtotal);
|
double get manualBillDiscountAmount => billDiscount.amountOn(subtotal);
|
||||||
|
|
||||||
|
/// What the automatic campaigns took off.
|
||||||
|
double get promoDiscountAmount =>
|
||||||
|
appliedPromos.fold(0.0, (sum, p) => sum + p.amount).asMoney;
|
||||||
|
|
||||||
/// All bill-level reductions combined.
|
/// All bill-level reductions combined.
|
||||||
|
///
|
||||||
|
/// Clamped to the subtotal so no combination of tier, campaign and manual
|
||||||
|
/// discount can drive a bill below zero and turn a sale into a payout.
|
||||||
double get billDiscountTotal =>
|
double get billDiscountTotal =>
|
||||||
(membershipDiscountAmount + manualBillDiscountAmount)
|
(membershipDiscountAmount + manualBillDiscountAmount +
|
||||||
|
promoDiscountAmount)
|
||||||
.clamp(0, subtotal)
|
.clamp(0, subtotal)
|
||||||
.toDouble()
|
.toDouble()
|
||||||
.asMoney;
|
.asMoney;
|
||||||
@@ -159,13 +177,113 @@ class Cart extends Equatable {
|
|||||||
return v.clamp(0, double.infinity).toDouble().asMoney;
|
return v.clamp(0, double.infinity).toDouble().asMoney;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Proportion of the bill remaining after bill-level reductions. Used to
|
/// Bill-level reductions, allocated to the lines that earned them.
|
||||||
/// spread those reductions fairly across lines when apportioning GST.
|
///
|
||||||
double get _billFactor => subtotal <= 0 ? 1 : netAmount / subtotal;
|
/// 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.
|
/// GST payable across the bill, after apportioning bill-level discounts.
|
||||||
double get taxAmount =>
|
double get taxAmount {
|
||||||
lines.fold(0.0, (sum, l) => sum + l.taxAmount * _billFactor).asMoney;
|
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 cgst => (taxAmount / 2).asMoney;
|
||||||
double get sgst => (taxAmount / 2).asMoney;
|
double get sgst => (taxAmount / 2).asMoney;
|
||||||
@@ -180,10 +298,11 @@ class Cart extends Equatable {
|
|||||||
/// side of the total printed on the same bill, which a tax invoice cannot
|
/// side of the total printed on the same bill, which a tax invoice cannot
|
||||||
/// show; the residue is absorbed by the largest slab.
|
/// show; the residue is absorbed by the largest slab.
|
||||||
Map<double, double> get taxBreakdown {
|
Map<double, double> get taxBreakdown {
|
||||||
|
final nets = _lineNetAmounts;
|
||||||
final raw = <double, double>{};
|
final raw = <double, double>{};
|
||||||
for (final line in lines) {
|
for (var i = 0; i < lines.length; i++) {
|
||||||
final rate = line.product.gstRate;
|
final rate = lines[i].product.gstRate;
|
||||||
raw[rate] = (raw[rate] ?? 0) + line.taxAmount * _billFactor;
|
raw[rate] = (raw[rate] ?? 0) + (nets[i] - nets[i] / (1 + rate));
|
||||||
}
|
}
|
||||||
if (raw.isEmpty) return const {};
|
if (raw.isEmpty) return const {};
|
||||||
|
|
||||||
@@ -240,6 +359,7 @@ class Cart extends Equatable {
|
|||||||
Discount? billDiscount,
|
Discount? billDiscount,
|
||||||
int? pointsRedeemed,
|
int? pointsRedeemed,
|
||||||
String? note,
|
String? note,
|
||||||
|
List<AppliedPromo>? appliedPromos,
|
||||||
}) {
|
}) {
|
||||||
return Cart(
|
return Cart(
|
||||||
lines: lines ?? this.lines,
|
lines: lines ?? this.lines,
|
||||||
@@ -247,10 +367,11 @@ class Cart extends Equatable {
|
|||||||
billDiscount: billDiscount ?? this.billDiscount,
|
billDiscount: billDiscount ?? this.billDiscount,
|
||||||
pointsRedeemed: pointsRedeemed ?? this.pointsRedeemed,
|
pointsRedeemed: pointsRedeemed ?? this.pointsRedeemed,
|
||||||
note: note ?? this.note,
|
note: note ?? this.note,
|
||||||
|
appliedPromos: appliedPromos ?? this.appliedPromos,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props =>
|
List<Object?> get props =>
|
||||||
[lines, customer, billDiscount, pointsRedeemed, note];
|
[lines, customer, billDiscount, pointsRedeemed, note, appliedPromos];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
import '../../core/utils/extensions.dart';
|
import '../../core/utils/extensions.dart';
|
||||||
@@ -74,6 +75,51 @@ class Customer extends Equatable {
|
|||||||
final DateTime? createdAt;
|
final DateTime? createdAt;
|
||||||
final DateTime? lastVisitAt;
|
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);
|
MembershipTier get tier => MembershipTier.forSpend(lifetimeSpend);
|
||||||
|
|
||||||
/// Cash value of the points currently held.
|
/// 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);
|
||||||
|
}
|
||||||
225
lib/domain/entities/promo.dart
Normal file
225
lib/domain/entities/promo.dart
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../core/constants/app_constants.dart';
|
||||||
|
import 'product.dart';
|
||||||
|
|
||||||
|
/// What a promo does to a bill.
|
||||||
|
enum PromoType {
|
||||||
|
percentOffBill('% off the bill'),
|
||||||
|
flatOffBill('flat off the bill'),
|
||||||
|
percentOffCategory('% off a category'),
|
||||||
|
percentOffProduct('% off a product'),
|
||||||
|
|
||||||
|
/// Buy [Promo.buyQuantity], get [Promo.freeQuantity] of the same product
|
||||||
|
/// free. The cheapest way a shop clears stock, and the one customers ask for
|
||||||
|
/// by name.
|
||||||
|
buyXGetY('buy X get Y free');
|
||||||
|
|
||||||
|
const PromoType(this.label);
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
bool get needsTarget =>
|
||||||
|
this == percentOffCategory ||
|
||||||
|
this == percentOffProduct ||
|
||||||
|
this == buyXGetY;
|
||||||
|
|
||||||
|
bool get isPercentage =>
|
||||||
|
this == percentOffBill ||
|
||||||
|
this == percentOffCategory ||
|
||||||
|
this == percentOffProduct;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A campaign the till applies automatically.
|
||||||
|
class Promo extends Equatable {
|
||||||
|
const Promo({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.type,
|
||||||
|
this.value = 0,
|
||||||
|
this.targetId,
|
||||||
|
this.targetLabel,
|
||||||
|
this.buyQuantity = 0,
|
||||||
|
this.freeQuantity = 0,
|
||||||
|
this.minBillValue = 0,
|
||||||
|
this.maxDiscount,
|
||||||
|
this.validFrom,
|
||||||
|
this.validTo,
|
||||||
|
this.daysOfWeek = const {},
|
||||||
|
this.stackable = false,
|
||||||
|
this.priority = 100,
|
||||||
|
this.isActive = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final PromoType type;
|
||||||
|
|
||||||
|
/// Percent for a percentage promo, rupees for a flat one.
|
||||||
|
final double value;
|
||||||
|
|
||||||
|
/// Category name or product id, depending on [type].
|
||||||
|
final String? targetId;
|
||||||
|
|
||||||
|
/// Human-readable target, so the bill can say "20% off Beverages" without a
|
||||||
|
/// lookup.
|
||||||
|
final String? targetLabel;
|
||||||
|
|
||||||
|
final int buyQuantity;
|
||||||
|
final int freeQuantity;
|
||||||
|
|
||||||
|
/// Floor on the bill before this applies at all.
|
||||||
|
final double minBillValue;
|
||||||
|
|
||||||
|
/// Ceiling on what a percentage promo can take off.
|
||||||
|
///
|
||||||
|
/// Without one, "20% off" on an unusually large trolley gives away more than
|
||||||
|
/// the campaign was ever costed for.
|
||||||
|
final double? maxDiscount;
|
||||||
|
|
||||||
|
final DateTime? validFrom;
|
||||||
|
final DateTime? validTo;
|
||||||
|
|
||||||
|
/// 1 = Monday … 7 = Sunday, matching [DateTime.weekday]. Empty means every
|
||||||
|
/// day.
|
||||||
|
final Set<int> daysOfWeek;
|
||||||
|
|
||||||
|
/// Whether this can combine with other promos.
|
||||||
|
///
|
||||||
|
/// Most campaigns should not. Two stacking percentages compound into a
|
||||||
|
/// discount nobody signed off, and the shop finds out at the end of the
|
||||||
|
/// month.
|
||||||
|
final bool stackable;
|
||||||
|
|
||||||
|
/// Lower runs first. Only matters for ordering on the bill and for breaking
|
||||||
|
/// ties between equal-value exclusive promos.
|
||||||
|
final int priority;
|
||||||
|
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
|
/// Whether the promo is live at [at], ignoring the contents of the bill.
|
||||||
|
bool isLiveAt(DateTime at) {
|
||||||
|
if (!isActive) return false;
|
||||||
|
|
||||||
|
final from = validFrom;
|
||||||
|
if (from != null && at.isBefore(from)) return false;
|
||||||
|
|
||||||
|
final to = validTo;
|
||||||
|
// Inclusive of the closing day: a campaign "to the 31st" runs all of it.
|
||||||
|
if (to != null && at.isAfter(_endOfDay(to))) return false;
|
||||||
|
|
||||||
|
if (daysOfWeek.isNotEmpty && !daysOfWeek.contains(at.weekday)) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime _endOfDay(DateTime day) =>
|
||||||
|
DateTime(day.year, day.month, day.day, 23, 59, 59, 999);
|
||||||
|
|
||||||
|
/// Whether this campaign is aimed at [product] in particular.
|
||||||
|
///
|
||||||
|
/// A bill-wide promo targets everything; a category or product one targets
|
||||||
|
/// only what it names. Two things read this and they must never disagree:
|
||||||
|
/// `PromoEngine` uses it to price the discount, and [Cart] uses it to decide
|
||||||
|
/// which lines carry the GST reduction. A campaign priced against one set of
|
||||||
|
/// lines and taxed against another puts the wrong figure in a slab on a
|
||||||
|
/// filed return, so the rule lives here once rather than in both callers.
|
||||||
|
bool targets(Product product) => switch (type) {
|
||||||
|
PromoType.percentOffBill || PromoType.flatOffBill => true,
|
||||||
|
|
||||||
|
// Matched on the enum name, which is stable across a label change —
|
||||||
|
// renaming "Personal Care" must not silently switch off a campaign.
|
||||||
|
PromoType.percentOffCategory => product.category.name == targetId,
|
||||||
|
|
||||||
|
PromoType.percentOffProduct || PromoType.buyXGetY =>
|
||||||
|
product.id == targetId,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Whether the discount lands on named lines rather than the whole bill.
|
||||||
|
bool get isTargeted => type.needsTarget;
|
||||||
|
|
||||||
|
/// One-line description for the campaign list.
|
||||||
|
String get summary => switch (type) {
|
||||||
|
PromoType.percentOffBill => '${_trim(value)}% off the whole bill',
|
||||||
|
PromoType.flatOffBill =>
|
||||||
|
'${AppConstants.currencySymbol}${_trim(value)} off the bill',
|
||||||
|
PromoType.percentOffCategory =>
|
||||||
|
'${_trim(value)}% off ${targetLabel ?? targetId}',
|
||||||
|
PromoType.percentOffProduct =>
|
||||||
|
'${_trim(value)}% off ${targetLabel ?? targetId}',
|
||||||
|
PromoType.buyXGetY =>
|
||||||
|
'Buy $buyQuantity get $freeQuantity free on ${targetLabel ?? targetId}',
|
||||||
|
};
|
||||||
|
|
||||||
|
static String _trim(double v) =>
|
||||||
|
v == v.roundToDouble() ? v.toStringAsFixed(0) : v.toStringAsFixed(2);
|
||||||
|
|
||||||
|
Promo copyWith({
|
||||||
|
String? name,
|
||||||
|
PromoType? type,
|
||||||
|
double? value,
|
||||||
|
String? targetId,
|
||||||
|
String? targetLabel,
|
||||||
|
int? buyQuantity,
|
||||||
|
int? freeQuantity,
|
||||||
|
double? minBillValue,
|
||||||
|
double? maxDiscount,
|
||||||
|
bool clearMaxDiscount = false,
|
||||||
|
DateTime? validFrom,
|
||||||
|
DateTime? validTo,
|
||||||
|
bool clearDates = false,
|
||||||
|
Set<int>? daysOfWeek,
|
||||||
|
bool? stackable,
|
||||||
|
int? priority,
|
||||||
|
bool? isActive,
|
||||||
|
}) =>
|
||||||
|
Promo(
|
||||||
|
id: id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
type: type ?? this.type,
|
||||||
|
value: value ?? this.value,
|
||||||
|
targetId: targetId ?? this.targetId,
|
||||||
|
targetLabel: targetLabel ?? this.targetLabel,
|
||||||
|
buyQuantity: buyQuantity ?? this.buyQuantity,
|
||||||
|
freeQuantity: freeQuantity ?? this.freeQuantity,
|
||||||
|
minBillValue: minBillValue ?? this.minBillValue,
|
||||||
|
maxDiscount:
|
||||||
|
clearMaxDiscount ? null : (maxDiscount ?? this.maxDiscount),
|
||||||
|
validFrom: clearDates ? null : (validFrom ?? this.validFrom),
|
||||||
|
validTo: clearDates ? null : (validTo ?? this.validTo),
|
||||||
|
daysOfWeek: daysOfWeek ?? this.daysOfWeek,
|
||||||
|
stackable: stackable ?? this.stackable,
|
||||||
|
priority: priority ?? this.priority,
|
||||||
|
isActive: isActive ?? this.isActive,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
targetId,
|
||||||
|
buyQuantity,
|
||||||
|
freeQuantity,
|
||||||
|
minBillValue,
|
||||||
|
maxDiscount,
|
||||||
|
validFrom,
|
||||||
|
validTo,
|
||||||
|
daysOfWeek,
|
||||||
|
stackable,
|
||||||
|
priority,
|
||||||
|
isActive,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A promo that fired on a particular bill, and what it took off.
|
||||||
|
class AppliedPromo extends Equatable {
|
||||||
|
const AppliedPromo({required this.promo, required this.amount});
|
||||||
|
|
||||||
|
final Promo promo;
|
||||||
|
final double amount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [promo.id, amount];
|
||||||
|
}
|
||||||
@@ -17,23 +17,48 @@ enum StaffRole {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A person who signs in at the terminal.
|
/// A person who signs in at the terminal.
|
||||||
|
///
|
||||||
|
/// Deliberately carries no PIN. It used to, which meant the credential was in
|
||||||
|
/// memory, in every widget that held a user, and — because the accounts were
|
||||||
|
/// declared as constants — inside the shipped binary. Verification now happens
|
||||||
|
/// in `StaffDao` against a stored hash, and nothing above the data layer ever
|
||||||
|
/// sees the secret.
|
||||||
class StaffUser extends Equatable {
|
class StaffUser extends Equatable {
|
||||||
const StaffUser({
|
const StaffUser({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.role,
|
required this.role,
|
||||||
required this.pin,
|
this.mustChangePin = false,
|
||||||
|
this.isActive = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
final StaffRole role;
|
final StaffRole role;
|
||||||
|
|
||||||
/// Four-digit quick-unlock code. Never rendered.
|
/// Set on a seeded or admin-reset account until the person picks their own.
|
||||||
final String pin;
|
final bool mustChangePin;
|
||||||
|
|
||||||
|
/// Deactivated rather than deleted, so bills already rung keep pointing at a
|
||||||
|
/// real person.
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
|
StaffUser copyWith({
|
||||||
|
String? name,
|
||||||
|
StaffRole? role,
|
||||||
|
bool? mustChangePin,
|
||||||
|
bool? isActive,
|
||||||
|
}) =>
|
||||||
|
StaffUser(
|
||||||
|
id: id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
role: role ?? this.role,
|
||||||
|
mustChangePin: mustChangePin ?? this.mustChangePin,
|
||||||
|
isActive: isActive ?? this.isActive,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [id, name, role];
|
List<Object?> get props => [id, name, role, mustChangePin, isActive];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The registered outlet this terminal belongs to.
|
/// The registered outlet this terminal belongs to.
|
||||||
@@ -58,6 +83,26 @@ class StoreAccount extends Equatable {
|
|||||||
final List<StaffUser> staff;
|
final List<StaffUser> staff;
|
||||||
final String plan;
|
final String plan;
|
||||||
|
|
||||||
|
StoreAccount copyWith({
|
||||||
|
String? name,
|
||||||
|
String? email,
|
||||||
|
String? address,
|
||||||
|
String? gstin,
|
||||||
|
String? phone,
|
||||||
|
List<StaffUser>? staff,
|
||||||
|
String? plan,
|
||||||
|
}) =>
|
||||||
|
StoreAccount(
|
||||||
|
id: id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
email: email ?? this.email,
|
||||||
|
address: address ?? this.address,
|
||||||
|
gstin: gstin ?? this.gstin,
|
||||||
|
phone: phone ?? this.phone,
|
||||||
|
staff: staff ?? this.staff,
|
||||||
|
plan: plan ?? this.plan,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [id, email];
|
List<Object?> get props => [id, name, email, address, gstin, phone, plan];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,30 @@ import '../entities/shift_report.dart';
|
|||||||
import '../entities/sync_event.dart';
|
import '../entities/sync_event.dart';
|
||||||
import '../entities/transaction.dart';
|
import '../entities/transaction.dart';
|
||||||
|
|
||||||
/// Result of one end-of-day upload.
|
/// Result of one upload pass.
|
||||||
class SyncOutcome {
|
class SyncOutcome {
|
||||||
const SyncOutcome({
|
const SyncOutcome({
|
||||||
required this.attempted,
|
required this.attempted,
|
||||||
required this.uploaded,
|
required this.uploaded,
|
||||||
|
this.rejected = 0,
|
||||||
this.error,
|
this.error,
|
||||||
|
this.isRetryable = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int attempted;
|
final int attempted;
|
||||||
final int uploaded;
|
final int uploaded;
|
||||||
|
|
||||||
|
/// Bills the back office looked at and refused. These stay on the terminal
|
||||||
|
/// but sending them again unchanged will fail again, so they need a person.
|
||||||
|
final int rejected;
|
||||||
|
|
||||||
final String? error;
|
final String? error;
|
||||||
|
|
||||||
|
/// Whether trying again could plausibly work. False for a bad credential or
|
||||||
|
/// an unconfigured endpoint — the drain engine halts rather than retrying
|
||||||
|
/// something that cannot succeed.
|
||||||
|
final bool isRetryable;
|
||||||
|
|
||||||
bool get isSuccess => error == null;
|
bool get isSuccess => error == null;
|
||||||
bool get hadNothingToDo => attempted == 0;
|
bool get hadNothingToDo => attempted == 0;
|
||||||
int get remaining => attempted - uploaded;
|
int get remaining => attempted - uploaded;
|
||||||
@@ -27,6 +39,7 @@ class OrderSyncRow {
|
|||||||
required this.total,
|
required this.total,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.isSynced,
|
required this.isSynced,
|
||||||
|
this.itemCount = 0,
|
||||||
this.syncedAt,
|
this.syncedAt,
|
||||||
this.attempts = 0,
|
this.attempts = 0,
|
||||||
this.error,
|
this.error,
|
||||||
@@ -37,15 +50,19 @@ class OrderSyncRow {
|
|||||||
final double total;
|
final double total;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final bool isSynced;
|
final bool isSynced;
|
||||||
|
|
||||||
|
/// Units on the bill. Fractional because loose goods are sold by weight.
|
||||||
|
final double itemCount;
|
||||||
final DateTime? syncedAt;
|
final DateTime? syncedAt;
|
||||||
final int attempts;
|
final int attempts;
|
||||||
final String? error;
|
final String? error;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The terminal's two network touchpoints.
|
/// The terminal's network touchpoints.
|
||||||
///
|
///
|
||||||
/// Morning: pull the catalogue. End of day: upload every order still at
|
/// Pull the catalogue; upload every order still at `sync_status = 0`. Nothing
|
||||||
/// `sync_status = 0`. Nothing else leaves the device.
|
/// else leaves the device. *When* the upload runs is not decided here — see
|
||||||
|
/// `SyncEngine`, which owns triggers and retry policy.
|
||||||
abstract class SyncRepository {
|
abstract class SyncRepository {
|
||||||
bool get hasCatalogue;
|
bool get hasCatalogue;
|
||||||
DateTime? get lastImportAt;
|
DateTime? get lastImportAt;
|
||||||
@@ -71,12 +88,27 @@ abstract class SyncRepository {
|
|||||||
bool scopeToCashier = false,
|
bool scopeToCashier = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// End-of-day step — uploads pending orders and flips the accepted ones to
|
/// One upload pass — sends pending orders and flips the ones the back office
|
||||||
/// `sync_status = 1`. Failures leave every row untouched at 0.
|
/// confirmed to `sync_status = 1`. Failures leave every row untouched at 0.
|
||||||
Future<SyncOutcome> syncOrders({
|
Future<SyncOutcome> syncOrders({
|
||||||
void Function(double progress, String stage)? onProgress,
|
void Function(double progress, String stage)? onProgress,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// How many shoppers registered at this till are still waiting to go up.
|
||||||
|
Future<int> unsyncedCustomerCount();
|
||||||
|
|
||||||
|
/// Uploads shoppers registered at this till.
|
||||||
|
///
|
||||||
|
/// Deliberately separate from [syncOrders]. A registration is not a financial
|
||||||
|
/// record: it can be replayed safely, and it must not be stuck behind a bill
|
||||||
|
/// the back office has refused. Run it first so a bill referring to a new
|
||||||
|
/// shopper arrives after the shopper does.
|
||||||
|
Future<SyncOutcome> syncCustomers();
|
||||||
|
|
||||||
|
/// Retires confirmed bills past their retention window. Archived totals are
|
||||||
|
/// untouched.
|
||||||
|
Future<int> purgeExpired();
|
||||||
|
|
||||||
Future<List<OrderSyncRow>> orderSyncRows({int limit = 200});
|
Future<List<OrderSyncRow>> orderSyncRows({int limit = 200});
|
||||||
|
|
||||||
List<SyncEvent> get events;
|
List<SyncEvent> get events;
|
||||||
|
|||||||
@@ -13,6 +13,18 @@ abstract class TransactionRepository {
|
|||||||
Customer? updatedCustomer,
|
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<List<SaleTransaction>> history({int limit = 50});
|
||||||
|
|
||||||
Future<SaleTransaction?> findByInvoice(String invoiceNumber);
|
Future<SaleTransaction?> findByInvoice(String invoiceNumber);
|
||||||
|
|||||||
143
lib/domain/services/promo_engine.dart
Normal file
143
lib/domain/services/promo_engine.dart
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import '../../core/utils/extensions.dart';
|
||||||
|
import '../entities/cart.dart';
|
||||||
|
import '../entities/promo.dart';
|
||||||
|
|
||||||
|
/// Decides which campaigns fire on a bill, and for how much.
|
||||||
|
///
|
||||||
|
/// Kept out of [Cart] on purpose. Cart owns arithmetic that must never be
|
||||||
|
/// wrong; this owns policy that a shop changes weekly. Mixing them would put a
|
||||||
|
/// marketing decision in the same class as the GST calculation.
|
||||||
|
class PromoEngine {
|
||||||
|
const PromoEngine._();
|
||||||
|
|
||||||
|
/// Evaluates [promos] against [cart] and returns what actually fires.
|
||||||
|
///
|
||||||
|
/// ### Stacking
|
||||||
|
///
|
||||||
|
/// All eligible **stackable** promos apply together. Of the **exclusive**
|
||||||
|
/// ones, only the single best applies — the one worth most to the shopper,
|
||||||
|
/// with [Promo.priority] breaking ties.
|
||||||
|
///
|
||||||
|
/// This is the conservative reading, and deliberately so. Letting two
|
||||||
|
/// percentages compound produces a discount nobody costed, and a shop finds
|
||||||
|
/// out at the end of the month rather than at the till.
|
||||||
|
///
|
||||||
|
/// The total is capped at the cart subtotal: no combination of campaigns can
|
||||||
|
/// make a bill negative, or turn a sale into a payout.
|
||||||
|
static List<AppliedPromo> evaluate({
|
||||||
|
required Cart cart,
|
||||||
|
required List<Promo> promos,
|
||||||
|
required DateTime at,
|
||||||
|
}) {
|
||||||
|
if (cart.isEmpty || promos.isEmpty) return const [];
|
||||||
|
|
||||||
|
final subtotal = cart.subtotal;
|
||||||
|
|
||||||
|
final eligible = <AppliedPromo>[];
|
||||||
|
for (final promo in promos) {
|
||||||
|
if (!promo.isLiveAt(at)) continue;
|
||||||
|
if (subtotal < promo.minBillValue) continue;
|
||||||
|
|
||||||
|
final amount = amountFor(promo: promo, cart: cart);
|
||||||
|
if (amount <= 0) continue;
|
||||||
|
|
||||||
|
eligible.add(AppliedPromo(promo: promo, amount: amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eligible.isEmpty) return const [];
|
||||||
|
|
||||||
|
final stackable = eligible.where((a) => a.promo.stackable).toList()
|
||||||
|
..sort((a, b) => a.promo.priority.compareTo(b.promo.priority));
|
||||||
|
|
||||||
|
final exclusive = eligible.where((a) => !a.promo.stackable).toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
// Best for the shopper first; priority only breaks a genuine tie.
|
||||||
|
final byAmount = b.amount.compareTo(a.amount);
|
||||||
|
if (byAmount != 0) return byAmount;
|
||||||
|
return a.promo.priority.compareTo(b.promo.priority);
|
||||||
|
});
|
||||||
|
|
||||||
|
final chosen = <AppliedPromo>[
|
||||||
|
...stackable,
|
||||||
|
if (exclusive.isNotEmpty) exclusive.first,
|
||||||
|
]..sort((a, b) => a.promo.priority.compareTo(b.promo.priority));
|
||||||
|
|
||||||
|
return _capped(chosen, subtotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trims the applied set so it can never exceed the bill.
|
||||||
|
///
|
||||||
|
/// Trimming the last one rather than scaling all of them keeps every other
|
||||||
|
/// figure on the receipt exactly what the campaign promised.
|
||||||
|
static List<AppliedPromo> _capped(List<AppliedPromo> applied, double ceiling) {
|
||||||
|
final result = <AppliedPromo>[];
|
||||||
|
var running = 0.0;
|
||||||
|
|
||||||
|
for (final entry in applied) {
|
||||||
|
final headroom = (ceiling - running).asMoney;
|
||||||
|
if (headroom <= 0) break;
|
||||||
|
|
||||||
|
final amount = entry.amount <= headroom ? entry.amount : headroom;
|
||||||
|
result.add(AppliedPromo(promo: entry.promo, amount: amount));
|
||||||
|
running = (running + amount).asMoney;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one promo is worth on this cart, ignoring stacking rules.
|
||||||
|
static double amountFor({required Promo promo, required Cart cart}) {
|
||||||
|
final raw = switch (promo.type) {
|
||||||
|
PromoType.percentOffBill => cart.subtotal * (promo.value / 100),
|
||||||
|
PromoType.flatOffBill => promo.value,
|
||||||
|
// Both delegate the "does this line count?" question to the promo
|
||||||
|
// itself, because [Cart] asks the same question when it decides which
|
||||||
|
// lines carry the GST reduction. Answering it twice invites the two to
|
||||||
|
// drift apart.
|
||||||
|
PromoType.percentOffCategory =>
|
||||||
|
_percentOfMatching(cart, promo.value, promo),
|
||||||
|
PromoType.percentOffProduct =>
|
||||||
|
_percentOfMatching(cart, promo.value, promo),
|
||||||
|
PromoType.buyXGetY => _buyXGetY(cart, promo),
|
||||||
|
};
|
||||||
|
|
||||||
|
final capped = promo.maxDiscount == null
|
||||||
|
? raw
|
||||||
|
: (raw < promo.maxDiscount! ? raw : promo.maxDiscount!);
|
||||||
|
|
||||||
|
return capped.clamp(0, cart.subtotal).toDouble().asMoney;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _percentOfMatching(Cart cart, double percent, Promo promo) {
|
||||||
|
final base = cart.lines
|
||||||
|
.where((line) => promo.targets(line.product))
|
||||||
|
.fold(0.0, (sum, line) => sum + line.payable);
|
||||||
|
return base * (percent / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Free units are the cheapest way to price this: for every group of
|
||||||
|
/// (buy + free), the shopper pays for `buy` of them.
|
||||||
|
///
|
||||||
|
/// Deliberately counts whole groups only. A "buy 2 get 1" on three items
|
||||||
|
/// gives one free; on five it still gives one, because the fifth has not
|
||||||
|
/// earned the second group.
|
||||||
|
static double _buyXGetY(Cart cart, Promo promo) {
|
||||||
|
if (promo.buyQuantity <= 0 || promo.freeQuantity <= 0) return 0;
|
||||||
|
|
||||||
|
final line = cart.lines.firstWhereOrNull(
|
||||||
|
(l) => l.product.id == promo.targetId,
|
||||||
|
);
|
||||||
|
if (line == null) return 0;
|
||||||
|
|
||||||
|
final groupSize = promo.buyQuantity + promo.freeQuantity;
|
||||||
|
final groups = line.quantity ~/ groupSize;
|
||||||
|
if (groups <= 0) return 0;
|
||||||
|
|
||||||
|
// Priced at the unit rate actually being charged, so a line that already
|
||||||
|
// carries a manual discount does not refund more than it took.
|
||||||
|
final unitPrice =
|
||||||
|
line.quantity <= 0 ? 0.0 : line.payable / line.quantity;
|
||||||
|
|
||||||
|
return groups * promo.freeQuantity * unitPrice;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,8 @@ class CheckoutSale {
|
|||||||
required Cart cart,
|
required Cart cart,
|
||||||
required List<PaymentSplit> payments,
|
required List<PaymentSplit> payments,
|
||||||
required String cashierName,
|
required String cashierName,
|
||||||
String terminalId = 'TERM-01',
|
required String terminalId,
|
||||||
|
String? terminalCode,
|
||||||
}) async {
|
}) async {
|
||||||
_validate(cart, payments);
|
_validate(cart, payments);
|
||||||
await _assertStockAvailable(cart);
|
await _assertStockAvailable(cart);
|
||||||
@@ -82,7 +83,11 @@ class CheckoutSale {
|
|||||||
|
|
||||||
final transaction = SaleTransaction(
|
final transaction = SaleTransaction(
|
||||||
id: _uuid.v4(),
|
id: _uuid.v4(),
|
||||||
invoiceNumber: Formatters.invoiceNumber(sequence, now),
|
invoiceNumber: Formatters.invoiceNumber(
|
||||||
|
sequence,
|
||||||
|
now,
|
||||||
|
terminalCode: terminalCode ?? terminalId,
|
||||||
|
),
|
||||||
cart: cart,
|
cart: cart,
|
||||||
payments: payments,
|
payments: payments,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../core/constants/app_constants.dart';
|
import '../../../app/providers.dart';
|
||||||
|
import '../../../data/local/app_database.dart';
|
||||||
|
import '../../../data/remote/pos_auth_api.dart';
|
||||||
|
import '../../../domain/entities/pos_session.dart';
|
||||||
import '../../../domain/entities/store_account.dart';
|
import '../../../domain/entities/store_account.dart';
|
||||||
|
|
||||||
/// Sign-in state for the terminal.
|
/// Sign-in state for the terminal.
|
||||||
@@ -19,10 +23,28 @@ class Authenticating extends AuthState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Authenticated 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 StoreAccount store;
|
||||||
final StaffUser user;
|
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 {
|
class AuthFailure extends AuthState {
|
||||||
@@ -31,76 +53,284 @@ class AuthFailure extends AuthState {
|
|||||||
final String message;
|
final String message;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Credentials that ship with the demo build.
|
/// The two shapes this terminal can take.
|
||||||
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
|
||||||
static const String email = 'admin@nearle.in';
|
/// (see [PosSession.isCashier]).
|
||||||
static const String password = 'nearle123';
|
///
|
||||||
}
|
/// The split is what the two are *for*, not decoration:
|
||||||
|
///
|
||||||
const _demoStore = StoreAccount(
|
/// * [admin] runs the whole shell and is the only mode that can pull the
|
||||||
id: 'store-001',
|
/// catalogue. Signing out leaves the products on the terminal.
|
||||||
name: AppConstants.storeName,
|
/// * [cashier] gets the billing screen and nothing else, and signing out
|
||||||
email: DemoCredentials.email,
|
/// takes the catalogue with it.
|
||||||
address: AppConstants.storeAddress,
|
enum TerminalLogin {
|
||||||
gstin: AppConstants.storeGstin,
|
admin(
|
||||||
phone: AppConstants.storePhone,
|
label: 'Admin',
|
||||||
staff: [
|
role: StaffRole.admin,
|
||||||
StaffUser(id: 'u1', name: 'Suriya', role: StaffRole.admin, pin: '1234'),
|
blurb: 'Full shell — import products, promos, settings.',
|
||||||
StaffUser(id: 'u2', name: 'Divya', role: StaffRole.manager, pin: '2345'),
|
),
|
||||||
StaffUser(id: 'u3', name: 'Rahul', role: StaffRole.cashier, pin: '3456'),
|
cashier(
|
||||||
],
|
label: 'Cashier',
|
||||||
|
role: StaffRole.cashier,
|
||||||
|
blurb: 'Billing only, on the products the admin imported.',
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Validates store credentials and holds the signed-in session.
|
const TerminalLogin({
|
||||||
///
|
required this.label,
|
||||||
/// Backed by a hardcoded account for now; swapping in a real identity provider
|
required this.role,
|
||||||
/// means changing only [signIn].
|
required this.blurb,
|
||||||
class AuthController extends StateNotifier<AuthState> {
|
});
|
||||||
AuthController() : super(const Unauthenticated());
|
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final StaffRole role;
|
||||||
|
final String blurb;
|
||||||
|
|
||||||
|
/// The catalogue is pulled once by an admin and billed against by whoever is
|
||||||
|
/// on the counter, so only the cashier's sign-out drops it. An admin closing
|
||||||
|
/// the shell is a handover, not the end of the day.
|
||||||
|
bool get clearsCatalogueOnSignOut => this == TerminalLogin.cashier;
|
||||||
|
|
||||||
|
/// Which shell the back office's role name lands in.
|
||||||
|
static TerminalLogin forSession(PosSession session) =>
|
||||||
|
session.isCashier ? TerminalLogin.cashier : TerminalLogin.admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates store credentials against the back office and holds the session.
|
||||||
|
class AuthController extends StateNotifier<AuthState> {
|
||||||
|
AuthController(this._ref) : super(const Unauthenticated());
|
||||||
|
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
/// Whether signing out right now would wipe the products off this terminal.
|
||||||
|
///
|
||||||
|
/// Read *before* [signOut] by anything that needs to warn the operator, since
|
||||||
|
/// the session is gone by the time it returns.
|
||||||
|
bool get clearsCatalogueOnSignOut {
|
||||||
|
final current = state;
|
||||||
|
return current is Authenticated && current.login.clearsCatalogueOnSignOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live bearer token, or null when nobody is signed in.
|
||||||
|
String? get token {
|
||||||
|
final current = state;
|
||||||
|
return current is Authenticated ? current.session.token : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signs in against `POST /pos/login`.
|
||||||
|
///
|
||||||
|
/// [authname] is the account the back office issued for this till, e.g.
|
||||||
|
/// `supervisor.1135@pos.nearle.in`. The role that comes back — not anything
|
||||||
|
/// chosen on this screen — decides whether the terminal opens the admin
|
||||||
|
/// shell or the cashier till.
|
||||||
Future<bool> signIn({
|
Future<bool> signIn({
|
||||||
required String email,
|
required String authname,
|
||||||
required String password,
|
required String password,
|
||||||
}) async {
|
}) async {
|
||||||
state = const Authenticating();
|
state = const Authenticating();
|
||||||
|
|
||||||
// Stand-in for the network round trip.
|
try {
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 600));
|
final session = await _ref.read(posAuthApiProvider).login(
|
||||||
|
authname: authname,
|
||||||
|
password: password,
|
||||||
|
deviceId: _ref.read(terminalIdentityProvider).deviceId,
|
||||||
|
);
|
||||||
|
|
||||||
final normalised = email.trim().toLowerCase();
|
await _open(session, persist: true);
|
||||||
|
return true;
|
||||||
if (normalised != DemoCredentials.email) {
|
} on AuthApiException catch (e) {
|
||||||
state = const AuthFailure('No store is registered against that email.');
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password != DemoCredentials.password) {
|
|
||||||
state = const AuthFailure('Incorrect password. Please try again.');
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
state = Authenticated(store: _demoStore, user: _demoStore.staff.first);
|
/// Re-opens the session stored on this terminal, if there is a live one.
|
||||||
|
///
|
||||||
|
/// Called once at startup, before the first frame, so a till that was signed
|
||||||
|
/// in when it lost power comes back up on the same shell rather than at a
|
||||||
|
/// login screen someone has to find the credentials for.
|
||||||
|
///
|
||||||
|
/// Returns false — and leaves the terminal signed out — when there is no
|
||||||
|
/// session, or the token has expired.
|
||||||
|
Future<bool> restore() async {
|
||||||
|
final session = await _ref.read(sessionStoreProvider).read();
|
||||||
|
if (session == null) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Already on disk, so nothing to persist. Details are re-applied because
|
||||||
|
// a shop that changed its GSTIN in the back office should not print the
|
||||||
|
// old one just because this terminal never signed out.
|
||||||
|
await _open(session, persist: false);
|
||||||
|
return true;
|
||||||
|
} on Object catch (e, stack) {
|
||||||
|
debugPrint('Session restore failed: $e\n$stack');
|
||||||
|
await _ref.read(sessionStoreProvider).clear();
|
||||||
|
state = const Unauthenticated();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns a session into a live shell.
|
||||||
|
Future<void> _open(PosSession session, {required bool persist}) async {
|
||||||
|
if (persist) await _ref.read(sessionStoreProvider).save(session);
|
||||||
|
|
||||||
|
await _applyStoreDetails(session);
|
||||||
|
|
||||||
|
// Read directly rather than through `storeAccountProvider`: that provider
|
||||||
|
// watches this controller, so going through it here would rebuild it in
|
||||||
|
// the middle of the sign-in that is about to populate it. Setting the
|
||||||
|
// state below is what refreshes it, once.
|
||||||
|
final store = await _ref.read(storeRepositoryProvider).load(
|
||||||
|
email: session.authname,
|
||||||
|
);
|
||||||
|
|
||||||
|
state = Authenticated(
|
||||||
|
store: store,
|
||||||
|
// The operator is whoever the back office says signed in, not a local
|
||||||
|
// seeded account that happens to share a role.
|
||||||
|
user: session.user,
|
||||||
|
login: TerminalLogin.forSession(session),
|
||||||
|
session: session,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies the outlet's details out of the login response into this
|
||||||
|
/// terminal's own record.
|
||||||
|
///
|
||||||
|
/// The name, address, GSTIN and phone are printed on every invoice, where
|
||||||
|
/// they are a legal requirement rather than decoration — so the back office
|
||||||
|
/// is the source of truth for them, and a correction made there reaches the
|
||||||
|
/// till on the next sign-in. Blank fields are skipped, so a partial response
|
||||||
|
/// never erases details that are already right.
|
||||||
|
///
|
||||||
|
/// Delete this method if the terminal should keep whatever was typed into
|
||||||
|
/// Settings instead; nothing else depends on it.
|
||||||
|
Future<void> _applyStoreDetails(PosSession session) async {
|
||||||
|
final catalogue = _ref.read(localStoreProvider).catalogue;
|
||||||
|
|
||||||
|
Future<void> put(String key, String value) async {
|
||||||
|
if (value.trim().isEmpty) return;
|
||||||
|
await catalogue.setMeta(key, value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
await put(MetaKeys.storeName, session.locationName);
|
||||||
|
await put(MetaKeys.storeAddress, session.address);
|
||||||
|
await put(MetaKeys.storeGstin, session.gstin.toUpperCase());
|
||||||
|
await put(MetaKeys.storePhone, session.phone);
|
||||||
|
|
||||||
|
await _followOutlet(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-points the terminal at the outlet the session belongs to.
|
||||||
|
///
|
||||||
|
/// The outlet id namespaces every sync topic, so a till moved between shops
|
||||||
|
/// would otherwise keep publishing its bills into the previous shop's books.
|
||||||
|
///
|
||||||
|
/// Goes through the identity store rather than writing the meta row alone:
|
||||||
|
/// the in-memory [TerminalIdentity] is what `syncConfigProvider` reads, and a
|
||||||
|
/// row on disk that nothing has re-read is a change that appears to have
|
||||||
|
/// worked and has not.
|
||||||
|
Future<void> _followOutlet(PosSession session) async {
|
||||||
|
if (session.storeId.trim().isEmpty) return;
|
||||||
|
|
||||||
|
final local = _ref.read(localStoreProvider);
|
||||||
|
if (local.terminal.storeId == session.storeId) return;
|
||||||
|
|
||||||
|
await local.identityStore.rename(storeId: session.storeId);
|
||||||
|
local.terminal = await local.identityStore.load();
|
||||||
|
|
||||||
|
// Rebuilds the sync configuration, and with it the catalogue source and
|
||||||
|
// the order transport, onto the new outlet's topics.
|
||||||
|
_ref.invalidate(terminalIdentityProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switches the active operator, checking their PIN.
|
||||||
|
///
|
||||||
|
/// Every bill is stamped with whoever is active, so this is the boundary that
|
||||||
|
/// decides who a sale is attributed to — it cannot be a bare selection from a
|
||||||
|
/// list. It changes who the bill names, never what the session may open:
|
||||||
|
/// [Authenticated.login] is untouched, so a cashier terminal stays a cashier
|
||||||
|
/// terminal.
|
||||||
|
Future<bool> switchUser(String pin) async {
|
||||||
|
final current = state;
|
||||||
|
if (current is! Authenticated) return false;
|
||||||
|
|
||||||
|
final store = _ref.read(localStoreProvider);
|
||||||
|
final user = await store.staff.authenticate(pin);
|
||||||
|
if (user == null) return false;
|
||||||
|
|
||||||
|
state = Authenticated(
|
||||||
|
store: current.store,
|
||||||
|
user: user,
|
||||||
|
login: current.login,
|
||||||
|
session: current.session,
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switches the active operator without signing the store out.
|
/// Re-reads the store after staff or details change, keeping the session.
|
||||||
void switchUser(StaffUser user) {
|
Future<void> refreshStore() async {
|
||||||
final current = state;
|
final current = state;
|
||||||
if (current is! Authenticated) return;
|
if (current is! Authenticated) return;
|
||||||
state = Authenticated(store: current.store, user: user);
|
|
||||||
|
final store = await _ref.read(storeRepositoryProvider).load(
|
||||||
|
email: current.session.authname,
|
||||||
|
);
|
||||||
|
_ref.invalidate(storeAccountProvider);
|
||||||
|
|
||||||
|
// The signed-in account comes from the back office and is not in this
|
||||||
|
// terminal's staff table, so a miss here means "not a local operator",
|
||||||
|
// not "deactivated". Only a local operator who has actually disappeared
|
||||||
|
// hands the session back to the account that opened it.
|
||||||
|
final me = store.staff.where((s) => s.id == current.user.id);
|
||||||
|
|
||||||
|
state = Authenticated(
|
||||||
|
store: store,
|
||||||
|
user: me.isNotEmpty ? me.first : current.session.user,
|
||||||
|
login: current.login,
|
||||||
|
session: current.session,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void signOut() => state = const Unauthenticated();
|
/// Ends the session, drops the stored copy of it, and — for a cashier only —
|
||||||
|
/// takes the catalogue with it.
|
||||||
|
///
|
||||||
|
/// Every cashier sign-out drops the products, whatever the reason for it.
|
||||||
|
/// The next shift should bill against what the back office answers with,
|
||||||
|
/// never a catalogue carried over, and a terminal left at a login screen
|
||||||
|
/// must not be sitting on a shop's prices and stock.
|
||||||
|
///
|
||||||
|
/// An admin signing out is the opposite case. They have just pulled the
|
||||||
|
/// products *so that* a cashier can pick the terminal up, so dropping the
|
||||||
|
/// table here would make the import pointless.
|
||||||
|
Future<void> signOut() async {
|
||||||
|
if (clearsCatalogueOnSignOut) {
|
||||||
|
await _ref.read(localStoreProvider).clearCatalogue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unconditional, and before the state change: the token and the staff PINs
|
||||||
|
// in that blob must not survive a sign-out, and nothing below may be able
|
||||||
|
// to leave them on disk.
|
||||||
|
await _ref.read(sessionStoreProvider).clear();
|
||||||
|
|
||||||
|
state = const Unauthenticated();
|
||||||
|
}
|
||||||
|
|
||||||
void clearError() {
|
void clearError() {
|
||||||
if (state is AuthFailure) state = const Unauthenticated();
|
if (state is AuthFailure) state = const Unauthenticated();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final authControllerProvider =
|
final authControllerProvider = StateNotifierProvider<AuthController, AuthState>(
|
||||||
StateNotifierProvider<AuthController, AuthState>((ref) => AuthController());
|
AuthController.new,
|
||||||
|
);
|
||||||
|
|
||||||
/// The signed-in store, or null before sign-in.
|
/// The signed-in store, or null before sign-in.
|
||||||
final currentStoreProvider = Provider<StoreAccount?>((ref) {
|
final currentStoreProvider = Provider<StoreAccount?>((ref) {
|
||||||
@@ -113,3 +343,48 @@ final currentUserProvider = Provider<StaffUser?>((ref) {
|
|||||||
final s = ref.watch(authControllerProvider);
|
final s = ref.watch(authControllerProvider);
|
||||||
return s is Authenticated ? s.user : null;
|
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 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../app/providers.dart';
|
import '../../../app/providers.dart';
|
||||||
|
import '../../../core/constants/asset_paths.dart';
|
||||||
import '../../../core/router/app_router.dart';
|
import '../../../core/router/app_router.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/utils/validators.dart';
|
|
||||||
import '../../../core/widgets/primary_button.dart';
|
import '../../../core/widgets/primary_button.dart';
|
||||||
import '../providers/auth_controller.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 {
|
class LoginScreen extends ConsumerStatefulWidget {
|
||||||
const LoginScreen({super.key});
|
const LoginScreen({super.key});
|
||||||
|
|
||||||
@@ -21,15 +34,16 @@ class LoginScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
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 _obscure = true;
|
||||||
bool _rememberTerminal = true;
|
bool _rememberTerminal = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_email.dispose();
|
_authname.dispose();
|
||||||
_password.dispose();
|
_password.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -39,194 +53,89 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||||
|
|
||||||
final ok = await ref.read(authControllerProvider.notifier).signIn(
|
final ok = await ref.read(authControllerProvider.notifier).signIn(
|
||||||
email: _email.text,
|
authname: _authname.text,
|
||||||
password: _password.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final auth = ref.watch(authControllerProvider);
|
||||||
|
final session = ref.watch(cashierSessionProvider);
|
||||||
|
final busy = auth is Authenticating;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.background,
|
backgroundColor: AppColors.background,
|
||||||
body: LayoutBuilder(
|
body: Stack(
|
||||||
builder: (context, constraints) {
|
fit: StackFit.expand,
|
||||||
// Below this there isn't room for the brand panel beside the form.
|
|
||||||
final showBrandPanel = constraints.maxWidth >= 1000;
|
|
||||||
|
|
||||||
return Row(
|
|
||||||
children: [
|
children: [
|
||||||
if (showBrandPanel)
|
const _Backdrop(),
|
||||||
const Expanded(flex: 5, child: _BrandPanel()),
|
SafeArea(
|
||||||
Expanded(
|
child: LayoutBuilder(
|
||||||
flex: 4,
|
builder: (context, box) {
|
||||||
child: _FormPanel(
|
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,
|
formKey: _formKey,
|
||||||
email: _email,
|
authname: _authname,
|
||||||
password: _password,
|
password: _password,
|
||||||
obscure: _obscure,
|
obscure: _obscure,
|
||||||
rememberTerminal: _rememberTerminal,
|
rememberTerminal: _rememberTerminal,
|
||||||
showCompactLogo: !showBrandPanel,
|
busy: busy,
|
||||||
onToggleObscure: () => setState(() => _obscure = !_obscure),
|
failure:
|
||||||
|
auth is AuthFailure ? auth.message : null,
|
||||||
|
onToggleObscure: () =>
|
||||||
|
setState(() => _obscure = !_obscure),
|
||||||
onToggleRemember: (v) =>
|
onToggleRemember: (v) =>
|
||||||
setState(() => _rememberTerminal = v ?? true),
|
setState(() => _rememberTerminal = v ?? true),
|
||||||
onSubmit: _submit,
|
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 {
|
/// The shopfront behind the form.
|
||||||
const _FormPanel({
|
///
|
||||||
|
/// 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.formKey,
|
||||||
required this.email,
|
required this.authname,
|
||||||
required this.password,
|
required this.password,
|
||||||
required this.obscure,
|
required this.obscure,
|
||||||
required this.rememberTerminal,
|
required this.rememberTerminal,
|
||||||
required this.showCompactLogo,
|
required this.busy,
|
||||||
|
required this.failure,
|
||||||
required this.onToggleObscure,
|
required this.onToggleObscure,
|
||||||
required this.onToggleRemember,
|
required this.onToggleRemember,
|
||||||
required this.onSubmit,
|
required this.onSubmit,
|
||||||
});
|
});
|
||||||
|
|
||||||
final GlobalKey<FormState> formKey;
|
final GlobalKey<FormState> formKey;
|
||||||
final TextEditingController email;
|
final TextEditingController authname;
|
||||||
final TextEditingController password;
|
final TextEditingController password;
|
||||||
final bool obscure;
|
final bool obscure;
|
||||||
final bool rememberTerminal;
|
final bool rememberTerminal;
|
||||||
final bool showCompactLogo;
|
final bool busy;
|
||||||
|
final String? failure;
|
||||||
final VoidCallback onToggleObscure;
|
final VoidCallback onToggleObscure;
|
||||||
final ValueChanged<bool?> onToggleRemember;
|
final ValueChanged<bool?> onToggleRemember;
|
||||||
final VoidCallback onSubmit;
|
final VoidCallback onSubmit;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final auth = ref.watch(authControllerProvider);
|
return Container(
|
||||||
final session = ref.watch(cashierSessionProvider);
|
|
||||||
final busy = auth is Authenticating;
|
|
||||||
|
|
||||||
return SafeArea(
|
|
||||||
child: Center(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(AppSpacing.xxl),
|
padding: const EdgeInsets.all(AppSpacing.xxl),
|
||||||
child: ConstrainedBox(
|
decoration: BoxDecoration(
|
||||||
constraints: const BoxConstraints(maxWidth: 420),
|
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(
|
child: Form(
|
||||||
key: formKey,
|
key: formKey,
|
||||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||||
@@ -276,56 +239,42 @@ class _FormPanel extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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(
|
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.',
|
'registered.',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13.5,
|
fontSize: 13,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.xxxl),
|
const SizedBox(height: AppSpacing.xl),
|
||||||
|
|
||||||
const _Label('Store email'),
|
const _Label('Terminal account'),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: email,
|
controller: authname,
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
enabled: !busy,
|
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
|
validator: (v) => (v ?? '').trim().isEmpty
|
||||||
? 'Store email is required'
|
? 'The terminal account is required'
|
||||||
: Validators.emailOptional(v),
|
: null,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: 'store@example.in',
|
hintText: 'supervisor.1135@pos.nearle.in',
|
||||||
prefixIcon: Icon(Icons.storefront_outlined),
|
prefixIcon: Icon(Icons.storefront_outlined),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -338,11 +287,11 @@ class _FormPanel extends ConsumerWidget {
|
|||||||
enabled: !busy,
|
enabled: !busy,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
onFieldSubmitted: (_) => onSubmit(),
|
onFieldSubmitted: (_) => onSubmit(),
|
||||||
validator: (v) => (v ?? '').isEmpty
|
// Length is the server's rule to enforce. Refusing to *send* a
|
||||||
? 'Password is required'
|
// short password only produces a second, different error message
|
||||||
: ((v ?? '').length < 6
|
// for the same wrong credential.
|
||||||
? 'Password looks too short'
|
validator: (v) =>
|
||||||
: null),
|
(v ?? '').isEmpty ? 'Password is required' : null,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Enter your password',
|
hintText: 'Enter your password',
|
||||||
prefixIcon: const Icon(Icons.lock_outline_rounded),
|
prefixIcon: const Icon(Icons.lock_outline_rounded),
|
||||||
@@ -366,9 +315,7 @@ class _FormPanel extends ConsumerWidget {
|
|||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: busy
|
onTap: busy ? null : () => onToggleRemember(!rememberTerminal),
|
||||||
? null
|
|
||||||
: () => onToggleRemember(!rememberTerminal),
|
|
||||||
borderRadius: AppRadius.brXs,
|
borderRadius: AppRadius.brXs,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
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),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
@@ -413,12 +360,15 @@ class _FormPanel extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.error_outline_rounded,
|
const Icon(
|
||||||
color: AppColors.danger, size: 18,),
|
Icons.error_outline_rounded,
|
||||||
|
color: AppColors.danger,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
auth.message,
|
failure!,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.danger,
|
color: AppColors.danger,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
@@ -430,7 +380,7 @@ class _FormPanel extends ConsumerWidget {
|
|||||||
).animate().shake(duration: 320.ms, hz: 3),
|
).animate().shake(duration: 320.ms, hz: 3),
|
||||||
],
|
],
|
||||||
|
|
||||||
const SizedBox(height: AppSpacing.xl),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
label: 'Sign in',
|
label: 'Sign in',
|
||||||
icon: Icons.login_rounded,
|
icon: Icons.login_rounded,
|
||||||
@@ -438,34 +388,10 @@ class _FormPanel extends ConsumerWidget {
|
|||||||
busy: busy,
|
busy: busy,
|
||||||
onPressed: onSubmit,
|
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/constants/app_constants.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.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/numeric_keypad.dart';
|
||||||
import '../../../core/widgets/primary_button.dart';
|
import '../../../core/widgets/primary_button.dart';
|
||||||
import '../../../core/widgets/status_pill.dart';
|
|
||||||
import '../../../domain/entities/customer.dart';
|
import '../../../domain/entities/customer.dart';
|
||||||
import '../../pos/providers/cart_controller.dart';
|
import '../../pos/providers/cart_controller.dart';
|
||||||
import '../providers/customer_providers.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
|
/// A mobile number, optionally a name, or skip. Nothing else — no lookup
|
||||||
/// just a name, or the whole step skipped. Nothing here blocks the sale.
|
/// 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) {
|
Future<void> showCustomerCaptureSheet(BuildContext context) {
|
||||||
return showModalBottomSheet<void>(
|
return showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -35,13 +41,14 @@ class _CustomerCaptureSheet extends ConsumerStatefulWidget {
|
|||||||
_CustomerCaptureSheetState();
|
_CustomerCaptureSheetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CustomerCaptureSheetState
|
class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
|
||||||
extends ConsumerState<_CustomerCaptureSheet> {
|
|
||||||
String _digits = '';
|
String _digits = '';
|
||||||
final _name = TextEditingController();
|
final _name = TextEditingController();
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
|
bool get _complete => _digits.length == AppConstants.mobileNumberLength;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_name.dispose();
|
_name.dispose();
|
||||||
@@ -54,15 +61,14 @@ class _CustomerCaptureSheetState
|
|||||||
_digits += d;
|
_digits += d;
|
||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
if (_digits.length == AppConstants.mobileNumberLength) {
|
|
||||||
ref.read(customerLookupProvider.notifier).search(_digits);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _backspace() {
|
void _backspace() {
|
||||||
if (_digits.isEmpty) return;
|
if (_digits.isEmpty) return;
|
||||||
setState(() => _digits = _digits.substring(0, _digits.length - 1));
|
setState(() {
|
||||||
ref.read(customerLookupProvider.notifier).reset();
|
_digits = _digits.substring(0, _digits.length - 1);
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clear() {
|
void _clear() {
|
||||||
@@ -70,7 +76,6 @@ class _CustomerCaptureSheetState
|
|||||||
_digits = '';
|
_digits = '';
|
||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
ref.read(customerLookupProvider.notifier).reset();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _attachAndClose(Customer? customer) {
|
void _attachAndClose(Customer? customer) {
|
||||||
@@ -78,22 +83,38 @@ class _CustomerCaptureSheetState
|
|||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves with whatever was given. Both fields are optional: a bare number
|
/// Saves with whatever was given.
|
||||||
/// is still worth keeping, because it is what the WhatsApp bill is sent to.
|
///
|
||||||
Future<void> _quickRegister() async {
|
/// The name is optional: a bare number is still worth keeping, because it is
|
||||||
final typed = _name.text.trim();
|
/// what the WhatsApp bill is sent to. An existing record wins over creating a
|
||||||
final name = typed.isEmpty
|
/// second one — silently, because a cashier does not need to be told the
|
||||||
? 'Customer ${_digits.substring(_digits.length - 4)}'
|
/// shopper has been here before to finish the sale.
|
||||||
: typed;
|
Future<void> _save() async {
|
||||||
|
if (!_complete) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_saving = true;
|
_saving = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final typed = _name.text.trim();
|
||||||
|
final repository = ref.read(customerRepositoryProvider);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final created = await ref.read(customerRepositoryProvider).create(
|
final existing = await repository.findByMobile(_digits);
|
||||||
Customer(id: '', name: name, mobile: _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);
|
ref.invalidate(recentCustomersProvider);
|
||||||
if (mounted) _attachAndClose(created);
|
if (mounted) _attachAndClose(created);
|
||||||
@@ -108,7 +129,6 @@ class _CustomerCaptureSheetState
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final lookup = ref.watch(customerLookupProvider);
|
|
||||||
final attached = ref.watch(
|
final attached = ref.watch(
|
||||||
cartControllerProvider.select((c) => c.customer),
|
cartControllerProvider.select((c) => c.customer),
|
||||||
);
|
);
|
||||||
@@ -130,36 +150,100 @@ class _CustomerCaptureSheetState
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_grabber(),
|
_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),
|
_header(attached),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(AppSpacing.xxl),
|
padding: const EdgeInsets.fromLTRB(
|
||||||
child: LayoutBuilder(
|
AppSpacing.xxl,
|
||||||
builder: (context, constraints) {
|
AppSpacing.xl,
|
||||||
// Side by side once there is room for both columns.
|
AppSpacing.xxl,
|
||||||
final wide = constraints.maxWidth >= 720;
|
AppSpacing.xxl,
|
||||||
final entry = _entryColumn();
|
),
|
||||||
final result = _resultColumn(lookup);
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
if (!wide) {
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
return Column(
|
|
||||||
children: [
|
children: [
|
||||||
entry,
|
_display(),
|
||||||
const SizedBox(height: AppSpacing.xl),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
result,
|
Text(
|
||||||
|
_complete
|
||||||
|
? 'Ready to save'
|
||||||
|
: '${AppConstants.mobileNumberLength - _digits.length}'
|
||||||
|
' more digit(s)',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
Center(
|
||||||
|
child: NumericKeypad(
|
||||||
|
maxWidth: 320,
|
||||||
|
onKey: _append,
|
||||||
|
onBackspace: _backspace,
|
||||||
|
onClear: _clear,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
TextField(
|
||||||
|
controller: _name,
|
||||||
|
textCapitalization:
|
||||||
|
TextCapitalization.words,
|
||||||
|
enabled: !_saving,
|
||||||
|
onSubmitted: (_) => _save(),
|
||||||
|
inputFormatters: [
|
||||||
|
LengthLimitingTextInputFormatter(60),
|
||||||
],
|
],
|
||||||
);
|
decoration: const InputDecoration(
|
||||||
}
|
labelText: 'Customer name',
|
||||||
return Row(
|
hintText: 'Optional',
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
prefixIcon:
|
||||||
children: [
|
Icon(Icons.person_outline_rounded),
|
||||||
Expanded(child: entry),
|
),
|
||||||
const SizedBox(width: AppSpacing.xxl),
|
),
|
||||||
Expanded(child: result),
|
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(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
AppSpacing.xxl,
|
AppSpacing.xxl,
|
||||||
0,
|
0,
|
||||||
AppSpacing.md,
|
AppSpacing.lg,
|
||||||
AppSpacing.lg,
|
AppSpacing.lg,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
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(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -197,10 +293,16 @@ class _CustomerCaptureSheetState
|
|||||||
Text(
|
Text(
|
||||||
attached == null ? 'Add customer' : 'Change customer',
|
attached == null ? 'Add customer' : 'Change customer',
|
||||||
overflow: TextOverflow.ellipsis,
|
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(
|
const Text(
|
||||||
'Optional — for loyalty points and tier discounts',
|
'Optional — the bill can be sent to this number',
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12.5,
|
fontSize: 12.5,
|
||||||
@@ -211,357 +313,84 @@ class _CustomerCaptureSheetState
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
TextButton(
|
|
||||||
onPressed: () => _attachAndClose(null),
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
child: const Text('Skip'),
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
icon: const Icon(Icons.close_rounded),
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
color: AppColors.textTertiary,
|
||||||
tooltip: 'Close',
|
tooltip: 'Close',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _entryColumn() => Column(
|
/// The number as it is keyed, grouped 5 + 5 the way it is read aloud.
|
||||||
mainAxisSize: MainAxisSize.min,
|
Widget _display() {
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
final filled = _digits.isNotEmpty;
|
||||||
children: [
|
final head = _digits.length <= 5 ? _digits : _digits.substring(0, 5);
|
||||||
_display(),
|
final tail = _digits.length <= 5 ? '' : _digits.substring(5);
|
||||||
const SizedBox(height: AppSpacing.xl),
|
|
||||||
Center(
|
|
||||||
child: NumericKeypad(
|
|
||||||
maxWidth: 340,
|
|
||||||
onKey: _append,
|
|
||||||
onBackspace: _backspace,
|
|
||||||
onClear: _clear,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _display() => Container(
|
return Container(
|
||||||
height: 68,
|
height: 64,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
padding: const EdgeInsets.only(left: AppSpacing.md, right: AppSpacing.xs),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.primarySurface,
|
color: filled ? AppColors.surface : AppColors.surfaceAlt,
|
||||||
borderRadius: AppRadius.brLg,
|
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(
|
child: Row(
|
||||||
children: [
|
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',
|
'+91',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: AppSpacing.md),
|
const SizedBox(width: AppSpacing.md),
|
||||||
// FittedBox guarantees ten digits fit at any sheet width.
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
fit: BoxFit.scaleDown,
|
fit: BoxFit.scaleDown,
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Text(
|
child: filled
|
||||||
_digits.isEmpty
|
? Text(
|
||||||
? '– – – – – – – – – –'
|
tail.isEmpty ? head : '$head $tail',
|
||||||
: _digits.split('').join(' '),
|
style: AppTypography.money(23).copyWith(
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
'Mobile number',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w700,
|
color: AppColors.textTertiary.withValues(alpha: 0.9),
|
||||||
letterSpacing: 1,
|
|
||||||
color: _digits.isEmpty
|
|
||||||
? AppColors.textTertiary
|
|
||||||
: AppColors.textPrimary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_digits.isNotEmpty)
|
if (filled)
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: _clear,
|
onPressed: _clear,
|
||||||
icon: const Icon(Icons.close_rounded, size: 20),
|
icon: const Icon(Icons.backspace_outlined, size: 18),
|
||||||
color: AppColors.textTertiary,
|
color: AppColors.textTertiary,
|
||||||
tooltip: 'Clear',
|
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ class PrinterSettings {
|
|||||||
this.printerName,
|
this.printerName,
|
||||||
this.autoPrint = false,
|
this.autoPrint = false,
|
||||||
this.openDrawer = true,
|
this.openDrawer = true,
|
||||||
|
this.drawerHost,
|
||||||
|
this.drawerPort = 9100,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Target passed to `directPrintPdf`. Null means "use the system default".
|
/// Target passed to `directPrintPdf`. Null means "use the system default".
|
||||||
@@ -31,7 +33,16 @@ class PrinterSettings {
|
|||||||
|
|
||||||
final bool openDrawer;
|
final bool openDrawer;
|
||||||
|
|
||||||
|
/// IP address of the receipt printer the drawer is wired to.
|
||||||
|
///
|
||||||
|
/// Separate from [printerUrl] because that is an opaque platform handle for
|
||||||
|
/// the PDF driver, which cannot carry raw ESC/POS bytes. The drawer kick
|
||||||
|
/// needs a socket, so it needs an address.
|
||||||
|
final String? drawerHost;
|
||||||
|
final int drawerPort;
|
||||||
|
|
||||||
bool get hasPrinter => printerUrl != null;
|
bool get hasPrinter => printerUrl != null;
|
||||||
|
bool get hasDrawer => (drawerHost ?? '').isNotEmpty;
|
||||||
|
|
||||||
PrinterSettings copyWith({
|
PrinterSettings copyWith({
|
||||||
String? printerUrl,
|
String? printerUrl,
|
||||||
@@ -39,12 +50,16 @@ class PrinterSettings {
|
|||||||
bool clearPrinter = false,
|
bool clearPrinter = false,
|
||||||
bool? autoPrint,
|
bool? autoPrint,
|
||||||
bool? openDrawer,
|
bool? openDrawer,
|
||||||
|
String? drawerHost,
|
||||||
|
int? drawerPort,
|
||||||
}) {
|
}) {
|
||||||
return PrinterSettings(
|
return PrinterSettings(
|
||||||
printerUrl: clearPrinter ? null : (printerUrl ?? this.printerUrl),
|
printerUrl: clearPrinter ? null : (printerUrl ?? this.printerUrl),
|
||||||
printerName: clearPrinter ? null : (printerName ?? this.printerName),
|
printerName: clearPrinter ? null : (printerName ?? this.printerName),
|
||||||
autoPrint: autoPrint ?? this.autoPrint,
|
autoPrint: autoPrint ?? this.autoPrint,
|
||||||
openDrawer: openDrawer ?? this.openDrawer,
|
openDrawer: openDrawer ?? this.openDrawer,
|
||||||
|
drawerHost: drawerHost ?? this.drawerHost,
|
||||||
|
drawerPort: drawerPort ?? this.drawerPort,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,11 +76,26 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
|
|||||||
if (!store.isReady) return;
|
if (!store.isReady) return;
|
||||||
|
|
||||||
final dao = store.catalogue;
|
final dao = store.catalogue;
|
||||||
|
|
||||||
|
// Read every value first, then assign. Written inline the four awaits still
|
||||||
|
// run before the assignment, so leaving Settings mid-read threw
|
||||||
|
// "used after dispose" — which reaches the cashier as a red screen.
|
||||||
|
final url = await dao.meta(MetaKeys.printerUrl);
|
||||||
|
final name = await dao.meta(MetaKeys.printerName);
|
||||||
|
final autoPrint = await dao.meta(MetaKeys.autoPrint);
|
||||||
|
final openDrawer = await dao.meta(MetaKeys.openDrawer);
|
||||||
|
final drawerHost = await dao.meta(MetaKeys.drawerHost);
|
||||||
|
final drawerPort = await dao.meta(MetaKeys.drawerPort);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
state = PrinterSettings(
|
state = PrinterSettings(
|
||||||
printerUrl: await dao.meta(MetaKeys.printerUrl),
|
printerUrl: url,
|
||||||
printerName: await dao.meta(MetaKeys.printerName),
|
printerName: name,
|
||||||
autoPrint: (await dao.meta(MetaKeys.autoPrint)) == '1',
|
autoPrint: autoPrint == '1',
|
||||||
openDrawer: (await dao.meta(MetaKeys.openDrawer)) != '0',
|
openDrawer: openDrawer != '0',
|
||||||
|
drawerHost: drawerHost,
|
||||||
|
drawerPort: int.tryParse(drawerPort ?? '') ?? 9100,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,12 +105,14 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
|
|||||||
if (printer == null) {
|
if (printer == null) {
|
||||||
await dao.setMeta(MetaKeys.printerUrl, '');
|
await dao.setMeta(MetaKeys.printerUrl, '');
|
||||||
await dao.setMeta(MetaKeys.printerName, '');
|
await dao.setMeta(MetaKeys.printerName, '');
|
||||||
|
if (!mounted) return;
|
||||||
state = state.copyWith(clearPrinter: true, autoPrint: false);
|
state = state.copyWith(clearPrinter: true, autoPrint: false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await dao.setMeta(MetaKeys.printerUrl, printer.url);
|
await dao.setMeta(MetaKeys.printerUrl, printer.url);
|
||||||
await dao.setMeta(MetaKeys.printerName, printer.name);
|
await dao.setMeta(MetaKeys.printerName, printer.name);
|
||||||
|
if (!mounted) return;
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
printerUrl: printer.url,
|
printerUrl: printer.url,
|
||||||
printerName: printer.name,
|
printerName: printer.name,
|
||||||
@@ -95,6 +127,7 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
|
|||||||
.read(localStoreProvider)
|
.read(localStoreProvider)
|
||||||
.catalogue
|
.catalogue
|
||||||
.setMeta(MetaKeys.autoPrint, value ? '1' : '0');
|
.setMeta(MetaKeys.autoPrint, value ? '1' : '0');
|
||||||
|
if (!mounted) return;
|
||||||
state = state.copyWith(autoPrint: value);
|
state = state.copyWith(autoPrint: value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,8 +136,21 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
|
|||||||
.read(localStoreProvider)
|
.read(localStoreProvider)
|
||||||
.catalogue
|
.catalogue
|
||||||
.setMeta(MetaKeys.openDrawer, value ? '1' : '0');
|
.setMeta(MetaKeys.openDrawer, value ? '1' : '0');
|
||||||
|
if (!mounted) return;
|
||||||
state = state.copyWith(openDrawer: value);
|
state = state.copyWith(openDrawer: value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Points the drawer kick at a printer.
|
||||||
|
///
|
||||||
|
/// A blank host disables it — which is the honest state for a USB printer,
|
||||||
|
/// since there is no raw path to one from Flutter.
|
||||||
|
Future<void> setDrawerAddress(String host, int port) async {
|
||||||
|
final dao = _ref.read(localStoreProvider).catalogue;
|
||||||
|
await dao.setMeta(MetaKeys.drawerHost, host.trim());
|
||||||
|
await dao.setMeta(MetaKeys.drawerPort, '$port');
|
||||||
|
if (!mounted) return;
|
||||||
|
state = state.copyWith(drawerHost: host.trim(), drawerPort: port);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final printerSettingsProvider =
|
final printerSettingsProvider =
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import '../../../app/providers.dart';
|
|||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/utils/formatters.dart';
|
import '../../../core/utils/formatters.dart';
|
||||||
import '../../../core/widgets/status_pill.dart';
|
|
||||||
import '../../../domain/entities/customer.dart';
|
import '../../../domain/entities/customer.dart';
|
||||||
|
import '../../customer/widgets/customer_capture_sheet.dart';
|
||||||
import '../widgets/module_widgets.dart';
|
import '../widgets/module_widgets.dart';
|
||||||
|
|
||||||
/// Full customer book, independent of the six shown during billing.
|
/// Full customer book, independent of the six shown during billing.
|
||||||
@@ -23,14 +23,6 @@ class CustomersView extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _CustomersViewState extends ConsumerState<CustomersView> {
|
class _CustomersViewState extends ConsumerState<CustomersView> {
|
||||||
String _query = '';
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -38,10 +30,9 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
|
|||||||
|
|
||||||
final filtered = all.where((c) {
|
final filtered = all.where((c) {
|
||||||
final q = _query.trim().toLowerCase();
|
final q = _query.trim().toLowerCase();
|
||||||
final matchesQuery = q.isEmpty ||
|
return q.isEmpty ||
|
||||||
c.name.toLowerCase().contains(q) ||
|
c.name.toLowerCase().contains(q) ||
|
||||||
c.mobile.contains(q);
|
c.mobile.contains(q);
|
||||||
return matchesQuery && (_tier == null || c.tier == _tier);
|
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
final lifetime = all.fold<double>(0, (s, c) => s + c.lifetimeSpend);
|
final lifetime = all.fold<double>(0, (s, c) => s + c.lifetimeSpend);
|
||||||
@@ -84,31 +75,17 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.lg),
|
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(
|
PanelCard(
|
||||||
title: 'Customer book',
|
title: 'Customer book',
|
||||||
subtitle: '${filtered.length} shown',
|
subtitle: '${filtered.length} shown',
|
||||||
action: FilledButton.icon(
|
action: FilledButton.icon(
|
||||||
onPressed: () {},
|
// Reuses the sheet the till already opens at checkout, so a
|
||||||
|
// customer added here is validated the same way — including the
|
||||||
|
// duplicate-mobile guard.
|
||||||
|
onPressed: () async {
|
||||||
|
await showCustomerCaptureSheet(context);
|
||||||
|
ref.invalidate(allCustomersProvider);
|
||||||
|
},
|
||||||
icon: const Icon(Icons.person_add_alt_1_rounded, size: 17),
|
icon: const Icon(Icons.person_add_alt_1_rounded, size: 17),
|
||||||
label: const Text('Add customer'),
|
label: const Text('Add customer'),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
@@ -127,45 +104,11 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
|
|||||||
isDense: true,
|
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),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
ResponsiveTable(
|
ResponsiveTable(
|
||||||
columns: const [
|
columns: const [
|
||||||
TableCol('Customer', flex: 4),
|
TableCol('Customer', flex: 4),
|
||||||
TableCol('Mobile', flex: 3, priority: 1),
|
TableCol('Mobile', flex: 3, priority: 1),
|
||||||
TableCol('Tier', flex: 2),
|
|
||||||
TableCol('Points', flex: 2, numeric: true, priority: 1),
|
TableCol('Points', flex: 2, numeric: true, priority: 1),
|
||||||
TableCol('Lifetime', flex: 2, numeric: true),
|
TableCol('Lifetime', flex: 2, numeric: true),
|
||||||
TableCol('Visits', flex: 2, numeric: true, priority: 1),
|
TableCol('Visits', flex: 2, numeric: true, priority: 1),
|
||||||
@@ -192,7 +135,6 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
Cell(Formatters.mobile(c.mobile), mono: true),
|
Cell(Formatters.mobile(c.mobile), mono: true),
|
||||||
StatusPill.tier(c.tier, dense: true),
|
|
||||||
Cell('${c.loyaltyPoints}', mono: true),
|
Cell('${c.loyaltyPoints}', mono: true),
|
||||||
Cell(Formatters.moneyCompact(c.lifetimeSpend),
|
Cell(Formatters.moneyCompact(c.lifetimeSpend),
|
||||||
mono: true, bold: true,),
|
mono: true, bold: true,),
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../app/providers.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/utils/formatters.dart';
|
import '../../../core/utils/formatters.dart';
|
||||||
import '../../../core/widgets/primary_button.dart';
|
import '../../../data/sync/sync_engine.dart';
|
||||||
import '../../../domain/entities/transaction.dart';
|
|
||||||
import '../../../domain/repositories/sync_repository.dart';
|
|
||||||
import '../../sync/providers/sync_controller.dart';
|
import '../../sync/providers/sync_controller.dart';
|
||||||
import '../widgets/module_widgets.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
|
/// Read-only. Uploading is the sync engine's job — it pushes after every sale
|
||||||
/// `sync_status = 0`. Accepted bills flip to 1; anything that fails stays at 0
|
/// and retries on its own — so this page reports rather than drives. The
|
||||||
/// and is retried on the next tap.
|
/// 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 {
|
class EventsView extends ConsumerWidget {
|
||||||
const EventsView({super.key});
|
const EventsView({super.key});
|
||||||
|
|
||||||
@@ -23,13 +23,22 @@ class EventsView extends ConsumerWidget {
|
|||||||
final report = ref.watch(todayReportProvider);
|
final report = ref.watch(todayReportProvider);
|
||||||
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
|
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
|
||||||
final rows = ref.watch(orderSyncRowsProvider).value ?? const [];
|
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;
|
final r = report.value;
|
||||||
|
|
||||||
return ModulePage(
|
return ModulePage(
|
||||||
children: [
|
children: [
|
||||||
|
if (engine.lastError != null && pending > 0)
|
||||||
|
_EngineWarningBanner(engine: engine),
|
||||||
|
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: AppSpacing.lg,
|
spacing: AppSpacing.lg,
|
||||||
runSpacing: AppSpacing.lg,
|
runSpacing: AppSpacing.lg,
|
||||||
@@ -70,118 +79,30 @@ class EventsView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.lg),
|
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(
|
PanelCard(
|
||||||
title: 'Orders',
|
title: 'Orders',
|
||||||
subtitle: '${rows.length} stored \u00b7 $pending awaiting upload',
|
subtitle: '${rows.length} stored \u00b7 $pending awaiting upload',
|
||||||
child: ResponsiveTable(
|
child: ResponsiveTable(
|
||||||
columns: const [
|
columns: const [
|
||||||
TableCol('Invoice', flex: 3),
|
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('Total', flex: 2, numeric: true),
|
||||||
TableCol('Sync', flex: 2, numeric: true),
|
TableCol('Sync', flex: 2, numeric: true),
|
||||||
],
|
],
|
||||||
rows: rows
|
rows: rows
|
||||||
.map((o) => [
|
.map((o) => [
|
||||||
Cell(o.invoiceNumber, bold: true, mono: true),
|
Cell(o.invoiceNumber, bold: true, mono: true),
|
||||||
Cell(Formatters.time(o.createdAt),
|
// Date sits with the time because this table outlives the
|
||||||
color: AppColors.textTertiary,),
|
// 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),
|
Cell(Formatters.money(o.total), mono: true, bold: true),
|
||||||
TagChip(
|
TagChip(
|
||||||
o.isSynced ? 'Synced' : 'Pending',
|
o.isSynced ? 'Synced' : 'Pending',
|
||||||
@@ -192,121 +113,87 @@ class EventsView extends ConsumerWidget {
|
|||||||
.toList(),
|
.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) {
|
/// Units on a bill. Whole where they are whole — loose goods are sold by
|
||||||
final ok = outcome.isSuccess;
|
/// weight, so "2.5" is a real answer here and rounding it would be a lie.
|
||||||
final uploaded = outcome.uploaded;
|
static String _units(double count) =>
|
||||||
final attempted = outcome.attempted;
|
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(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
|
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(color: surface, borderRadius: AppRadius.brLg),
|
||||||
color: ok ? AppColors.successSurface : AppColors.dangerSurface,
|
|
||||||
borderRadius: AppRadius.brSm,
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Icons.wifi_off_rounded, size: 20, color: color),
|
||||||
ok ? Icons.check_circle_outline_rounded : Icons.wifi_off_rounded,
|
|
||||||
size: 18,
|
|
||||||
color: ok ? AppColors.success : AppColors.danger,
|
|
||||||
),
|
|
||||||
const SizedBox(width: AppSpacing.sm),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
ok
|
|
||||||
? '$uploaded of $attempted bills uploaded and marked synced.'
|
|
||||||
: '${outcome.error}',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
height: 1.45,
|
|
||||||
color: ok ? AppColors.success : AppColors.danger,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Color _methodColor(PaymentMethod m) => switch (m) {
|
|
||||||
PaymentMethod.cash => AppColors.success,
|
|
||||||
PaymentMethod.card => AppColors.info,
|
|
||||||
PaymentMethod.upi => AppColors.primary,
|
|
||||||
PaymentMethod.wallet => AppColors.warning,
|
|
||||||
PaymentMethod.giftCard => AppColors.tierGold,
|
|
||||||
PaymentMethod.loyalty => AppColors.tierSilver,
|
|
||||||
};
|
|
||||||
|
|
||||||
Widget _row(String label, String value) => Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13.5,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: AppSpacing.md),
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
Text(
|
Text(
|
||||||
value,
|
halted
|
||||||
|
? 'Sync halted — ${engine.pending} bill(s) not sent'
|
||||||
|
: "Couldn't reach the server — "
|
||||||
|
'${engine.pending} bill(s) not sent',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
engine.lastError ?? 'The last upload attempt failed.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: color,
|
||||||
|
height: 1.45,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'$retryNote Every bill is still safe on this terminal — '
|
||||||
|
'nothing is lost while it waits.',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 13.5,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w600,
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.45,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../app/providers.dart';
|
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/utils/formatters.dart';
|
import '../../../core/utils/formatters.dart';
|
||||||
@@ -25,14 +24,18 @@ class ProductImportView extends ConsumerWidget {
|
|||||||
final ready = ref.watch(catalogueReadyProvider);
|
final ready = ref.watch(catalogueReadyProvider);
|
||||||
final lastImport = ref.watch(lastImportAtProvider);
|
final lastImport = ref.watch(lastImportAtProvider);
|
||||||
final products = ref.watch(allProductsProvider).value ?? const <Product>[];
|
final products = ref.watch(allProductsProvider).value ?? const <Product>[];
|
||||||
final revision = ref.watch(syncRepositoryProvider).catalogueRevision;
|
|
||||||
|
|
||||||
return ModulePage(
|
return ModulePage(
|
||||||
children: [
|
children: [
|
||||||
if (!ready) _NotImportedBanner(state: state),
|
if (!ready) _NotImportedBanner(state: state),
|
||||||
|
|
||||||
if (ready) ...[
|
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(
|
Wrap(
|
||||||
|
alignment: WrapAlignment.start,
|
||||||
|
runAlignment: WrapAlignment.start,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.start,
|
||||||
spacing: AppSpacing.lg,
|
spacing: AppSpacing.lg,
|
||||||
runSpacing: AppSpacing.lg,
|
runSpacing: AppSpacing.lg,
|
||||||
children: [
|
children: [
|
||||||
@@ -43,13 +46,6 @@ class ProductImportView extends ConsumerWidget {
|
|||||||
color: AppColors.success,
|
color: AppColors.success,
|
||||||
caption: 'available offline',
|
caption: 'available offline',
|
||||||
),
|
),
|
||||||
StatTile(
|
|
||||||
label: 'Catalogue Revision',
|
|
||||||
value: revision ?? '—',
|
|
||||||
icon: Icons.tag_rounded,
|
|
||||||
color: AppColors.info,
|
|
||||||
caption: 'server version',
|
|
||||||
),
|
|
||||||
StatTile(
|
StatTile(
|
||||||
label: 'Last Imported',
|
label: 'Last Imported',
|
||||||
value: lastImport == null
|
value: lastImport == null
|
||||||
@@ -91,7 +87,7 @@ class ProductImportView extends ConsumerWidget {
|
|||||||
child: ResponsiveTable(
|
child: ResponsiveTable(
|
||||||
columns: const [
|
columns: const [
|
||||||
TableCol('Product', flex: 4),
|
TableCol('Product', flex: 4),
|
||||||
TableCol('SKU', flex: 3, priority: 1),
|
TableCol('Barcode', flex: 3, priority: 1),
|
||||||
TableCol('Category', flex: 2, priority: 1),
|
TableCol('Category', flex: 2, priority: 1),
|
||||||
TableCol('Price', flex: 2, numeric: true),
|
TableCol('Price', flex: 2, numeric: true),
|
||||||
TableCol('Stock', 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)),
|
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,
|
TagChip(p.category.label,
|
||||||
color: AppColors.textSecondary,),
|
color: AppColors.textSecondary,),
|
||||||
Cell(Formatters.money(p.price), mono: true, bold: true),
|
Cell(Formatters.money(p.price), mono: true, bold: true),
|
||||||
|
|||||||
@@ -1,112 +1,150 @@
|
|||||||
import 'package:flutter/material.dart';
|
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_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
|
import '../../../core/utils/formatters.dart';
|
||||||
|
import '../../../domain/entities/promo.dart';
|
||||||
|
import '../../../domain/entities/store_account.dart';
|
||||||
|
import '../../auth/providers/auth_controller.dart';
|
||||||
|
import '../../../core/widgets/empty_state.dart';
|
||||||
import '../widgets/module_widgets.dart';
|
import '../widgets/module_widgets.dart';
|
||||||
|
import '../widgets/promo_editor_dialog.dart';
|
||||||
|
|
||||||
/// Discount rules and campaigns.
|
/// Discount rules and campaigns.
|
||||||
class PromosView extends StatefulWidget {
|
///
|
||||||
|
/// This was a mockup: three hardcoded rows with a toggle that changed nothing,
|
||||||
|
/// and no promo code anywhere in the domain or data layers. A cashier looking
|
||||||
|
/// at it would reasonably conclude promotions were running. They were not.
|
||||||
|
class PromosView extends ConsumerWidget {
|
||||||
const PromosView({super.key});
|
const PromosView({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PromosView> createState() => _PromosViewState();
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
}
|
final promosAsync = ref.watch(promosProvider);
|
||||||
|
final isAdmin = ref.watch(currentUserProvider)?.role == StaffRole.admin;
|
||||||
|
|
||||||
class _PromosViewState extends State<PromosView> {
|
|
||||||
final Set<String> _enabled = {'WEEKEND10', 'DAIRY5', 'FESTIVE'};
|
|
||||||
|
|
||||||
static const _campaigns = [
|
|
||||||
(
|
|
||||||
'WEEKEND10',
|
|
||||||
'Weekend Saver',
|
|
||||||
'10% off bills above ₹500',
|
|
||||||
'Sat–Sun',
|
|
||||||
412,
|
|
||||||
AppColors.primary,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'DAIRY5',
|
|
||||||
'Dairy Days',
|
|
||||||
'5% off all dairy products',
|
|
||||||
'Ends 31 Aug',
|
|
||||||
286,
|
|
||||||
AppColors.info,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'FESTIVE',
|
|
||||||
'Festive Bonus',
|
|
||||||
'Double loyalty points',
|
|
||||||
'Ends 15 Sep',
|
|
||||||
178,
|
|
||||||
AppColors.tierGold,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'NEWCUST',
|
|
||||||
'First Purchase',
|
|
||||||
'₹50 off the first bill',
|
|
||||||
'Always on',
|
|
||||||
94,
|
|
||||||
AppColors.success,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ModulePage(
|
return ModulePage(
|
||||||
children: [
|
children: [
|
||||||
Wrap(
|
promosAsync.when(
|
||||||
spacing: AppSpacing.lg,
|
loading: () => const Padding(
|
||||||
runSpacing: AppSpacing.lg,
|
padding: EdgeInsets.all(AppSpacing.xxl),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
error: (e, _) => Text('Could not load campaigns: $e'),
|
||||||
|
// Stretch, not the Column default of centre. Centred, every card
|
||||||
|
// shrank to its own intrinsic width and floated in the middle of the
|
||||||
|
// page instead of starting at the left edge like every other module.
|
||||||
|
data: (promos) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
StatTile(
|
_summary(promos),
|
||||||
label: 'Active Campaigns',
|
const SizedBox(height: AppSpacing.lg),
|
||||||
value: '${_enabled.length}',
|
_campaignList(context, ref, promos, isAdmin: isAdmin),
|
||||||
icon: Icons.campaign_rounded,
|
|
||||||
caption: 'of ${_campaigns.length} configured',
|
|
||||||
),
|
|
||||||
const StatTile(
|
|
||||||
label: 'Redemptions',
|
|
||||||
value: '970',
|
|
||||||
icon: Icons.confirmation_number_rounded,
|
|
||||||
color: AppColors.info,
|
|
||||||
caption: 'this month',
|
|
||||||
),
|
|
||||||
const StatTile(
|
|
||||||
label: 'Discount Given',
|
|
||||||
value: '₹48,240',
|
|
||||||
icon: Icons.local_offer_rounded,
|
|
||||||
color: AppColors.warning,
|
|
||||||
caption: '2.6% of sales',
|
|
||||||
),
|
|
||||||
const StatTile(
|
|
||||||
label: 'Incremental Sales',
|
|
||||||
value: '₹2.14L',
|
|
||||||
icon: Icons.trending_up_rounded,
|
|
||||||
color: AppColors.success,
|
|
||||||
delta: '+18%',
|
|
||||||
caption: 'attributed',
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.lg),
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
PanelCard(
|
Widget _summary(List<Promo> promos) {
|
||||||
|
final live = promos.where((p) => p.isLiveAt(DateTime.now())).length;
|
||||||
|
final scheduled = promos
|
||||||
|
.where((p) =>
|
||||||
|
p.isActive &&
|
||||||
|
p.validFrom != null &&
|
||||||
|
p.validFrom!.isAfter(DateTime.now()),)
|
||||||
|
.length;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: StatTile(
|
||||||
|
label: 'Running now',
|
||||||
|
value: '$live',
|
||||||
|
icon: Icons.play_circle_outline_rounded,
|
||||||
|
color: AppColors.success,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: StatTile(
|
||||||
|
label: 'Scheduled',
|
||||||
|
value: '$scheduled',
|
||||||
|
icon: Icons.schedule_rounded,
|
||||||
|
color: AppColors.info,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: StatTile(
|
||||||
|
label: 'Paused',
|
||||||
|
value: '${promos.where((p) => !p.isActive).length}',
|
||||||
|
icon: Icons.pause_circle_outline_rounded,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _campaignList(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
List<Promo> promos, {
|
||||||
|
required bool isAdmin,
|
||||||
|
}) {
|
||||||
|
return PanelCard(
|
||||||
title: 'Campaigns',
|
title: 'Campaigns',
|
||||||
subtitle: 'Toggle a rule to apply it at the till immediately',
|
subtitle: promos.isEmpty
|
||||||
action: FilledButton.icon(
|
? 'Nothing running. Add a campaign and the till applies it '
|
||||||
onPressed: () {},
|
'automatically.'
|
||||||
|
: 'Applied automatically at the till, in priority order',
|
||||||
|
action: isAdmin
|
||||||
|
? FilledButton.icon(
|
||||||
|
onPressed: () => showPromoEditor(context),
|
||||||
icon: const Icon(Icons.add_rounded, size: 18),
|
icon: const Icon(Icons.add_rounded, size: 18),
|
||||||
label: const Text('New campaign'),
|
label: const Text('New campaign'),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: AppColors.primary,
|
backgroundColor: AppColors.primary,
|
||||||
minimumSize: const Size(0, 40),
|
minimumSize: const Size(0, 40),
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
child: Column(
|
: null,
|
||||||
|
child: promos.isEmpty
|
||||||
|
? const EmptyState(
|
||||||
|
title: 'No campaigns yet',
|
||||||
|
message: 'A campaign here is applied to every bill that '
|
||||||
|
'qualifies, without the cashier doing anything.',
|
||||||
|
emoji: '🏷️',
|
||||||
|
compact: true,
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
for (final c in _campaigns)
|
for (final promo in promos)
|
||||||
Container(
|
_PromoRow(promo: promo, isAdmin: isAdmin),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PromoRow extends ConsumerWidget {
|
||||||
|
const _PromoRow({required this.promo, required this.isAdmin});
|
||||||
|
|
||||||
|
final Promo promo;
|
||||||
|
final bool isAdmin;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final live = promo.isLiveAt(DateTime.now());
|
||||||
|
|
||||||
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
|
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -122,18 +160,22 @@ class _PromosViewState extends State<PromosView> {
|
|||||||
runSpacing: AppSpacing.sm,
|
runSpacing: AppSpacing.sm,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 320,
|
width: 360,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 38,
|
width: 38,
|
||||||
height: 38,
|
height: 38,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: c.$6.withValues(alpha: 0.12),
|
color: (live ? AppColors.success : AppColors.textTertiary)
|
||||||
|
.withValues(alpha: 0.12),
|
||||||
borderRadius: AppRadius.brSm,
|
borderRadius: AppRadius.brSm,
|
||||||
),
|
),
|
||||||
child: Icon(Icons.sell_rounded,
|
child: Icon(
|
||||||
size: 18, color: c.$6,),
|
Icons.sell_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: live ? AppColors.success : AppColors.textTertiary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: AppSpacing.md),
|
const SizedBox(width: AppSpacing.md),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -142,15 +184,12 @@ class _PromosViewState extends State<PromosView> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
c.$2,
|
promo.name,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
c.$3,
|
promo.summary,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12.5,
|
fontSize: 12.5,
|
||||||
@@ -163,40 +202,109 @@ class _PromosViewState extends State<PromosView> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
Wrap(
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
spacing: AppSpacing.sm,
|
||||||
children: [
|
children: [
|
||||||
TagChip(c.$1, color: c.$6),
|
if (promo.stackable)
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const TagChip('Stacks', color: AppColors.info),
|
||||||
TagChip(c.$4, color: AppColors.textSecondary),
|
// The distinction a shop actually needs: switched on, but out of
|
||||||
const SizedBox(width: AppSpacing.sm),
|
// its date range or wrong day, is not the same as switched off.
|
||||||
|
if (promo.isActive && !live)
|
||||||
|
const TagChip('Not today', color: AppColors.warning),
|
||||||
Text(
|
Text(
|
||||||
'${c.$5} used',
|
_window(promo),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: AppColors.textTertiary,
|
color: AppColors.textTertiary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: AppSpacing.sm),
|
if (isAdmin) ...[
|
||||||
Switch(
|
Switch(
|
||||||
value: _enabled.contains(c.$1),
|
value: promo.isActive,
|
||||||
onChanged: (v) => setState(() {
|
onChanged: (v) async {
|
||||||
if (v) {
|
await ref
|
||||||
_enabled.add(c.$1);
|
.read(localStoreProvider)
|
||||||
} else {
|
.promos
|
||||||
_enabled.remove(c.$1);
|
.setActive(promo.id, active: v);
|
||||||
}
|
ref
|
||||||
}),
|
..invalidate(promosProvider)
|
||||||
|
..invalidate(activePromosProvider);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Edit',
|
||||||
|
icon: const Icon(Icons.edit_outlined, size: 17),
|
||||||
|
onPressed: () => showPromoEditor(context, existing: promo),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Delete',
|
||||||
|
icon: const Icon(Icons.delete_outline_rounded, size: 17),
|
||||||
|
color: AppColors.danger,
|
||||||
|
onPressed: () => _confirmDelete(context, ref),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _window(Promo promo) {
|
||||||
|
final from = promo.validFrom;
|
||||||
|
final to = promo.validTo;
|
||||||
|
|
||||||
|
if (from == null && to == null) {
|
||||||
|
return promo.daysOfWeek.isEmpty ? 'Always' : _days(promo.daysOfWeek);
|
||||||
|
}
|
||||||
|
|
||||||
|
final range = [
|
||||||
|
if (from != null) 'from ${Formatters.date(from)}',
|
||||||
|
if (to != null) 'to ${Formatters.date(to)}',
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
return promo.daysOfWeek.isEmpty
|
||||||
|
? range
|
||||||
|
: '$range · ${_days(promo.daysOfWeek)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _days(Set<int> days) {
|
||||||
|
const names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||||
|
final sorted = days.toList()..sort();
|
||||||
|
return sorted.map((d) => names[d - 1]).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: Text('Delete "${promo.name}"?'),
|
||||||
|
content: const Text(
|
||||||
|
'Bills already rung keep the discount they were given — a bill '
|
||||||
|
'stores the amount, not a link to the campaign. Only future sales '
|
||||||
|
'are affected.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: AppColors.danger),
|
||||||
|
child: const Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
await ref.read(localStoreProvider).promos.delete(promo.id);
|
||||||
|
ref
|
||||||
|
..invalidate(promosProvider)
|
||||||
|
..invalidate(activePromosProvider);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,23 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../app/providers.dart';
|
import '../../../app/providers.dart';
|
||||||
|
import '../../../core/config/sync_config.dart';
|
||||||
import '../../../core/constants/app_constants.dart';
|
import '../../../core/constants/app_constants.dart';
|
||||||
|
import '../../../core/services/cash_drawer_service.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/utils/formatters.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 '../../../domain/entities/store_account.dart';
|
||||||
import '../../auth/providers/auth_controller.dart';
|
import '../../auth/providers/auth_controller.dart';
|
||||||
import '../providers/printer_settings.dart';
|
import '../providers/printer_settings.dart';
|
||||||
|
import '../widgets/back_office_dialog.dart';
|
||||||
|
import '../widgets/staff_dialogs.dart';
|
||||||
|
import '../widgets/store_details_dialog.dart';
|
||||||
import '../../sync/providers/sync_controller.dart';
|
import '../../sync/providers/sync_controller.dart';
|
||||||
import '../widgets/module_widgets.dart';
|
import '../widgets/module_widgets.dart';
|
||||||
|
import '../../../core/widgets/numeric_keypad.dart';
|
||||||
|
|
||||||
/// Terminal and store configuration.
|
/// Terminal and store configuration.
|
||||||
class SettingsView extends ConsumerStatefulWidget {
|
class SettingsView extends ConsumerStatefulWidget {
|
||||||
@@ -21,15 +29,50 @@ class SettingsView extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsViewState extends ConsumerState<SettingsView> {
|
class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||||
|
final _drawerHost = TextEditingController();
|
||||||
|
final _drawerPort = TextEditingController(text: '9100');
|
||||||
|
bool _testingDrawer = false;
|
||||||
|
bool _loadedDrawerFields = false;
|
||||||
|
|
||||||
|
/// Null until the first read comes back from the meta table.
|
||||||
|
bool? _hasRemovalPin;
|
||||||
|
bool _loadedRemovalPin = false;
|
||||||
|
|
||||||
bool _scannerSound = true;
|
bool _scannerSound = true;
|
||||||
bool _roundOff = true;
|
bool _roundOff = true;
|
||||||
bool _autoLoyalty = true;
|
bool _autoLoyalty = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_drawerHost.dispose();
|
||||||
|
_drawerPort.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final store = ref.watch(currentStoreProvider);
|
final store = ref.watch(currentStoreProvider);
|
||||||
final user = ref.watch(currentUserProvider);
|
final user = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
|
// Seeded once, from whatever was persisted. Assigning on every build would
|
||||||
|
// fight the cashier for the cursor while they type.
|
||||||
|
if (!_loadedRemovalPin) {
|
||||||
|
_loadedRemovalPin = true;
|
||||||
|
final store = ref.read(localStoreProvider);
|
||||||
|
if (store.isReady) {
|
||||||
|
store.voidPin.isConfigured.then((has) {
|
||||||
|
if (mounted) setState(() => _hasRemovalPin = has);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final printer = ref.watch(printerSettingsProvider);
|
||||||
|
if (!_loadedDrawerFields && printer.hasDrawer) {
|
||||||
|
_loadedDrawerFields = true;
|
||||||
|
_drawerHost.text = printer.drawerHost ?? '';
|
||||||
|
_drawerPort.text = '${printer.drawerPort}';
|
||||||
|
}
|
||||||
|
|
||||||
return ModulePage(
|
return ModulePage(
|
||||||
children: [
|
children: [
|
||||||
LayoutBuilder(
|
LayoutBuilder(
|
||||||
@@ -53,6 +96,8 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
|||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
_staffCard(store, user),
|
_staffCard(store, user),
|
||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
_removalPinCard(user),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
_aboutCard(),
|
_aboutCard(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -79,7 +124,10 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
|||||||
Widget _storeCard(StoreAccount? store) => PanelCard(
|
Widget _storeCard(StoreAccount? store) => PanelCard(
|
||||||
title: 'Store details',
|
title: 'Store details',
|
||||||
subtitle: 'Printed on every invoice',
|
subtitle: 'Printed on every invoice',
|
||||||
action: TextButton(onPressed: () {}, child: const Text('Edit')),
|
action: TextButton(
|
||||||
|
onPressed: () => showStoreDetailsDialog(context),
|
||||||
|
child: const Text('Edit'),
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -236,7 +284,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
|||||||
.read(receiptServiceProvider)
|
.read(receiptServiceProvider)
|
||||||
.printTestPage(
|
.printTestPage(
|
||||||
printerUrl: settings.printerUrl,);
|
printerUrl: settings.printerUrl,);
|
||||||
if (!context.mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context)
|
ScaffoldMessenger.of(context)
|
||||||
..hideCurrentSnackBar()
|
..hideCurrentSnackBar()
|
||||||
..showSnackBar(SnackBar(
|
..showSnackBar(SnackBar(
|
||||||
@@ -282,18 +330,102 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
|||||||
),
|
),
|
||||||
_toggle(
|
_toggle(
|
||||||
'Open cash drawer on cash sales',
|
'Open cash drawer on cash sales',
|
||||||
'Needs a raw ESC/POS link — see the notes in ReceiptService',
|
settings.hasDrawer
|
||||||
|
? 'Kicks the drawer on ${settings.drawerHost} after a cash '
|
||||||
|
'tender'
|
||||||
|
: 'Enter the printer\'s IP address below to enable this',
|
||||||
settings.openDrawer,
|
settings.openDrawer,
|
||||||
controller.setOpenDrawer,
|
settings.hasDrawer ? controller.setOpenDrawer : null,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
_drawerAddressField(settings, controller),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The drawer needs a socket, not the print driver.
|
||||||
|
///
|
||||||
|
/// A PDF is rendered by the platform driver, which will not pass raw ESC/POS
|
||||||
|
/// bytes through to the device — so the printer's own address is the only way
|
||||||
|
/// to reach the drawer wired to its RJ11 port.
|
||||||
|
Widget _drawerAddressField(
|
||||||
|
PrinterSettings settings,
|
||||||
|
PrinterSettingsController controller,
|
||||||
|
) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _drawerHost,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Printer IP address',
|
||||||
|
hintText: '192.168.1.50',
|
||||||
|
helperText: 'Leave blank for a USB printer',
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
SizedBox(
|
||||||
|
width: 84,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _drawerPort,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(labelText: 'Port', isDense: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: AppSpacing.xs),
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: _testingDrawer
|
||||||
|
? null
|
||||||
|
: () => _saveAndTestDrawer(controller),
|
||||||
|
child: _testingDrawer
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Test'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveAndTestDrawer(PrinterSettingsController controller) async {
|
||||||
|
setState(() => _testingDrawer = true);
|
||||||
|
|
||||||
|
final port = int.tryParse(_drawerPort.text.trim()) ?? 9100;
|
||||||
|
await controller.setDrawerAddress(_drawerHost.text, port);
|
||||||
|
|
||||||
|
final result = await ref.read(receiptServiceProvider).openCashDrawer(
|
||||||
|
host: _drawerHost.text,
|
||||||
|
port: port,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _testingDrawer = false);
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(SnackBar(
|
||||||
|
backgroundColor:
|
||||||
|
result.isSuccess ? AppColors.success : AppColors.danger,
|
||||||
|
content: Text(result.message),
|
||||||
|
),);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _staffCard(StoreAccount? store, StaffUser? current) => PanelCard(
|
Widget _staffCard(StoreAccount? store, StaffUser? current) => PanelCard(
|
||||||
title: 'Users & roles',
|
title: 'Users & roles',
|
||||||
action: TextButton(onPressed: () {}, child: const Text('Manage')),
|
action: TextButton(
|
||||||
|
onPressed: () => showStaffDialog(context),
|
||||||
|
child: const Text('Manage'),
|
||||||
|
),
|
||||||
child: ResponsiveTable(
|
child: ResponsiveTable(
|
||||||
stackBelow: 360,
|
stackBelow: 360,
|
||||||
columns: const [
|
columns: const [
|
||||||
@@ -305,24 +437,122 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
|||||||
.map((s) => [
|
.map((s) => [
|
||||||
Cell(s.name, bold: true),
|
Cell(s.name, bold: true),
|
||||||
Cell(s.role.label, color: AppColors.textSecondary),
|
Cell(s.role.label, color: AppColors.textSecondary),
|
||||||
s.id == current?.id
|
if (s.id == current?.id)
|
||||||
? const TagChip('Signed in',
|
const TagChip('Signed in', color: AppColors.success)
|
||||||
color: AppColors.success,)
|
else if (s.mustChangePin)
|
||||||
: const SizedBox.shrink(),
|
const TagChip('Default PIN', color: AppColors.warning)
|
||||||
|
else
|
||||||
|
const SizedBox.shrink(),
|
||||||
],)
|
],)
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// ------------------------------------------------------- Removal PIN
|
||||||
|
/// The PIN a cashier types to take a rung item back off a bill.
|
||||||
|
///
|
||||||
|
/// Admin-only, and deliberately not a staff PIN. A staff PIN identifies the
|
||||||
|
/// person a bill is stamped with; handing one out so the counter can void a
|
||||||
|
/// line would put the whole shift under the wrong name. This is a shared
|
||||||
|
/// secret for one specific action, and the admin's own staff PIN keeps
|
||||||
|
/// working whether or not it is set.
|
||||||
|
Widget _removalPinCard(StaffUser? user) {
|
||||||
|
final isAdmin = user?.role == StaffRole.admin;
|
||||||
|
|
||||||
|
return PanelCard(
|
||||||
|
title: 'Item removal PIN',
|
||||||
|
subtitle: 'Asked for when an item is taken off a bill',
|
||||||
|
action: isAdmin
|
||||||
|
? TextButton(
|
||||||
|
onPressed: () => _setRemovalPin(),
|
||||||
|
child: Text(_hasRemovalPin == true ? 'Change' : 'Set PIN'),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_row(
|
||||||
|
'Status',
|
||||||
|
_hasRemovalPin == null
|
||||||
|
? 'Checking…'
|
||||||
|
: (_hasRemovalPin! ? 'Set' : 'Not set'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
Text(
|
||||||
|
_hasRemovalPin == true
|
||||||
|
? 'A cashier can remove an item using this PIN. An admin PIN '
|
||||||
|
'still works too.'
|
||||||
|
: 'No PIN is set, so a removal currently needs an admin PIN. '
|
||||||
|
'Set one and a cashier can void a line without an admin '
|
||||||
|
'walking over.',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isAdmin) ...[
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
const Text(
|
||||||
|
'Only an admin can change it.',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppColors.textTertiary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setRemovalPin() async {
|
||||||
|
final pin = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => const _RemovalPinDialog(),
|
||||||
|
);
|
||||||
|
if (pin == null) return;
|
||||||
|
|
||||||
|
final store = ref.read(localStoreProvider);
|
||||||
|
try {
|
||||||
|
if (pin.isEmpty) {
|
||||||
|
await store.voidPin.clearPin();
|
||||||
|
} else {
|
||||||
|
await store.voidPin.setPin(pin);
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _hasRemovalPin = pin.isNotEmpty);
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(SnackBar(
|
||||||
|
content: Text(pin.isEmpty
|
||||||
|
? 'Removal PIN cleared. Removals now need an admin PIN.'
|
||||||
|
: 'Removal PIN saved.',),
|
||||||
|
),);
|
||||||
|
} on VoidPinException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(SnackBar(content: Text(e.message)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _connectivityCard() {
|
Widget _connectivityCard() {
|
||||||
final ready = ref.watch(catalogueReadyProvider);
|
final ready = ref.watch(catalogueReadyProvider);
|
||||||
final lastImport = ref.watch(lastImportAtProvider);
|
final lastImport = ref.watch(lastImportAtProvider);
|
||||||
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
|
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
|
||||||
|
final config = ref.watch(syncConfigProvider);
|
||||||
|
final terminal = ref.watch(terminalIdentityProvider);
|
||||||
|
final sync = ref.watch(syncEngineStateProvider).value ??
|
||||||
|
ref.watch(syncEngineProvider).state;
|
||||||
|
|
||||||
return PanelCard(
|
return PanelCard(
|
||||||
title: 'Connectivity & sync',
|
title: 'Connectivity & sync',
|
||||||
subtitle: 'This terminal only needs a connection to import the '
|
subtitle: 'Bills are written to this terminal first and uploaded in the '
|
||||||
'catalogue and to push the shift report.',
|
'background. Nothing is ever held up waiting for the network.',
|
||||||
|
action: TextButton(
|
||||||
|
onPressed: () => showBackOfficeDialog(context),
|
||||||
|
child: const Text('Configure'),
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -331,7 +561,27 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
|||||||
'Last import',
|
'Last import',
|
||||||
lastImport == null ? 'Never' : Formatters.dateTime(lastImport),
|
lastImport == null ? 'Never' : Formatters.dateTime(lastImport),
|
||||||
),
|
),
|
||||||
_row('Unsynced bills', '$outstanding'),
|
_row('Terminal', '${terminal.name} · ${terminal.code}'),
|
||||||
|
_row('Route', _transportLabel(config)),
|
||||||
|
_row('Waiting to upload', '$outstanding bill(s)'),
|
||||||
|
_row(
|
||||||
|
'Last upload',
|
||||||
|
sync.lastSuccessAt == null
|
||||||
|
? 'Never'
|
||||||
|
: Formatters.dateTime(sync.lastSuccessAt!),
|
||||||
|
),
|
||||||
|
if (sync.isHalted)
|
||||||
|
_row('Status', 'Halted — ${sync.lastError ?? 'refused'}')
|
||||||
|
else if (sync.nextAttemptAt != null)
|
||||||
|
_row(
|
||||||
|
'Next attempt',
|
||||||
|
'${Formatters.time(sync.nextAttemptAt!)} '
|
||||||
|
'(attempt ${sync.consecutiveFailures + 1})',
|
||||||
|
),
|
||||||
|
_row(
|
||||||
|
'Bills kept on device',
|
||||||
|
'${OrderDao.retentionWindow.inDays} days after upload',
|
||||||
|
),
|
||||||
_toggle(
|
_toggle(
|
||||||
'Simulate offline',
|
'Simulate offline',
|
||||||
'Forces import and sync to fail, so you can confirm nothing is '
|
'Forces import and sync to fail, so you can confirm nothing is '
|
||||||
@@ -340,6 +590,9 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
|||||||
ref.watch(simulateOfflineProvider),
|
ref.watch(simulateOfflineProvider),
|
||||||
(v) {
|
(v) {
|
||||||
ref.read(simulateOfflineProvider.notifier).state = v;
|
ref.read(simulateOfflineProvider.notifier).state = v;
|
||||||
|
// The pill and the drain both read connectivity, so they have to
|
||||||
|
// be told the switch moved.
|
||||||
|
ref.read(connectivityServiceProvider).refresh();
|
||||||
// Clear any stale failure banner left by the previous setting.
|
// Clear any stale failure banner left by the previous setting.
|
||||||
ref.read(catalogueImportProvider.notifier).reset();
|
ref.read(catalogueImportProvider.notifier).reset();
|
||||||
ref.read(orderSyncProvider.notifier).reset();
|
ref.read(orderSyncProvider.notifier).reset();
|
||||||
@@ -350,26 +603,44 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _aboutCard() => PanelCard(
|
String _transportLabel(SyncConfig config) => switch (config.transport) {
|
||||||
|
TransportKind.simulated =>
|
||||||
|
'Simulated — no back office configured for this terminal',
|
||||||
|
TransportKind.http => 'HTTP · ${config.httpBaseUrl}',
|
||||||
|
TransportKind.mqtt =>
|
||||||
|
'MQTT · ${config.brokerHost}:${config.brokerPort}'
|
||||||
|
'${config.useTls ? ' (TLS)' : ''}',
|
||||||
|
};
|
||||||
|
|
||||||
|
Widget _aboutCard() {
|
||||||
|
final terminal = ref.watch(terminalIdentityProvider);
|
||||||
|
|
||||||
|
return PanelCard(
|
||||||
title: 'About',
|
title: 'About',
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_row('Application', '${AppConstants.appName} 1.0.0'),
|
_row('Application',
|
||||||
_row('Terminal', 'TERM-01'),
|
'${AppConstants.appName} ${AppConstants.appVersion}',),
|
||||||
_row('Data store', 'SQLite (on device)'),
|
_row('Terminal', '${terminal.name} (${terminal.code})'),
|
||||||
|
// The identifier support asks for. Stable for the life of the
|
||||||
|
// device, and the only thing that ties this till to its history.
|
||||||
|
_row('Device ID', terminal.deviceId, mono: true),
|
||||||
|
_row('Store', terminal.storeId),
|
||||||
|
_row('Data store', 'SQLite (on device, WAL)'),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () {},
|
onPressed: () => showBackOfficeDialog(context),
|
||||||
icon: const Icon(Icons.sync_rounded, size: 17),
|
icon: const Icon(Icons.settings_ethernet_rounded, size: 17),
|
||||||
label: const Text('Check for updates'),
|
label: const Text('Back office connection'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _row(String label, String value, {bool mono = false}) => Padding(
|
Widget _row(String label, String value, {bool mono = false}) => Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
|
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
|
||||||
@@ -444,3 +715,96 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Four-to-eight digits, keyed on the same pad the till uses everywhere else.
|
||||||
|
class _RemovalPinDialog extends StatefulWidget {
|
||||||
|
const _RemovalPinDialog();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_RemovalPinDialog> createState() => _RemovalPinDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RemovalPinDialogState extends State<_RemovalPinDialog> {
|
||||||
|
String _pin = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Set removal PIN'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: (MediaQuery.sizeOf(context).width - 96).clamp(260.0, 340.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Four digits or more. Give it to whoever is on the counter — it '
|
||||||
|
'authorises removing an item from a bill and nothing else.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
SizedBox(
|
||||||
|
height: 22,
|
||||||
|
child: Center(
|
||||||
|
child: _pin.isEmpty
|
||||||
|
? const Text(
|
||||||
|
'Enter PIN',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < _pin.length; i++)
|
||||||
|
Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.primary,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
NumericKeypad(
|
||||||
|
onKey: (d) {
|
||||||
|
if (_pin.length >= 8) return;
|
||||||
|
setState(() => _pin += d);
|
||||||
|
},
|
||||||
|
onBackspace: () {
|
||||||
|
if (_pin.isEmpty) return;
|
||||||
|
setState(() => _pin = _pin.substring(0, _pin.length - 1));
|
||||||
|
},
|
||||||
|
onClear: () => setState(() => _pin = ''),
|
||||||
|
onSubmit: _pin.length >= 4
|
||||||
|
? () => Navigator.of(context).pop(_pin)
|
||||||
|
: null,
|
||||||
|
submitLabel: 'Save PIN',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(''),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: AppColors.danger),
|
||||||
|
child: const Text('Remove PIN'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
374
lib/presentation/modules/widgets/back_office_dialog.dart
Normal file
374
lib/presentation/modules/widgets/back_office_dialog.dart
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../app/providers.dart';
|
||||||
|
import '../../../core/config/sync_config.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/theme/app_dimens.dart';
|
||||||
|
import '../../../core/widgets/primary_button.dart';
|
||||||
|
|
||||||
|
/// Points this terminal at a back office, and names it.
|
||||||
|
///
|
||||||
|
/// Until this existed a store was wired up by editing `syncConfigProvider` and
|
||||||
|
/// rebuilding — which is not something a shop can do, and made every terminal
|
||||||
|
/// in a fleet a separate build.
|
||||||
|
Future<void> showBackOfficeDialog(BuildContext context) => showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => const _BackOfficeDialog(),
|
||||||
|
);
|
||||||
|
|
||||||
|
class _BackOfficeDialog extends ConsumerStatefulWidget {
|
||||||
|
const _BackOfficeDialog();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_BackOfficeDialog> createState() => _BackOfficeDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BackOfficeDialogState extends ConsumerState<_BackOfficeDialog> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
late TransportKind _kind;
|
||||||
|
late final TextEditingController _terminalName;
|
||||||
|
late final TextEditingController _storeId;
|
||||||
|
late final TextEditingController _host;
|
||||||
|
late final TextEditingController _port;
|
||||||
|
late final TextEditingController _username;
|
||||||
|
late final TextEditingController _password;
|
||||||
|
late final TextEditingController _httpUrl;
|
||||||
|
late final TextEditingController _apiKey;
|
||||||
|
late bool _useTls;
|
||||||
|
|
||||||
|
bool _saving = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final config = ref.read(syncConfigProvider);
|
||||||
|
final terminal = ref.read(terminalIdentityProvider);
|
||||||
|
|
||||||
|
_kind = config.transport;
|
||||||
|
_useTls = config.useTls;
|
||||||
|
_terminalName = TextEditingController(text: terminal.name);
|
||||||
|
_storeId = TextEditingController(text: terminal.storeId);
|
||||||
|
_host = TextEditingController(text: config.brokerHost);
|
||||||
|
_port = TextEditingController(text: '${config.brokerPort}');
|
||||||
|
_username = TextEditingController(text: config.username ?? '');
|
||||||
|
_password = TextEditingController(text: config.password ?? '');
|
||||||
|
_httpUrl = TextEditingController(text: config.httpBaseUrl);
|
||||||
|
_apiKey = TextEditingController(text: config.apiKey ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final c in [
|
||||||
|
_terminalName,
|
||||||
|
_storeId,
|
||||||
|
_host,
|
||||||
|
_port,
|
||||||
|
_username,
|
||||||
|
_password,
|
||||||
|
_httpUrl,
|
||||||
|
_apiKey,
|
||||||
|
]) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||||
|
setState(() => _saving = true);
|
||||||
|
|
||||||
|
final store = ref.read(localStoreProvider);
|
||||||
|
|
||||||
|
// Name and store id are the terminal's own, and live in its database — a
|
||||||
|
// reinstall must not lose which shop this till belongs to.
|
||||||
|
await store.identityStore.rename(
|
||||||
|
name: _terminalName.text.trim(),
|
||||||
|
storeId: _storeId.text.trim(),
|
||||||
|
);
|
||||||
|
await store.hydrate();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Deliberately left out of the identity store: credentials belong to the
|
||||||
|
// route, not to the machine, and re-pointing a terminal should not rewrite
|
||||||
|
// who it is.
|
||||||
|
final next = ref.read(syncConfigProvider).copyWith(
|
||||||
|
transport: _kind,
|
||||||
|
storeId: _storeId.text.trim(),
|
||||||
|
brokerHost: _host.text.trim(),
|
||||||
|
brokerPort: int.tryParse(_port.text.trim()) ?? 8883,
|
||||||
|
useTls: _useTls,
|
||||||
|
username: _username.text.trim().isEmpty ? null : _username.text.trim(),
|
||||||
|
password: _password.text.isEmpty ? null : _password.text,
|
||||||
|
httpBaseUrl: _httpUrl.text.trim(),
|
||||||
|
apiKey: _apiKey.text.trim().isEmpty ? null : _apiKey.text.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Non-secret settings to the database, credentials to the OS keystore.
|
||||||
|
// Held only in memory they had to be retyped after every restart, which on
|
||||||
|
// a shop-floor terminal means they end up on a sticky note instead.
|
||||||
|
await store.syncConfig.save(next);
|
||||||
|
ref.read(syncConfigProvider.notifier).state = next;
|
||||||
|
|
||||||
|
if (mounted) Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final terminal = ref.watch(terminalIdentityProvider);
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Back office connection'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 520,
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_IdentityBanner(code: terminal.code, deviceId: terminal.deviceId),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
|
||||||
|
TextFormField(
|
||||||
|
controller: _terminalName,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Terminal name',
|
||||||
|
helperText: 'What staff call this till, e.g. "Counter 2"',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
|
TextFormField(
|
||||||
|
controller: _storeId,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Store ID',
|
||||||
|
helperText: 'Namespaces this shop on the broker',
|
||||||
|
),
|
||||||
|
validator: (v) => (v == null || v.trim().isEmpty)
|
||||||
|
? 'Every terminal must belong to a store'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
|
||||||
|
SegmentedButton<TransportKind>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: TransportKind.simulated,
|
||||||
|
label: Text('Offline demo'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: TransportKind.http,
|
||||||
|
label: Text('HTTP'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: TransportKind.mqtt,
|
||||||
|
label: Text('MQTT'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {_kind},
|
||||||
|
onSelectionChanged: (s) => setState(() => _kind = s.first),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
|
||||||
|
if (_kind == TransportKind.mqtt) ..._mqttFields(),
|
||||||
|
if (_kind == TransportKind.http) ..._httpFields(),
|
||||||
|
if (_kind == TransportKind.simulated)
|
||||||
|
const _Note(
|
||||||
|
'Bills queue and drain against a local stub. Nothing '
|
||||||
|
'leaves this terminal — use it to rehearse a shift '
|
||||||
|
'without a server.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _saving ? null : () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
PrimaryButton(
|
||||||
|
label: 'Save',
|
||||||
|
expanded: false,
|
||||||
|
busy: _saving,
|
||||||
|
onPressed: _save,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _mqttFields() => [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _host,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Broker host',
|
||||||
|
hintText: 'nats.example.com',
|
||||||
|
),
|
||||||
|
validator: (v) => (v == null || v.trim().isEmpty)
|
||||||
|
? 'A broker host is required'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _port,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
decoration: const InputDecoration(labelText: 'Port'),
|
||||||
|
validator: (v) {
|
||||||
|
final port = int.tryParse(v?.trim() ?? '');
|
||||||
|
return (port == null || port < 1 || port > 65535)
|
||||||
|
? '1–65535'
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _username,
|
||||||
|
decoration: const InputDecoration(labelText: 'Username'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _password,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: const InputDecoration(labelText: 'Password'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
value: _useTls,
|
||||||
|
onChanged: (v) => setState(() => _useTls = v),
|
||||||
|
title: const Text('Use TLS'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Bills carry customer names and mobile numbers. Turn this off only '
|
||||||
|
'on a closed network you control.',
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const _Note(
|
||||||
|
'Works against NATS with its MQTT gateway enabled, or any MQTT 3.1.1 '
|
||||||
|
'broker. Topics arrive as NATS subjects with "/" replaced by "." — '
|
||||||
|
'see docs/sync-contract.md.',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
List<Widget> _httpFields() => [
|
||||||
|
TextFormField(
|
||||||
|
controller: _httpUrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Base URL',
|
||||||
|
hintText: 'https://api.example.com',
|
||||||
|
helperText: 'Bills are posted to {base}/orders',
|
||||||
|
),
|
||||||
|
validator: (v) {
|
||||||
|
final text = v?.trim() ?? '';
|
||||||
|
if (text.isEmpty) return 'A base URL is required';
|
||||||
|
final uri = Uri.tryParse(text);
|
||||||
|
if (uri == null || !uri.isAbsolute) return 'Not a valid URL';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
TextFormField(
|
||||||
|
controller: _apiKey,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'API key',
|
||||||
|
helperText: 'Sent as a bearer token',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two identifiers a support call needs, and neither is editable.
|
||||||
|
class _IdentityBanner extends StatelessWidget {
|
||||||
|
const _IdentityBanner({required this.code, required this.deviceId});
|
||||||
|
|
||||||
|
final String code;
|
||||||
|
final String deviceId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.primarySurface,
|
||||||
|
borderRadius: AppRadius.brSm,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.point_of_sale_rounded,
|
||||||
|
size: 18, color: AppColors.primary,),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Terminal $code',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Device $deviceId',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Copy device ID',
|
||||||
|
icon: const Icon(Icons.copy_rounded, size: 16),
|
||||||
|
onPressed: () => Clipboard.setData(ClipboardData(text: deviceId)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Note extends StatelessWidget {
|
||||||
|
const _Note(this.text);
|
||||||
|
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Padding(
|
||||||
|
padding: const EdgeInsets.only(top: AppSpacing.sm),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
height: 1.45,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,12 @@ class ModulePage extends StatelessWidget {
|
|||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: EdgeInsets.all(padding),
|
padding: EdgeInsets.all(padding),
|
||||||
child: Column(
|
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,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: children,
|
children: children,
|
||||||
),
|
),
|
||||||
|
|||||||
464
lib/presentation/modules/widgets/promo_editor_dialog.dart
Normal file
464
lib/presentation/modules/widgets/promo_editor_dialog.dart
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../app/providers.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/theme/app_dimens.dart';
|
||||||
|
import '../../../core/widgets/primary_button.dart';
|
||||||
|
import '../../../data/local/promo_dao.dart';
|
||||||
|
import '../../../domain/entities/product.dart';
|
||||||
|
import '../../../domain/entities/promo.dart';
|
||||||
|
import '../../pos/providers/catalog_providers.dart';
|
||||||
|
|
||||||
|
/// Creates or edits a campaign.
|
||||||
|
Future<void> showPromoEditor(BuildContext context, {Promo? existing}) =>
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => _PromoEditor(existing: existing),
|
||||||
|
);
|
||||||
|
|
||||||
|
class _PromoEditor extends ConsumerStatefulWidget {
|
||||||
|
const _PromoEditor({this.existing});
|
||||||
|
|
||||||
|
final Promo? existing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_PromoEditor> createState() => _PromoEditorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PromoEditorState extends ConsumerState<_PromoEditor> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
late final TextEditingController _name;
|
||||||
|
late final TextEditingController _value;
|
||||||
|
late final TextEditingController _minBill;
|
||||||
|
late final TextEditingController _maxDiscount;
|
||||||
|
late final TextEditingController _buy;
|
||||||
|
late final TextEditingController _free;
|
||||||
|
|
||||||
|
late PromoType _type;
|
||||||
|
String? _targetId;
|
||||||
|
String? _targetLabel;
|
||||||
|
DateTime? _from;
|
||||||
|
DateTime? _to;
|
||||||
|
late Set<int> _days;
|
||||||
|
late bool _stackable;
|
||||||
|
|
||||||
|
bool _saving = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
bool get _isNew => widget.existing == null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final p = widget.existing;
|
||||||
|
|
||||||
|
_name = TextEditingController(text: p?.name ?? '');
|
||||||
|
_value = TextEditingController(text: p == null ? '' : _num(p.value));
|
||||||
|
_minBill = TextEditingController(
|
||||||
|
text: (p == null || p.minBillValue == 0) ? '' : _num(p.minBillValue),
|
||||||
|
);
|
||||||
|
_maxDiscount = TextEditingController(
|
||||||
|
text: p?.maxDiscount == null ? '' : _num(p!.maxDiscount!),
|
||||||
|
);
|
||||||
|
_buy = TextEditingController(text: '${p?.buyQuantity ?? 2}');
|
||||||
|
_free = TextEditingController(text: '${p?.freeQuantity ?? 1}');
|
||||||
|
|
||||||
|
_type = p?.type ?? PromoType.percentOffBill;
|
||||||
|
_targetId = p?.targetId;
|
||||||
|
_targetLabel = p?.targetLabel;
|
||||||
|
_from = p?.validFrom;
|
||||||
|
_to = p?.validTo;
|
||||||
|
_days = {...?p?.daysOfWeek};
|
||||||
|
_stackable = p?.stackable ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _num(double v) =>
|
||||||
|
v == v.roundToDouble() ? v.toStringAsFixed(0) : v.toStringAsFixed(2);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final c in [_name, _value, _minBill, _maxDiscount, _buy, _free]) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||||
|
|
||||||
|
if (_type.needsTarget && (_targetId ?? '').isEmpty) {
|
||||||
|
setState(() => _error = 'Choose what this campaign applies to.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_saving = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final promo = Promo(
|
||||||
|
id: widget.existing?.id ?? '',
|
||||||
|
name: _name.text,
|
||||||
|
type: _type,
|
||||||
|
value: double.tryParse(_value.text.trim()) ?? 0,
|
||||||
|
targetId: _targetId,
|
||||||
|
targetLabel: _targetLabel,
|
||||||
|
buyQuantity: int.tryParse(_buy.text.trim()) ?? 0,
|
||||||
|
freeQuantity: int.tryParse(_free.text.trim()) ?? 0,
|
||||||
|
minBillValue: double.tryParse(_minBill.text.trim()) ?? 0,
|
||||||
|
maxDiscount: double.tryParse(_maxDiscount.text.trim()),
|
||||||
|
validFrom: _from,
|
||||||
|
validTo: _to,
|
||||||
|
daysOfWeek: _days,
|
||||||
|
stackable: _stackable,
|
||||||
|
priority: widget.existing?.priority ?? 100,
|
||||||
|
isActive: widget.existing?.isActive ?? true,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ref.read(localStoreProvider).promos.save(promo);
|
||||||
|
ref
|
||||||
|
..invalidate(promosProvider)
|
||||||
|
..invalidate(activePromosProvider);
|
||||||
|
if (mounted) Navigator.of(context).pop();
|
||||||
|
} on PromoException catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_saving = false;
|
||||||
|
_error = e.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(_isNew ? 'New campaign' : 'Edit campaign'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 540,
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _name,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Campaign name',
|
||||||
|
hintText: 'Weekend Saver',
|
||||||
|
helperText: 'Shown on the bill when it applies',
|
||||||
|
),
|
||||||
|
validator: (v) => (v == null || v.trim().isEmpty)
|
||||||
|
? 'A campaign needs a name'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
|
DropdownButtonFormField<PromoType>(
|
||||||
|
initialValue: _type,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: const InputDecoration(labelText: 'What it does'),
|
||||||
|
items: [
|
||||||
|
for (final type in PromoType.values)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: type,
|
||||||
|
child: Text(type.label, overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (v) => setState(() {
|
||||||
|
_type = v ?? _type;
|
||||||
|
// The old target is meaningless under a different type — a
|
||||||
|
// category id on a product promo would silently never fire.
|
||||||
|
_targetId = null;
|
||||||
|
_targetLabel = null;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
|
if (_type.needsTarget) ...[
|
||||||
|
_targetField(),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (_type == PromoType.buyXGetY)
|
||||||
|
_buyGetFields()
|
||||||
|
else
|
||||||
|
_valueField(),
|
||||||
|
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _minBill,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Minimum bill',
|
||||||
|
hintText: '0',
|
||||||
|
prefixText: '₹ ',
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _maxDiscount,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Cap the discount',
|
||||||
|
hintText: 'No cap',
|
||||||
|
prefixText: '₹ ',
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_type.isPercentage)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: AppSpacing.xs),
|
||||||
|
child: Text(
|
||||||
|
'A cap is worth setting on a percentage: without one, an '
|
||||||
|
'unusually large trolley gives away more than the '
|
||||||
|
'campaign was costed for.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
_dateRange(),
|
||||||
|
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
_dayPicker(),
|
||||||
|
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
value: _stackable,
|
||||||
|
onChanged: (v) => setState(() => _stackable = v),
|
||||||
|
title: const Text('Can combine with other campaigns'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Off by default. Only the best non-combining campaign '
|
||||||
|
'applies to a bill — two percentages compounding produce a '
|
||||||
|
'discount nobody costed.',
|
||||||
|
style: TextStyle(fontSize: 11.5, height: 1.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.danger,
|
||||||
|
fontSize: 12.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _saving ? null : () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
PrimaryButton(
|
||||||
|
label: _isNew ? 'Create' : 'Save',
|
||||||
|
expanded: false,
|
||||||
|
busy: _saving,
|
||||||
|
onPressed: _save,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _valueField() => TextFormField(
|
||||||
|
controller: _value,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: _type.isPercentage ? 'Percentage off' : 'Amount off',
|
||||||
|
suffixText: _type.isPercentage ? '%' : null,
|
||||||
|
prefixText: _type.isPercentage ? null : '₹ ',
|
||||||
|
),
|
||||||
|
validator: (v) {
|
||||||
|
final parsed = double.tryParse((v ?? '').trim());
|
||||||
|
if (parsed == null || parsed <= 0) {
|
||||||
|
return 'A campaign must give something away';
|
||||||
|
}
|
||||||
|
if (_type.isPercentage && parsed > 100) {
|
||||||
|
// Over 100% is a refund with extra steps.
|
||||||
|
return 'A percentage cannot exceed 100';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _buyGetFields() => Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _buy,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
decoration: const InputDecoration(labelText: 'Buy', isDense: true),
|
||||||
|
validator: (v) => (int.tryParse(v ?? '') ?? 0) < 1
|
||||||
|
? 'At least one'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _free,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
decoration:
|
||||||
|
const InputDecoration(labelText: 'Get free', isDense: true),
|
||||||
|
validator: (v) => (int.tryParse(v ?? '') ?? 0) < 1
|
||||||
|
? 'At least one'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _targetField() {
|
||||||
|
if (_type == PromoType.percentOffCategory) {
|
||||||
|
return DropdownButtonFormField<String>(
|
||||||
|
initialValue: _targetId,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: const InputDecoration(labelText: 'Category'),
|
||||||
|
items: [
|
||||||
|
for (final category in ProductCategory.values)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: category.name,
|
||||||
|
child: Text('${category.emoji} ${category.label}'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (v) => setState(() {
|
||||||
|
_targetId = v;
|
||||||
|
_targetLabel = ProductCategory.values
|
||||||
|
.where((c) => c.name == v)
|
||||||
|
.map((c) => c.label)
|
||||||
|
.firstOrNull;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Product picker, for both the product promo and buy-X-get-Y.
|
||||||
|
final products = ref.watch(allProductsProvider).value ?? const <Product>[];
|
||||||
|
|
||||||
|
return DropdownButtonFormField<String>(
|
||||||
|
initialValue:
|
||||||
|
products.any((p) => p.id == _targetId) ? _targetId : null,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Product',
|
||||||
|
helperText: products.isEmpty
|
||||||
|
? 'Import the catalogue first — there is nothing to pick'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
for (final product in products)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: product.id,
|
||||||
|
child: Text(product.name, overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (v) => setState(() {
|
||||||
|
_targetId = v;
|
||||||
|
_targetLabel =
|
||||||
|
products.where((p) => p.id == v).map((p) => p.name).firstOrNull;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _dateRange() => Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _dateButton('Starts', _from, (d) => _from = d)),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(child: _dateButton('Ends', _to, (d) => _to = d)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _dateButton(String label, DateTime? value, void Function(DateTime?) set) {
|
||||||
|
return OutlinedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: value ?? DateTime.now(),
|
||||||
|
firstDate: DateTime(2024),
|
||||||
|
lastDate: DateTime(2100),
|
||||||
|
);
|
||||||
|
if (picked != null) setState(() => set(picked));
|
||||||
|
},
|
||||||
|
onLongPress: () => setState(() => set(null)),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
value == null
|
||||||
|
? 'Any time'
|
||||||
|
: '${value.day}/${value.month}/${value.year}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _dayPicker() {
|
||||||
|
const names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Days it runs',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.xs),
|
||||||
|
Wrap(
|
||||||
|
spacing: AppSpacing.xs,
|
||||||
|
children: [
|
||||||
|
for (var day = 1; day <= 7; day++)
|
||||||
|
FilterChip(
|
||||||
|
label: Text(names[day - 1]),
|
||||||
|
selected: _days.contains(day),
|
||||||
|
onSelected: (on) => setState(() {
|
||||||
|
on ? _days.add(day) : _days.remove(day);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: AppSpacing.xs),
|
||||||
|
child: Text(
|
||||||
|
'Pick none to run every day.',
|
||||||
|
style: TextStyle(fontSize: 11.5, color: AppColors.textTertiary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
538
lib/presentation/modules/widgets/staff_dialogs.dart
Normal file
538
lib/presentation/modules/widgets/staff_dialogs.dart
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../app/providers.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/theme/app_dimens.dart';
|
||||||
|
import '../../../core/widgets/primary_button.dart';
|
||||||
|
import '../../../data/local/staff_dao.dart';
|
||||||
|
import '../../../domain/entities/store_account.dart';
|
||||||
|
import '../../auth/providers/auth_controller.dart';
|
||||||
|
|
||||||
|
/// Manage who can sign in at this till.
|
||||||
|
Future<void> showStaffDialog(BuildContext context) => showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => const _StaffDialog(),
|
||||||
|
);
|
||||||
|
|
||||||
|
class _StaffDialog extends ConsumerWidget {
|
||||||
|
const _StaffDialog();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final me = ref.watch(currentUserProvider);
|
||||||
|
final storeAsync = ref.watch(storeAccountProvider);
|
||||||
|
|
||||||
|
// Only an admin can change who works here, or what they may do.
|
||||||
|
if (me?.role != StaffRole.admin) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Users & roles'),
|
||||||
|
content: const Text(
|
||||||
|
'Only an admin can add or change staff accounts. Ask a manager to '
|
||||||
|
'sign in first.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Users & roles'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 560,
|
||||||
|
child: storeAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => Text('Could not load staff: $e'),
|
||||||
|
data: (store) => SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
for (final user in store.staff)
|
||||||
|
_StaffRow(user: user, isMe: user.id == me?.id),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => _openEditor(context, ref, null),
|
||||||
|
icon: const Icon(Icons.person_add_alt_1_rounded, size: 17),
|
||||||
|
label: const Text('Add staff member'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Done'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StaffRow extends ConsumerWidget {
|
||||||
|
const _StaffRow({required this.user, required this.isMe});
|
||||||
|
|
||||||
|
final StaffUser user;
|
||||||
|
final bool isMe;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 4,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
user.name,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
user.role.label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (user.mustChangePin)
|
||||||
|
const Tooltip(
|
||||||
|
message: 'Still on the PIN this terminal was set up with',
|
||||||
|
child: Icon(Icons.warning_amber_rounded,
|
||||||
|
size: 17, color: AppColors.warning,),
|
||||||
|
),
|
||||||
|
if (isMe)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: AppSpacing.sm),
|
||||||
|
child: Text(
|
||||||
|
'Signed in',
|
||||||
|
style: TextStyle(fontSize: 11, color: AppColors.success),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Edit',
|
||||||
|
icon: const Icon(Icons.edit_outlined, size: 17),
|
||||||
|
onPressed: () => _openEditor(context, ref, user),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Remove',
|
||||||
|
icon: const Icon(Icons.person_off_outlined, size: 17),
|
||||||
|
color: AppColors.danger,
|
||||||
|
onPressed: () => _confirmDeactivate(context, ref, user),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDeactivate(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
StaffUser user,
|
||||||
|
) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: Text('Remove ${user.name}?'),
|
||||||
|
content: const Text(
|
||||||
|
'They will no longer be able to sign in. Bills they have already rung '
|
||||||
|
'keep their name, so shift reports stay correct.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: AppColors.danger),
|
||||||
|
child: const Text('Remove'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !context.mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ref.read(localStoreProvider).staff.deactivate(user.id);
|
||||||
|
await ref.read(authControllerProvider.notifier).refreshStore();
|
||||||
|
} on StaffException catch (e) {
|
||||||
|
if (context.mounted) _showError(context, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openEditor(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
StaffUser? existing,
|
||||||
|
) =>
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => _StaffEditor(existing: existing),
|
||||||
|
);
|
||||||
|
|
||||||
|
void _showError(BuildContext context, String message) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(SnackBar(
|
||||||
|
backgroundColor: AppColors.danger,
|
||||||
|
content: Text(message),
|
||||||
|
),);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a staff member, or change one's name, role or PIN.
|
||||||
|
class _StaffEditor extends ConsumerStatefulWidget {
|
||||||
|
const _StaffEditor({this.existing});
|
||||||
|
|
||||||
|
final StaffUser? existing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_StaffEditor> createState() => _StaffEditorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StaffEditorState extends ConsumerState<_StaffEditor> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
late final TextEditingController _name;
|
||||||
|
final _pin = TextEditingController();
|
||||||
|
final _confirmPin = TextEditingController();
|
||||||
|
|
||||||
|
late StaffRole _role;
|
||||||
|
bool _saving = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
bool get _isNew => widget.existing == null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_name = TextEditingController(text: widget.existing?.name ?? '');
|
||||||
|
_role = widget.existing?.role ?? StaffRole.cashier;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_name.dispose();
|
||||||
|
_pin.dispose();
|
||||||
|
_confirmPin.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||||
|
setState(() {
|
||||||
|
_saving = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final staff = ref.read(localStoreProvider).staff;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_isNew) {
|
||||||
|
await staff.create(
|
||||||
|
name: _name.text,
|
||||||
|
role: _role,
|
||||||
|
pin: _pin.text,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await staff.updateDetails(
|
||||||
|
id: widget.existing!.id,
|
||||||
|
name: _name.text,
|
||||||
|
role: _role,
|
||||||
|
);
|
||||||
|
// Blank means "leave it alone" — an admin editing a role should not be
|
||||||
|
// forced to know or reset someone's PIN.
|
||||||
|
if (_pin.text.isNotEmpty) {
|
||||||
|
await staff.setPin(
|
||||||
|
widget.existing!.id,
|
||||||
|
_pin.text,
|
||||||
|
// An admin setting someone else's PIN is a reset, so the person is
|
||||||
|
// asked to choose their own at next sign-in.
|
||||||
|
mustChangePin: widget.existing!.id !=
|
||||||
|
ref.read(currentUserProvider)?.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ref.read(authControllerProvider.notifier).refreshStore();
|
||||||
|
if (mounted) Navigator.of(context).pop();
|
||||||
|
} on StaffException catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_saving = false;
|
||||||
|
_error = e.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(_isNew ? 'Add staff member' : 'Edit ${widget.existing!.name}'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 420,
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _name,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: const InputDecoration(labelText: 'Name'),
|
||||||
|
validator: (v) => (v == null || v.trim().isEmpty)
|
||||||
|
? 'A staff member needs a name'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
|
DropdownButtonFormField<StaffRole>(
|
||||||
|
initialValue: _role,
|
||||||
|
// Without this the item is laid out at its natural width and
|
||||||
|
// "Manager — Sales, inventory and reports" runs 222px past the
|
||||||
|
// edge of the dialog.
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: const InputDecoration(labelText: 'Role'),
|
||||||
|
items: [
|
||||||
|
for (final role in StaffRole.values)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: role,
|
||||||
|
child: Text(
|
||||||
|
'${role.label} — ${role.description}',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (v) => setState(() => _role = v ?? _role),
|
||||||
|
),
|
||||||
|
// Spelled out below rather than squeezed into the dropdown, so
|
||||||
|
// the permissions being granted are actually readable.
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: AppSpacing.xs),
|
||||||
|
child: Text(
|
||||||
|
_role.description,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
|
TextFormField(
|
||||||
|
controller: _pin,
|
||||||
|
obscureText: true,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(6),
|
||||||
|
],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: _isNew ? 'PIN' : 'New PIN',
|
||||||
|
helperText: _isNew
|
||||||
|
? 'At least four digits, and not guessable across a '
|
||||||
|
'counter'
|
||||||
|
: 'Leave blank to keep the current PIN',
|
||||||
|
),
|
||||||
|
validator: (v) {
|
||||||
|
final text = v ?? '';
|
||||||
|
if (_isNew && text.isEmpty) return 'A PIN is required';
|
||||||
|
if (text.isNotEmpty && text.length < 4) {
|
||||||
|
return 'At least four digits';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
|
TextFormField(
|
||||||
|
controller: _confirmPin,
|
||||||
|
obscureText: true,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
decoration: const InputDecoration(labelText: 'Confirm PIN'),
|
||||||
|
validator: (v) {
|
||||||
|
// A mistyped PIN nobody can verify locks the account out —
|
||||||
|
// there is no email to reset it with.
|
||||||
|
if (_pin.text.isEmpty) return null;
|
||||||
|
return v != _pin.text ? 'The two PINs do not match' : null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.danger,
|
||||||
|
fontSize: 12.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _saving ? null : () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
PrimaryButton(
|
||||||
|
label: _isNew ? 'Add' : 'Save',
|
||||||
|
expanded: false,
|
||||||
|
busy: _saving,
|
||||||
|
onPressed: _save,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forces someone off a PIN they did not choose.
|
||||||
|
///
|
||||||
|
/// Shown after sign-in while [StaffUser.mustChangePin] is set. Not dismissable:
|
||||||
|
/// the seeded PINs are in the source of an open-source build, so a shop still
|
||||||
|
/// running one is effectively unprotected.
|
||||||
|
Future<void> showForcedPinChange(BuildContext context) => showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (_) => const _ForcedPinChange(),
|
||||||
|
);
|
||||||
|
|
||||||
|
class _ForcedPinChange extends ConsumerStatefulWidget {
|
||||||
|
const _ForcedPinChange();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_ForcedPinChange> createState() => _ForcedPinChangeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ForcedPinChangeState extends ConsumerState<_ForcedPinChange> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _pin = TextEditingController();
|
||||||
|
final _confirm = TextEditingController();
|
||||||
|
bool _saving = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pin.dispose();
|
||||||
|
_confirm.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||||
|
setState(() {
|
||||||
|
_saving = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final me = ref.read(currentUserProvider);
|
||||||
|
if (me == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ref.read(localStoreProvider).staff.setPin(me.id, _pin.text);
|
||||||
|
await ref.read(authControllerProvider.notifier).refreshStore();
|
||||||
|
if (mounted) Navigator.of(context).pop();
|
||||||
|
} on StaffException catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_saving = false;
|
||||||
|
_error = e.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final me = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
|
return PopScope(
|
||||||
|
canPop: false,
|
||||||
|
child: AlertDialog(
|
||||||
|
title: const Text('Choose your PIN'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 420,
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${me?.name ?? 'This account'} is still using the PIN this '
|
||||||
|
'terminal was set up with. Those are the same on every new '
|
||||||
|
'install, so please pick your own before ringing a sale.',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.45,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
|
||||||
|
TextFormField(
|
||||||
|
controller: _pin,
|
||||||
|
autofocus: true,
|
||||||
|
obscureText: true,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(6),
|
||||||
|
],
|
||||||
|
decoration: const InputDecoration(labelText: 'New PIN'),
|
||||||
|
validator: (v) => (v == null || v.length < 4)
|
||||||
|
? 'At least four digits'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
|
TextFormField(
|
||||||
|
controller: _confirm,
|
||||||
|
obscureText: true,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
decoration: const InputDecoration(labelText: 'Confirm PIN'),
|
||||||
|
validator: (v) =>
|
||||||
|
v != _pin.text ? 'The two PINs do not match' : null,
|
||||||
|
),
|
||||||
|
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.danger,
|
||||||
|
fontSize: 12.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
PrimaryButton(
|
||||||
|
label: 'Set PIN',
|
||||||
|
expanded: false,
|
||||||
|
busy: _saving,
|
||||||
|
onPressed: _save,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
180
lib/presentation/modules/widgets/store_details_dialog.dart
Normal file
180
lib/presentation/modules/widgets/store_details_dialog.dart
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../app/providers.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/theme/app_dimens.dart';
|
||||||
|
import '../../../core/widgets/primary_button.dart';
|
||||||
|
import '../../../data/repositories/store_repository_impl.dart';
|
||||||
|
import '../../../domain/entities/store_account.dart';
|
||||||
|
import '../../auth/providers/auth_controller.dart';
|
||||||
|
|
||||||
|
/// Edits what gets printed at the top of every invoice.
|
||||||
|
///
|
||||||
|
/// These were compile-time constants, so a shop could only correct its own
|
||||||
|
/// address or GSTIN by having the app rebuilt — on a GST invoice those fields
|
||||||
|
/// are a legal requirement, not decoration.
|
||||||
|
Future<void> showStoreDetailsDialog(BuildContext context) => showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => const _StoreDetailsDialog(),
|
||||||
|
);
|
||||||
|
|
||||||
|
class _StoreDetailsDialog extends ConsumerStatefulWidget {
|
||||||
|
const _StoreDetailsDialog();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_StoreDetailsDialog> createState() =>
|
||||||
|
_StoreDetailsDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StoreDetailsDialogState extends ConsumerState<_StoreDetailsDialog> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
late final TextEditingController _name;
|
||||||
|
late final TextEditingController _address;
|
||||||
|
late final TextEditingController _gstin;
|
||||||
|
late final TextEditingController _phone;
|
||||||
|
|
||||||
|
bool _saving = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final store = ref.read(currentStoreProvider);
|
||||||
|
_name = TextEditingController(text: store?.name ?? '');
|
||||||
|
_address = TextEditingController(text: store?.address ?? '');
|
||||||
|
_gstin = TextEditingController(text: store?.gstin ?? '');
|
||||||
|
_phone = TextEditingController(text: store?.phone ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final c in [_name, _address, _gstin, _phone]) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||||
|
setState(() => _saving = true);
|
||||||
|
|
||||||
|
await ref.read(storeRepositoryProvider).save(
|
||||||
|
name: _name.text,
|
||||||
|
address: _address.text,
|
||||||
|
gstin: _gstin.text,
|
||||||
|
phone: _phone.text,
|
||||||
|
);
|
||||||
|
|
||||||
|
await ref.read(authControllerProvider.notifier).refreshStore();
|
||||||
|
if (mounted) Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final me = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
|
// Changing the GSTIN on a live till changes what every future invoice
|
||||||
|
// claims about who collected the tax.
|
||||||
|
if (me?.role != StaffRole.admin) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Store details'),
|
||||||
|
content: const Text(
|
||||||
|
'Only an admin can change the details printed on invoices.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Store details'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 480,
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Printed at the top of every invoice.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
|
||||||
|
TextFormField(
|
||||||
|
controller: _name,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: const InputDecoration(labelText: 'Store name'),
|
||||||
|
validator: (v) => (v == null || v.trim().isEmpty)
|
||||||
|
? 'An invoice must name the seller'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
|
TextFormField(
|
||||||
|
controller: _address,
|
||||||
|
maxLines: 2,
|
||||||
|
decoration: const InputDecoration(labelText: 'Address'),
|
||||||
|
validator: (v) => (v == null || v.trim().isEmpty)
|
||||||
|
? 'An invoice must carry the place of supply'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
|
TextFormField(
|
||||||
|
controller: _gstin,
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
inputFormatters: [
|
||||||
|
LengthLimitingTextInputFormatter(15),
|
||||||
|
FilteringTextInputFormatter.allow(RegExp('[0-9a-zA-Z]')),
|
||||||
|
],
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'GSTIN',
|
||||||
|
hintText: '33AABCU9603R1ZM',
|
||||||
|
),
|
||||||
|
validator: GstinValidator.validate,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
|
TextFormField(
|
||||||
|
controller: _phone,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(10),
|
||||||
|
],
|
||||||
|
decoration: const InputDecoration(labelText: 'Phone'),
|
||||||
|
validator: (v) => (v == null || v.trim().length != 10)
|
||||||
|
? 'Ten digits'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _saving ? null : () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
PrimaryButton(
|
||||||
|
label: 'Save',
|
||||||
|
expanded: false,
|
||||||
|
busy: _saving,
|
||||||
|
onPressed: _save,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import '../../../domain/entities/transaction.dart';
|
|||||||
import '../../../domain/usecases/checkout_sale.dart';
|
import '../../../domain/usecases/checkout_sale.dart';
|
||||||
import '../../auth/providers/auth_controller.dart';
|
import '../../auth/providers/auth_controller.dart';
|
||||||
import '../../pos/providers/cart_controller.dart';
|
import '../../pos/providers/cart_controller.dart';
|
||||||
|
import '../../modules/providers/printer_settings.dart';
|
||||||
import '../../pos/providers/catalog_providers.dart';
|
import '../../pos/providers/catalog_providers.dart';
|
||||||
import '../../sync/providers/sync_controller.dart';
|
import '../../sync/providers/sync_controller.dart';
|
||||||
|
|
||||||
@@ -78,29 +79,27 @@ class PaymentController extends StateNotifier<PaymentState> {
|
|||||||
|
|
||||||
/// Whether Complete Sale should be enabled.
|
/// Whether Complete Sale should be enabled.
|
||||||
///
|
///
|
||||||
/// Cash is the strict case: the drawer cannot be reconciled and no change can
|
/// Every method now requires the cashier to state what was actually
|
||||||
/// be calculated unless the cashier states what was handed over. Card, UPI
|
/// received before the sale can complete — a tap on Exact for the common
|
||||||
/// and wallet settle on the external terminal, so they need no amount here.
|
/// 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 {
|
bool get canConfirm {
|
||||||
if (_billTotal <= 0) return false;
|
if (_billTotal <= 0) return false;
|
||||||
|
|
||||||
// Staged splits already cover the bill.
|
// Staged splits already cover the bill.
|
||||||
if (balanceDue <= 0.01) return true;
|
if (balanceDue <= 0.01) return true;
|
||||||
|
|
||||||
if (state.activeMethod.needsChange) {
|
|
||||||
return state.cashTendered >= balanceDue;
|
return state.cashTendered >= balanceDue;
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Why the button is disabled, for display next to it.
|
/// Why the button is disabled, for display next to it.
|
||||||
String? get blockedReason {
|
String? get blockedReason {
|
||||||
if (_billTotal <= 0) return 'Add at least one item before charging.';
|
if (_billTotal <= 0) return 'Add at least one item before charging.';
|
||||||
if (canConfirm) return null;
|
if (canConfirm) return null;
|
||||||
if (state.activeMethod.needsChange) {
|
return state.activeMethod.needsChange
|
||||||
return 'Enter the cash received, or tap Exact.';
|
? 'Enter the cash received, or tap Exact.'
|
||||||
}
|
: 'Enter the amount received, or tap Exact.';
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void selectMethod(PaymentMethod method) {
|
void selectMethod(PaymentMethod method) {
|
||||||
@@ -190,7 +189,18 @@ class PaymentController extends StateNotifier<PaymentState> {
|
|||||||
// No auto-print: with no roll printer attached this silently failed and
|
// No auto-print: with no roll printer attached this silently failed and
|
||||||
// looked like a bug. The receipt screen shows the bill on the terminal
|
// looked like a bug. The receipt screen shows the bill on the terminal
|
||||||
// and offers Print, WhatsApp and Share explicitly.
|
// and offers Print, WhatsApp and Share explicitly.
|
||||||
unawaited(_ref.read(receiptServiceProvider).openCashDrawer());
|
// Only for cash. A card-only sale that pops the drawer is a shrinkage
|
||||||
|
// risk, and it is what a shop notices first.
|
||||||
|
final printer = _ref.read(printerSettingsProvider);
|
||||||
|
final tookCash = splits.any((p) => p.method == PaymentMethod.cash);
|
||||||
|
if (printer.openDrawer && printer.hasDrawer && tookCash) {
|
||||||
|
unawaited(
|
||||||
|
_ref.read(receiptServiceProvider).openCashDrawer(
|
||||||
|
host: printer.drawerHost,
|
||||||
|
port: printer.drawerPort,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
unawaited(_ref.read(soundServiceProvider).saleComplete());
|
unawaited(_ref.read(soundServiceProvider).saleComplete());
|
||||||
|
|
||||||
// Stock changed, so the grid must refresh; the new order changes the
|
// Stock changed, so the grid must refresh; the new order changes the
|
||||||
@@ -199,6 +209,12 @@ class PaymentController extends StateNotifier<PaymentState> {
|
|||||||
_ref.invalidate(visibleProductsProvider);
|
_ref.invalidate(visibleProductsProvider);
|
||||||
_ref.read(orderVersionProvider.notifier).state++;
|
_ref.read(orderVersionProvider.notifier).state++;
|
||||||
|
|
||||||
|
// Deliberately NOT nudging the sync engine here. The bill sits on this
|
||||||
|
// terminal, unsynced, until the receipt screen's cancellation window
|
||||||
|
// closes — either it is pressed past early with New Sale, or the
|
||||||
|
// window runs out — or the sale is voided if the cashier cancels
|
||||||
|
// instead. See ReceiptScreen.
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} on CheckoutFailure catch (e) {
|
} on CheckoutFailure catch (e) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_animate/flutter_animate.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../app/providers.dart';
|
||||||
import '../../../core/router/app_router.dart';
|
import '../../../core/router/app_router.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.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/glass_card.dart';
|
||||||
import '../../../core/widgets/numeric_keypad.dart';
|
import '../../../core/widgets/numeric_keypad.dart';
|
||||||
import '../../../core/widgets/primary_button.dart';
|
import '../../../core/widgets/primary_button.dart';
|
||||||
import '../../../core/widgets/status_pill.dart';
|
import '../../../domain/entities/promo.dart';
|
||||||
import '../../../domain/entities/transaction.dart';
|
import '../../../domain/entities/transaction.dart';
|
||||||
import '../../customer/widgets/customer_capture_sheet.dart';
|
import '../../customer/widgets/customer_capture_sheet.dart';
|
||||||
import '../../pos/providers/cart_controller.dart';
|
import '../../pos/providers/cart_controller.dart';
|
||||||
@@ -97,7 +98,15 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
|||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
_customerCard(),
|
_customerCard(),
|
||||||
const SizedBox(height: AppSpacing.md),
|
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),
|
_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),
|
padding: EdgeInsets.all(pad),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(minHeight: minHeight),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1340),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(flex: 4, child: SingleChildScrollView(child: left)),
|
Expanded(child: left),
|
||||||
const SizedBox(width: AppSpacing.lg),
|
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),
|
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(
|
return GlassCard(
|
||||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||||
radius: AppRadius.xl,
|
radius: AppRadius.xl,
|
||||||
child: state.activeMethod.needsChange
|
child: _amountTender(controller, state),
|
||||||
? _cashTender(controller)
|
|
||||||
: _referenceTender(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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Row(
|
||||||
'Cash received',
|
children: [
|
||||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
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),
|
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(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: AppSpacing.lg,
|
horizontal: AppSpacing.lg,
|
||||||
vertical: AppSpacing.md,
|
vertical: AppSpacing.md,
|
||||||
@@ -378,25 +445,26 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Text('₹',
|
|
||||||
style:
|
|
||||||
TextStyle(fontSize: 22, color: AppColors.textTertiary),),
|
|
||||||
const SizedBox(width: AppSpacing.sm),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
fit: BoxFit.scaleDown,
|
fit: BoxFit.scaleDown,
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Text(
|
child: Text(
|
||||||
_cashBuffer.isEmpty ? '0' : _cashBuffer,
|
'₹${_cashBuffer.isEmpty ? '0' : _cashBuffer}',
|
||||||
style: AppTypography.money(28),
|
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,
|
spacing: AppSpacing.sm,
|
||||||
runSpacing: AppSpacing.sm,
|
runSpacing: AppSpacing.sm,
|
||||||
children: [
|
children: [
|
||||||
@@ -405,27 +473,47 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
|||||||
label: const Text('Exact'),
|
label: const Text('Exact'),
|
||||||
onPressed: () => _setCash(controller.balanceDue),
|
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(
|
ActionChip(
|
||||||
label: Text('₹$note'),
|
label: Text('₹$note'),
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
|
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
const SizedBox(height: AppSpacing.md),
|
}
|
||||||
_changeRow(controller.changeDue),
|
|
||||||
const SizedBox(height: AppSpacing.md),
|
/// Shortcuts beside the keypad, for the wide layout.
|
||||||
Center(
|
Widget _shortcutColumn(PaymentController controller) {
|
||||||
child: NumericKeypad(
|
return Column(
|
||||||
allowDecimal: true,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
maxWidth: 330,
|
mainAxisSize: MainAxisSize.min,
|
||||||
onKey: _appendCash,
|
children: [
|
||||||
onBackspace: _backspaceCash,
|
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),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
_splitButton(controller, amount: double.tryParse(_cashBuffer) ?? 0),
|
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) {
|
/// Takes part of the bill on the current method.
|
||||||
return Column(
|
///
|
||||||
|
/// 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,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(state.activeMethod.emoji,
|
const Icon(Icons.sell_outlined,
|
||||||
style: const TextStyle(fontSize: 20),),
|
size: 18, color: AppColors.primary,),
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
Expanded(
|
const Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'${state.activeMethod.label} payment',
|
'Offers',
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style:
|
style:
|
||||||
const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
if (applied.isNotEmpty)
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.xxl),
|
|
||||||
Center(
|
|
||||||
child: Container(
|
|
||||||
width: 104,
|
|
||||||
height: 104,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: AppColors.primarySurface,
|
|
||||||
borderRadius: AppRadius.brXl,
|
|
||||||
),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Text(state.activeMethod.emoji,
|
|
||||||
style: const TextStyle(fontSize: 46),),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
|
||||||
Text(
|
Text(
|
||||||
'Charge ${Formatters.money(controller.balanceDue)} on the '
|
'− ${Formatters.money(
|
||||||
'${state.activeMethod.label.toLowerCase()} terminal',
|
applied.fold<double>(0, (sum, a) => sum + a.amount),
|
||||||
textAlign: TextAlign.center,
|
)}',
|
||||||
style: const TextStyle(
|
style: AppTypography.money(14, color: AppColors.success),
|
||||||
fontSize: 13.5,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
height: 1.5,
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.xxl),
|
|
||||||
if (state.activeMethod.needsReference)
|
|
||||||
TextField(
|
|
||||||
onChanged: controller.setReference,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: switch (state.activeMethod) {
|
|
||||||
PaymentMethod.card => 'Approval code',
|
|
||||||
PaymentMethod.upi => 'UPI transaction ID',
|
|
||||||
PaymentMethod.giftCard => 'Gift card number',
|
|
||||||
_ => 'Reference',
|
|
||||||
},
|
|
||||||
prefixIcon: const Icon(Icons.tag_rounded),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
|
||||||
_splitButton(controller),
|
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
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}) {
|
Widget _offerRow({
|
||||||
return OutlinedButton.icon(
|
required IconData icon,
|
||||||
onPressed: controller.balanceDue > 0
|
required Color tone,
|
||||||
? () {
|
required String title,
|
||||||
controller.addSplit(
|
required String subtitle,
|
||||||
amount: amount?.clamp(0, controller.balanceDue).toDouble(),
|
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 = '');
|
|
||||||
}
|
// --------------------------------------------------------- Bill summary
|
||||||
: null,
|
/// What the amount due is made of.
|
||||||
icon: const Icon(Icons.call_split_rounded, size: 17),
|
Widget _summaryCard() {
|
||||||
label: const Text('Add as split payment'),
|
final cart = ref.watch(cartControllerProvider);
|
||||||
style: OutlinedButton.styleFrom(minimumSize: const Size(0, 44)),
|
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
|
// ------------------------------------------------------------ Bottom bar
|
||||||
Widget _bottomBar(
|
Widget _bottomBar(
|
||||||
PaymentController controller,
|
PaymentController controller,
|
||||||
@@ -622,7 +1003,6 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (state.activeMethod.needsChange)
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => _setCash(controller.balanceDue),
|
onPressed: () => _setCash(controller.balanceDue),
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
@@ -705,7 +1085,11 @@ class _MethodTile extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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),
|
const SizedBox(height: AppSpacing.xs),
|
||||||
Text(
|
Text(
|
||||||
method.label,
|
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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import '../../../core/services/sound_service.dart';
|
|||||||
import '../../../domain/entities/cart.dart';
|
import '../../../domain/entities/cart.dart';
|
||||||
import '../../../domain/entities/customer.dart';
|
import '../../../domain/entities/customer.dart';
|
||||||
import '../../../domain/entities/product.dart';
|
import '../../../domain/entities/product.dart';
|
||||||
|
import '../../../domain/entities/promo.dart';
|
||||||
import '../../../domain/entities/transaction.dart';
|
import '../../../domain/entities/transaction.dart';
|
||||||
import '../../../domain/repositories/product_repository.dart';
|
import '../../../domain/repositories/product_repository.dart';
|
||||||
import '../../../domain/repositories/transaction_repository.dart';
|
import '../../../domain/repositories/transaction_repository.dart';
|
||||||
|
import '../../../domain/services/promo_engine.dart';
|
||||||
|
|
||||||
/// Transient feedback for the scan toast — never a blocking dialog.
|
/// Transient feedback for the scan toast — never a blocking dialog.
|
||||||
enum ScanOutcome { added, incremented, notFound, outOfStock }
|
enum ScanOutcome { added, incremented, notFound, outOfStock }
|
||||||
@@ -43,9 +45,13 @@ class CartController extends StateNotifier<Cart> {
|
|||||||
required TransactionRepository transactions,
|
required TransactionRepository transactions,
|
||||||
required SoundService sound,
|
required SoundService sound,
|
||||||
required this.onFeedback,
|
required this.onFeedback,
|
||||||
|
List<Promo> promos = const [],
|
||||||
|
DateTime Function()? clock,
|
||||||
}) : _products = products,
|
}) : _products = products,
|
||||||
_transactions = transactions,
|
_transactions = transactions,
|
||||||
_sound = sound,
|
_sound = sound,
|
||||||
|
_promos = promos,
|
||||||
|
_now = clock ?? DateTime.now,
|
||||||
super(Cart.empty);
|
super(Cart.empty);
|
||||||
|
|
||||||
final ProductRepository _products;
|
final ProductRepository _products;
|
||||||
@@ -53,8 +59,29 @@ class CartController extends StateNotifier<Cart> {
|
|||||||
final SoundService _sound;
|
final SoundService _sound;
|
||||||
final void Function(ScanFeedback) onFeedback;
|
final void Function(ScanFeedback) onFeedback;
|
||||||
|
|
||||||
|
/// Campaigns live right now. Re-evaluated after every change to the bill,
|
||||||
|
/// because whether one fires depends on what is in it.
|
||||||
|
final List<Promo> _promos;
|
||||||
|
final DateTime Function() _now;
|
||||||
|
|
||||||
static const _uuid = Uuid();
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
|
/// Applies the campaign rules to [next] and stores the result.
|
||||||
|
///
|
||||||
|
/// Every mutation goes through here rather than assigning `state` directly,
|
||||||
|
/// so a promo cannot be left applied after the line that earned it is
|
||||||
|
/// removed — which is how a shopper gets a discount for an item they put
|
||||||
|
/// back.
|
||||||
|
void _commit(Cart next) {
|
||||||
|
state = next.copyWith(
|
||||||
|
appliedPromos: PromoEngine.evaluate(
|
||||||
|
cart: next,
|
||||||
|
promos: _promos,
|
||||||
|
at: _now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Snapshots for undo — capped so memory can't grow unbounded on a terminal
|
/// Snapshots for undo — capped so memory can't grow unbounded on a terminal
|
||||||
/// that runs for days.
|
/// that runs for days.
|
||||||
final List<Cart> _undoStack = [];
|
final List<Cart> _undoStack = [];
|
||||||
@@ -105,18 +132,18 @@ class CartController extends StateNotifier<Cart> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
state = state.copyWith(lines: [
|
_commit(state.copyWith(lines: [
|
||||||
...state.lines,
|
...state.lines,
|
||||||
CartLine(
|
CartLine(
|
||||||
product: product,
|
product: product,
|
||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
addedAt: DateTime.now(),
|
addedAt: DateTime.now(),
|
||||||
),
|
),
|
||||||
],);
|
],),);
|
||||||
} else {
|
} else {
|
||||||
state = state.copyWith(
|
_commit(state.copyWith(
|
||||||
lines: _replace(existing.copyWith(quantity: requested)),
|
lines: _replace(existing.copyWith(quantity: requested)),
|
||||||
);
|
),);
|
||||||
}
|
}
|
||||||
|
|
||||||
_clampRedemption();
|
_clampRedemption();
|
||||||
@@ -171,7 +198,7 @@ class CartController extends StateNotifier<Cart> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_push();
|
_push();
|
||||||
state = state.copyWith(lines: _replace(line.copyWith(quantity: capped)));
|
_commit(state.copyWith(lines: _replace(line.copyWith(quantity: capped))));
|
||||||
_clampRedemption();
|
_clampRedemption();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,18 +208,27 @@ class CartController extends StateNotifier<Cart> {
|
|||||||
setQuantity(productId, line.quantity + by);
|
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}) {
|
void decrement(String productId, {double by = 1}) {
|
||||||
final line = state.lineFor(productId);
|
final line = state.lineFor(productId);
|
||||||
if (line == null) return;
|
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) {
|
void removeLine(String productId) {
|
||||||
if (!state.contains(productId)) return;
|
if (!state.contains(productId)) return;
|
||||||
_push();
|
_push();
|
||||||
state = state.copyWith(
|
_commit(state.copyWith(
|
||||||
lines: state.lines.where((l) => l.product.id != productId).toList(),
|
lines: state.lines.where((l) => l.product.id != productId).toList(),
|
||||||
);
|
),);
|
||||||
_clampRedemption();
|
_clampRedemption();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,14 +236,14 @@ class CartController extends StateNotifier<Cart> {
|
|||||||
final line = state.lineFor(productId);
|
final line = state.lineFor(productId);
|
||||||
if (line == null) return;
|
if (line == null) return;
|
||||||
_push();
|
_push();
|
||||||
state = state.copyWith(lines: _replace(line.copyWith(discount: discount)));
|
_commit(state.copyWith(lines: _replace(line.copyWith(discount: discount))));
|
||||||
_clampRedemption();
|
_clampRedemption();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ Bill level
|
// ------------------------------------------------------------ Bill level
|
||||||
void applyBillDiscount(Discount discount) {
|
void applyBillDiscount(Discount discount) {
|
||||||
_push();
|
_push();
|
||||||
state = state.copyWith(billDiscount: discount);
|
_commit(state.copyWith(billDiscount: discount));
|
||||||
_clampRedemption();
|
_clampRedemption();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,9 +251,11 @@ class CartController extends StateNotifier<Cart> {
|
|||||||
|
|
||||||
void attachCustomer(Customer? customer) {
|
void attachCustomer(Customer? customer) {
|
||||||
_push();
|
_push();
|
||||||
state = customer == null
|
_commit(
|
||||||
|
customer == null
|
||||||
? state.copyWith(clearCustomer: true, pointsRedeemed: 0)
|
? state.copyWith(clearCustomer: true, pointsRedeemed: 0)
|
||||||
: state.copyWith(customer: customer);
|
: state.copyWith(customer: customer),
|
||||||
|
);
|
||||||
_clampRedemption();
|
_clampRedemption();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,9 +311,46 @@ class CartController extends StateNotifier<Cart> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> resume(ParkedBill bill) async {
|
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);
|
await _transactions.removeParked(bill.id);
|
||||||
_undoStack.clear();
|
_undoStack.clear();
|
||||||
state = bill.cart;
|
// Re-evaluated rather than restored: a campaign that has since ended must
|
||||||
|
// not be honoured just because the bill was parked while it was running.
|
||||||
|
_commit(bill.cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the header's "New Sale" button actually calls.
|
||||||
|
///
|
||||||
|
/// A cashier can always start over — that's the whole point of the escape
|
||||||
|
/// hatch — but silently wiping a non-empty cart here would be the exact
|
||||||
|
/// same hole the removal PIN closes: scan an item, then abandon the cart
|
||||||
|
/// instead of removing that one line, and it disappears just the same,
|
||||||
|
/// with nobody having approved anything. So a non-empty cart is parked,
|
||||||
|
/// not discarded — visible and resumable from Parked bills — and only an
|
||||||
|
/// already-empty cart takes the plain reset path.
|
||||||
|
Future<void> startNewSale() async {
|
||||||
|
if (state.isNotEmpty) {
|
||||||
|
await park(label: 'Auto-parked — new sale started with items still in cart');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts a cart back exactly as it was just before a sale that has since
|
||||||
|
/// been voided, so cancelling a completed bill doesn't make the cashier
|
||||||
|
/// re-scan everything. Promos are re-evaluated for the same reason
|
||||||
|
/// [resume] re-evaluates them, not restored verbatim.
|
||||||
|
void restore(Cart cart) {
|
||||||
|
_undoStack.clear();
|
||||||
|
_commit(cart);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<CartLine> _replace(CartLine updated) => [
|
List<CartLine> _replace(CartLine updated) => [
|
||||||
@@ -293,6 +368,10 @@ final cartControllerProvider =
|
|||||||
products: ref.watch(productRepositoryProvider),
|
products: ref.watch(productRepositoryProvider),
|
||||||
transactions: ref.watch(transactionRepositoryProvider),
|
transactions: ref.watch(transactionRepositoryProvider),
|
||||||
sound: ref.watch(soundServiceProvider),
|
sound: ref.watch(soundServiceProvider),
|
||||||
|
// Watched, so editing a campaign in Settings takes effect at the till
|
||||||
|
// without a restart. An empty list until they load is correct — no promo
|
||||||
|
// is safer than a stale one.
|
||||||
|
promos: ref.watch(activePromosProvider).value ?? const [],
|
||||||
onFeedback: (feedback) =>
|
onFeedback: (feedback) =>
|
||||||
ref.read(scanFeedbackProvider.notifier).state = feedback,
|
ref.read(scanFeedbackProvider.notifier).state = feedback,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../auth/providers/auth_controller.dart';
|
||||||
|
|
||||||
/// The modules a cashier needs. Deliberately excludes analytics — this
|
/// The modules a cashier needs. Deliberately excludes analytics — this
|
||||||
/// terminal is for billing, not back-office reporting.
|
/// terminal is for billing, not back-office reporting.
|
||||||
enum PosModule {
|
enum PosModule {
|
||||||
@@ -41,4 +43,37 @@ enum NavSection {
|
|||||||
PosModule.values.where((m) => m.section == this).toList();
|
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);
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import '../../../core/services/barcode_service.dart';
|
|||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/theme/app_layout.dart';
|
import '../../../core/theme/app_layout.dart';
|
||||||
|
import '../../auth/providers/auth_controller.dart';
|
||||||
import '../../modules/screens/customers_view.dart';
|
import '../../modules/screens/customers_view.dart';
|
||||||
import '../../modules/screens/events_view.dart';
|
import '../../modules/screens/events_view.dart';
|
||||||
import '../../modules/screens/product_import_view.dart';
|
import '../../modules/screens/product_import_view.dart';
|
||||||
import '../../modules/screens/promos_view.dart';
|
import '../../modules/screens/promos_view.dart';
|
||||||
import '../../modules/screens/settings_view.dart';
|
import '../../modules/screens/settings_view.dart';
|
||||||
|
import '../../modules/widgets/staff_dialogs.dart';
|
||||||
import '../../sync/providers/sync_controller.dart';
|
import '../../sync/providers/sync_controller.dart';
|
||||||
import '../providers/cart_controller.dart';
|
import '../providers/cart_controller.dart';
|
||||||
import '../providers/catalog_providers.dart';
|
import '../providers/catalog_providers.dart';
|
||||||
@@ -33,6 +35,11 @@ import 'pos_view.dart';
|
|||||||
/// * `1120–1300` sidebar as an icon rail, docked bill
|
/// * `1120–1300` sidebar as an icon rail, docked bill
|
||||||
/// * `920–1120` icon rail, bill becomes a bottom sheet
|
/// * `920–1120` icon rail, bill becomes a bottom sheet
|
||||||
/// * `< 920` sidebar goes off-canvas behind a menu button
|
/// * `< 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 {
|
class PosDashboardScreen extends ConsumerStatefulWidget {
|
||||||
const PosDashboardScreen({super.key});
|
const PosDashboardScreen({super.key});
|
||||||
|
|
||||||
@@ -45,10 +52,18 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
|
|||||||
final FocusNode _searchFocus = FocusNode();
|
final FocusNode _searchFocus = FocusNode();
|
||||||
late final BarcodeService _barcode;
|
late final BarcodeService _barcode;
|
||||||
|
|
||||||
|
/// Guards against re-opening the PIN dialog on every rebuild.
|
||||||
|
bool _promptedForPin = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
|
// Anyone still on a PIN this build shipped with is asked to choose their
|
||||||
|
// own before ringing a sale. Deferred to the first frame because it opens
|
||||||
|
// a dialog, which needs a Navigator that exists.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _maybePromptForPin());
|
||||||
|
|
||||||
// The scanner behaves like a keyboard, so listen globally rather than
|
// The scanner behaves like a keyboard, so listen globally rather than
|
||||||
// depending on any one field holding focus. A scan from another module
|
// depending on any one field holding focus. A scan from another module
|
||||||
// jumps back to billing, which is what a cashier expects.
|
// jumps back to billing, which is what a cashier expects.
|
||||||
@@ -65,6 +80,14 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
|
|||||||
)..attach();
|
)..attach();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _maybePromptForPin() async {
|
||||||
|
if (_promptedForPin || !mounted) return;
|
||||||
|
if (!ref.read(mustChangePinProvider)) return;
|
||||||
|
|
||||||
|
_promptedForPin = true;
|
||||||
|
await showForcedPinChange(context);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_barcode.dispose();
|
_barcode.dispose();
|
||||||
@@ -101,10 +124,15 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final layout = PosLayout.of(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 ready = ref.watch(catalogueReadyProvider);
|
||||||
final isPos = module == PosModule.pos && ready;
|
final isPos = module == PosModule.pos && ready;
|
||||||
|
|
||||||
|
final cashierMode = ref.watch(isCashierModeProvider);
|
||||||
|
final showSidebar = !cashierMode;
|
||||||
|
|
||||||
// Only the terminal itself needs the bill docked beside it.
|
// Only the terminal itself needs the bill docked beside it.
|
||||||
final showDockedBill = isPos && !layout.billingIsSheet;
|
final showDockedBill = isPos && !layout.billingIsSheet;
|
||||||
final showCartFab = isPos && layout.billingIsSheet;
|
final showCartFab = isPos && layout.billingIsSheet;
|
||||||
@@ -112,7 +140,7 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
key: _scaffoldKey,
|
key: _scaffoldKey,
|
||||||
backgroundColor: AppColors.background,
|
backgroundColor: AppColors.background,
|
||||||
drawer: layout.sidebarIsDrawer
|
drawer: showSidebar && layout.sidebarIsDrawer
|
||||||
? Drawer(
|
? Drawer(
|
||||||
width: PosLayout.expandedWidth,
|
width: PosLayout.expandedWidth,
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
@@ -139,17 +167,29 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
if (!layout.sidebarIsDrawer) AppSidebar(mode: layout.sidebar),
|
if (showSidebar && !layout.sidebarIsDrawer)
|
||||||
|
AppSidebar(mode: layout.sidebar),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
PageHeader(
|
PageHeader(
|
||||||
layout: layout,
|
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(
|
Expanded(
|
||||||
child: AnimatedSwitcher(
|
child: AnimatedSwitcher(
|
||||||
duration: AppMotion.fast,
|
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(
|
child: KeyedSubtree(
|
||||||
key: ValueKey(module),
|
key: ValueKey(module),
|
||||||
child: _body(module, layout),
|
child: _body(module, layout),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../../../core/theme/app_colors.dart';
|
|||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/theme/app_layout.dart';
|
import '../../../core/theme/app_layout.dart';
|
||||||
import '../../../core/widgets/primary_button.dart';
|
import '../../../core/widgets/primary_button.dart';
|
||||||
|
import '../../auth/providers/auth_controller.dart';
|
||||||
import '../../sync/providers/sync_controller.dart';
|
import '../../sync/providers/sync_controller.dart';
|
||||||
import '../providers/navigation_provider.dart';
|
import '../providers/navigation_provider.dart';
|
||||||
import '../widgets/category_chips.dart';
|
import '../widgets/category_chips.dart';
|
||||||
@@ -41,8 +42,8 @@ class PosView extends ConsumerWidget {
|
|||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const CustomerBar(),
|
|
||||||
const Divider(height: 1),
|
|
||||||
Padding(
|
Padding(
|
||||||
padding:
|
padding:
|
||||||
EdgeInsets.fromLTRB(pad, AppSpacing.lg, pad, AppSpacing.md),
|
EdgeInsets.fromLTRB(pad, AppSpacing.lg, pad, AppSpacing.md),
|
||||||
@@ -82,6 +83,11 @@ class _CatalogueRequired extends ConsumerWidget {
|
|||||||
final state = ref.watch(catalogueImportProvider);
|
final state = ref.watch(catalogueImportProvider);
|
||||||
final running = state is ImportRunning;
|
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(
|
return Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(AppSpacing.xxl),
|
padding: const EdgeInsets.all(AppSpacing.xxl),
|
||||||
@@ -102,17 +108,23 @@ class _CatalogueRequired extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.xxl),
|
const SizedBox(height: AppSpacing.xxl),
|
||||||
Text(
|
Text(
|
||||||
'Import products to start billing',
|
cashier
|
||||||
|
? 'No products on this terminal'
|
||||||
|
: 'Import products to start billing',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.sm),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
const Text(
|
Text(
|
||||||
'This terminal has no catalogue yet. Pull the current products '
|
cashier
|
||||||
'once at the start of your shift — after that everything runs '
|
? 'Nothing has been imported for this shift yet. Ask an '
|
||||||
'offline.',
|
'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,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
height: 1.6,
|
height: 1.6,
|
||||||
@@ -120,7 +132,7 @@ class _CatalogueRequired extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.xxl),
|
const SizedBox(height: AppSpacing.xxl),
|
||||||
|
|
||||||
if (running) ...[
|
if (!cashier && state is ImportRunning) ...[
|
||||||
Text(
|
Text(
|
||||||
state.stage,
|
state.stage,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -142,7 +154,7 @@ class _CatalogueRequired extends ConsumerWidget {
|
|||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
],
|
],
|
||||||
|
|
||||||
if (state is ImportFailed) ...[
|
if (!cashier && state is ImportFailed) ...[
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
|
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(
|
PrimaryButton(
|
||||||
label: 'Import catalogue now',
|
label: 'Import catalogue now',
|
||||||
icon: Icons.cloud_download_rounded,
|
icon: Icons.cloud_download_rounded,
|
||||||
@@ -189,6 +229,7 @@ class _CatalogueRequired extends ConsumerWidget {
|
|||||||
label: const Text('Open Product Import'),
|
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_dimens.dart';
|
||||||
import '../../../core/theme/app_layout.dart';
|
import '../../../core/theme/app_layout.dart';
|
||||||
import '../../../core/theme/app_typography.dart';
|
import '../../../core/theme/app_typography.dart';
|
||||||
|
import '../../../core/widgets/brand_mark.dart';
|
||||||
import '../../../core/utils/formatters.dart';
|
import '../../../core/utils/formatters.dart';
|
||||||
import '../../auth/providers/auth_controller.dart';
|
import '../../auth/providers/auth_controller.dart';
|
||||||
import '../../sync/widgets/sign_out_dialog.dart';
|
|
||||||
import '../providers/cart_controller.dart';
|
import '../providers/cart_controller.dart';
|
||||||
import '../../sync/providers/sync_controller.dart';
|
import '../../sync/providers/sync_controller.dart';
|
||||||
import '../providers/navigation_provider.dart';
|
import '../providers/navigation_provider.dart';
|
||||||
@@ -59,7 +59,9 @@ class AppSidebar extends ConsumerWidget {
|
|||||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.md),
|
padding: const EdgeInsets.symmetric(vertical: AppSpacing.md),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
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: section,
|
section: section,
|
||||||
expanded: expanded,
|
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(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
const BrandMark(size: 36),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (expanded) ...[
|
if (expanded) ...[
|
||||||
const SizedBox(width: AppSpacing.md),
|
const SizedBox(width: AppSpacing.md),
|
||||||
Flexible(
|
Flexible(
|
||||||
@@ -228,7 +212,8 @@ class _Section extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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 cartCount = ref.watch(cartItemCountProvider);
|
||||||
final ready = ref.watch(catalogueReadyProvider);
|
final ready = ref.watch(catalogueReadyProvider);
|
||||||
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
|
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
|
||||||
@@ -255,7 +240,7 @@ class _Section extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Divider(height: 1),
|
child: Divider(height: 1),
|
||||||
),
|
),
|
||||||
for (final module in section.modules)
|
for (final module in section.modules.where(visible.contains))
|
||||||
_NavTile(
|
_NavTile(
|
||||||
module: module,
|
module: module,
|
||||||
expanded: expanded,
|
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/router/app_router.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
|
import '../../../core/theme/app_layout.dart';
|
||||||
import '../../../core/theme/app_typography.dart';
|
import '../../../core/theme/app_typography.dart';
|
||||||
import '../../../core/utils/extensions.dart';
|
import '../../../core/utils/extensions.dart';
|
||||||
import '../../../core/utils/formatters.dart';
|
import '../../../core/utils/formatters.dart';
|
||||||
@@ -15,8 +16,8 @@ import '../../../core/widgets/primary_button.dart';
|
|||||||
import '../../../domain/entities/cart.dart';
|
import '../../../domain/entities/cart.dart';
|
||||||
import '../../customer/widgets/customer_capture_sheet.dart';
|
import '../../customer/widgets/customer_capture_sheet.dart';
|
||||||
import '../providers/cart_controller.dart';
|
import '../providers/cart_controller.dart';
|
||||||
|
import 'admin_pin_dialog.dart';
|
||||||
import 'cart_line_tile.dart';
|
import 'cart_line_tile.dart';
|
||||||
import 'discount_sheet.dart';
|
|
||||||
|
|
||||||
/// Always-visible bill on the right of the dashboard.
|
/// Always-visible bill on the right of the dashboard.
|
||||||
class BillingPanel extends ConsumerWidget {
|
class BillingPanel extends ConsumerWidget {
|
||||||
@@ -58,9 +59,12 @@ class BillingPanel extends ConsumerWidget {
|
|||||||
line: line,
|
line: line,
|
||||||
onIncrement: () => controller.increment(line.product.id),
|
onIncrement: () => controller.increment(line.product.id),
|
||||||
onDecrement: () => controller.decrement(line.product.id),
|
onDecrement: () => controller.decrement(line.product.id),
|
||||||
onRemove: () => controller.removeLine(line.product.id),
|
onRemove: () => _removeLine(
|
||||||
onDiscount: () =>
|
context,
|
||||||
showLineDiscountSheet(context, ref, line),
|
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 {
|
class _Header extends ConsumerWidget {
|
||||||
const _Header({required this.cart, required this.inSheet});
|
const _Header({required this.cart, required this.inSheet});
|
||||||
|
|
||||||
@@ -80,17 +102,17 @@ class _Header extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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(
|
return Container(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
|
||||||
AppSpacing.lg,
|
padding: EdgeInsets.symmetric(horizontal: contentPadding),
|
||||||
AppSpacing.md,
|
|
||||||
AppSpacing.sm,
|
|
||||||
AppSpacing.md,
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
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(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Cart',
|
'Cart',
|
||||||
@@ -101,7 +123,10 @@ class _Header extends ConsumerWidget {
|
|||||||
if (cart.isNotEmpty) ...[
|
if (cart.isNotEmpty) ...[
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: AppSpacing.sm,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: AppColors.primarySurface,
|
color: AppColors.primarySurface,
|
||||||
borderRadius: AppRadius.brPill,
|
borderRadius: AppRadius.brPill,
|
||||||
@@ -116,33 +141,14 @@ class _Header extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
// Icon-only actions: labelled buttons overflowed the 380px panel.
|
|
||||||
if (controller.canUndo)
|
// Undo, Park and Clear used to sit here as three icon buttons. They
|
||||||
_IconAction(
|
// are the least-pressed controls on the panel and they were the
|
||||||
icon: Icons.undo_rounded,
|
// first thing the eye landed on, above the bill itself. Undo is on
|
||||||
tooltip: 'Undo (F8)',
|
// F8; Park and Clear moved down beside the total, next to the button
|
||||||
color: AppColors.textSecondary,
|
// a cashier is already reaching for.
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (inSheet)
|
if (inSheet)
|
||||||
_IconAction(
|
_IconAction(
|
||||||
icon: Icons.close_rounded,
|
icon: Icons.close_rounded,
|
||||||
@@ -242,6 +248,17 @@ class _Summary extends ConsumerWidget {
|
|||||||
valueColor: AppColors.success,
|
valueColor: AppColors.success,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Named individually rather than lumped into one "Promotions" line:
|
||||||
|
// a shopper who came in for a specific offer needs to see it applied,
|
||||||
|
// and a cashier being asked "did the weekend deal come off?" needs to
|
||||||
|
// answer without opening a report.
|
||||||
|
for (final applied in cart.appliedPromos)
|
||||||
|
_Row(
|
||||||
|
label: applied.promo.name,
|
||||||
|
value: '-${Formatters.money(applied.amount)}',
|
||||||
|
valueColor: AppColors.success,
|
||||||
|
),
|
||||||
|
|
||||||
_Row(
|
_Row(
|
||||||
label: 'GST',
|
label: 'GST',
|
||||||
value: Formatters.money(cart.taxAmount),
|
value: Formatters.money(cart.taxAmount),
|
||||||
@@ -252,21 +269,6 @@ class _Summary extends ConsumerWidget {
|
|||||||
.join(', '),
|
.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)
|
if (cart.maxRedeemablePoints > 0 || cart.pointsRedeemed > 0)
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () => cart.pointsRedeemed > 0
|
onTap: () => cart.pointsRedeemed > 0
|
||||||
@@ -349,7 +351,13 @@ class _Row extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
|
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(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
@@ -362,13 +370,14 @@ class _Row extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
if (hint != null) ...[
|
if (hint != null) ...[
|
||||||
const SizedBox(width: AppSpacing.xs),
|
const SizedBox(width: AppSpacing.xs),
|
||||||
Text('($hint)',
|
Text(
|
||||||
|
'($hint)',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 11.5,
|
fontSize: 11.5,
|
||||||
color: AppColors.textTertiary,
|
color: AppColors.textTertiary,
|
||||||
),),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(width: AppSpacing.sm),
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
@@ -382,7 +391,8 @@ class _Row extends StatelessWidget {
|
|||||||
const SizedBox(width: AppSpacing.xs),
|
const SizedBox(width: AppSpacing.xs),
|
||||||
Icon(trailingIcon, size: 14, color: AppColors.textTertiary),
|
Icon(trailingIcon, size: 14, color: AppColors.textTertiary),
|
||||||
],
|
],
|
||||||
],),
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,18 +413,23 @@ class _Actions extends ConsumerWidget {
|
|||||||
AppSpacing.xl,
|
AppSpacing.xl,
|
||||||
AppSpacing.xl,
|
AppSpacing.xl,
|
||||||
),
|
),
|
||||||
child: PrimaryButton(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
PrimaryButton(
|
||||||
label: 'CHARGE',
|
label: 'CHARGE',
|
||||||
large: true,
|
large: true,
|
||||||
onPressed: enabled
|
onPressed: enabled
|
||||||
? () async {
|
? () async {
|
||||||
// Ask once per bill, before payment. Skipping is one tap and
|
// Ask once per bill, before payment. Skipping is one tap
|
||||||
// leaves the sale as walk-in.
|
// and leaves the sale as walk-in.
|
||||||
if (ref.read(cartControllerProvider).customer == null) {
|
if (ref.read(cartControllerProvider).customer == null) {
|
||||||
await showCustomerCaptureSheet(context);
|
await showCustomerCaptureSheet(context);
|
||||||
}
|
}
|
||||||
// Navigation result is not needed here.
|
// Navigation result is not needed here.
|
||||||
if (context.mounted) unawaited(context.push(AppRoutes.payment));
|
if (context.mounted) {
|
||||||
|
unawaited(context.push(AppRoutes.payment));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
trailing: enabled
|
trailing: enabled
|
||||||
@@ -424,6 +439,8 @@ class _Actions extends ConsumerWidget {
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,12 @@ class CartLineTile extends StatelessWidget {
|
|||||||
required this.onIncrement,
|
required this.onIncrement,
|
||||||
required this.onDecrement,
|
required this.onDecrement,
|
||||||
required this.onRemove,
|
required this.onRemove,
|
||||||
this.onDiscount,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final CartLine line;
|
final CartLine line;
|
||||||
final VoidCallback onIncrement;
|
final VoidCallback onIncrement;
|
||||||
final VoidCallback onDecrement;
|
final VoidCallback onDecrement;
|
||||||
final VoidCallback onRemove;
|
final VoidCallback onRemove;
|
||||||
final VoidCallback? onDiscount;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -30,7 +28,16 @@ class CartLineTile extends StatelessWidget {
|
|||||||
return Dismissible(
|
return Dismissible(
|
||||||
key: ValueKey('dismiss_${p.id}'),
|
key: ValueKey('dismiss_${p.id}'),
|
||||||
direction: DismissDirection.endToStart,
|
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(
|
background: Container(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
padding: const EdgeInsets.only(right: AppSpacing.xl),
|
padding: const EdgeInsets.only(right: AppSpacing.xl),
|
||||||
@@ -119,7 +126,7 @@ class CartLineTile extends StatelessWidget {
|
|||||||
color: AppColors.textTertiary,
|
color: AppColors.textTertiary,
|
||||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
tooltip: 'Remove',
|
tooltip: 'Remove from bill (needs the removal PIN)',
|
||||||
),
|
),
|
||||||
],),
|
],),
|
||||||
|
|
||||||
@@ -132,17 +139,6 @@ class CartLineTile extends StatelessWidget {
|
|||||||
onIncrement: onIncrement,
|
onIncrement: onIncrement,
|
||||||
onDecrement: onDecrement,
|
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(),
|
const Spacer(),
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
@@ -209,8 +205,12 @@ class _Stepper extends StatelessWidget {
|
|||||||
borderRadius: AppRadius.brSm,
|
borderRadius: AppRadius.brSm,
|
||||||
border: Border.all(color: AppColors.border),
|
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: [
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
_btn(Icons.remove_rounded, onDecrement),
|
_btn(Icons.remove_rounded, quantity > 1 ? onDecrement : null),
|
||||||
Container(
|
Container(
|
||||||
constraints: const BoxConstraints(minWidth: 42),
|
constraints: const BoxConstraints(minWidth: 42),
|
||||||
alignment: Alignment.center,
|
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,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
@@ -234,7 +234,13 @@ class _Stepper extends StatelessWidget {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 34,
|
width: 34,
|
||||||
height: 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_dimens.dart';
|
||||||
import '../../../core/theme/app_layout.dart';
|
import '../../../core/theme/app_layout.dart';
|
||||||
import '../../../core/utils/formatters.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/cart_controller.dart';
|
||||||
import '../providers/navigation_provider.dart';
|
import '../providers/navigation_provider.dart';
|
||||||
|
|
||||||
@@ -32,7 +35,10 @@ class PageHeader extends ConsumerWidget {
|
|||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, box) {
|
builder: (context, box) {
|
||||||
final showStatus = box.maxWidth >= 720;
|
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,
|
WidgetRef ref,
|
||||||
bool compact,
|
bool compact,
|
||||||
bool showStatus,
|
bool showStatus,
|
||||||
|
bool showBrand,
|
||||||
) {
|
) {
|
||||||
final module = ref.watch(activeModuleProvider);
|
|
||||||
final now = ref.watch(clockProvider).value ?? DateTime.now();
|
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(
|
return Container(
|
||||||
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
|
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
@@ -58,7 +68,7 @@ class PageHeader extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (compact) ...[
|
if (compact && onMenuTap != null) ...[
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: onMenuTap,
|
onPressed: onMenuTap,
|
||||||
icon: const Icon(Icons.menu_rounded),
|
icon: const Icon(Icons.menu_rounded),
|
||||||
@@ -68,28 +78,12 @@ class PageHeader extends ConsumerWidget {
|
|||||||
const SizedBox(width: AppSpacing.xs),
|
const SizedBox(width: AppSpacing.xs),
|
||||||
],
|
],
|
||||||
|
|
||||||
Flexible(
|
// Dropped first when the bar gets tight: the actions are what the
|
||||||
child: Column(
|
// counter actually presses.
|
||||||
mainAxisSize: MainAxisSize.min,
|
if (cashierMode && showBrand) ...[
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
const _CashierBrand(),
|
||||||
children: [
|
const SizedBox(width: AppSpacing.lg),
|
||||||
Text(
|
|
||||||
module.title,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 19,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: -0.4,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
height: 1.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!compact) _Breadcrumb(module: module),
|
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const Spacer(),
|
|
||||||
|
|
||||||
if (showStatus) ...[
|
if (showStatus) ...[
|
||||||
_LivePill(offline: ref.watch(simulateOfflineProvider)),
|
_LivePill(offline: ref.watch(simulateOfflineProvider)),
|
||||||
@@ -103,67 +97,46 @@ class PageHeader extends ConsumerWidget {
|
|||||||
fontFeatures: [FontFeature.tabularFigures()],
|
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),
|
_ParkedBillsButton(compact: compact),
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
_NewSaleButton(compact: compact),
|
_NewSaleButton(compact: compact),
|
||||||
|
|
||||||
|
// Every role signs out from here. It used to sit at the foot of the
|
||||||
|
// sidebar for admins and up here for cashiers, which meant the same
|
||||||
|
// action lived in two places depending on who was holding the till.
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Container(width: 1, height: 26, color: AppColors.border),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
const _LogoutButton(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Breadcrumb extends StatelessWidget {
|
|
||||||
const _Breadcrumb({required this.module});
|
|
||||||
|
|
||||||
final PosModule module;
|
/// What the pill is saying, in the order it takes precedence.
|
||||||
|
enum _Liveness { offlineSim, halted, syncing, queued, live }
|
||||||
|
|
||||||
@override
|
class _LivePill extends ConsumerStatefulWidget {
|
||||||
Widget build(BuildContext context) {
|
|
||||||
const style = TextStyle(fontSize: 12, color: AppColors.textTertiary);
|
|
||||||
|
|
||||||
return Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Text('Home', style: style),
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs + 2),
|
|
||||||
child: Icon(Icons.chevron_right_rounded,
|
|
||||||
size: 13, color: AppColors.textTertiary,),
|
|
||||||
),
|
|
||||||
Text(module.section.label, style: style),
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs + 2),
|
|
||||||
child: Icon(Icons.chevron_right_rounded,
|
|
||||||
size: 13, color: AppColors.textTertiary,),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
module.label,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LivePill extends StatefulWidget {
|
|
||||||
const _LivePill({required this.offline});
|
const _LivePill({required this.offline});
|
||||||
|
|
||||||
final bool offline;
|
final bool offline;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_LivePill> createState() => _LivePillState();
|
ConsumerState<_LivePill> createState() => _LivePillState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LivePillState extends State<_LivePill>
|
class _LivePillState extends ConsumerState<_LivePill>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late final AnimationController _c = AnimationController(
|
late final AnimationController _c = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
@@ -178,16 +151,62 @@ class _LivePillState extends State<_LivePill>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final offline = widget.offline;
|
// The engine may not have emitted yet on a cold start, so fall back to its
|
||||||
final tone = offline ? AppColors.warning : AppColors.success;
|
// current value rather than showing nothing.
|
||||||
final surface =
|
final sync = ref.watch(syncEngineStateProvider).value ??
|
||||||
offline ? AppColors.warningSurface : AppColors.successSurface;
|
ref.watch(syncEngineProvider).state;
|
||||||
|
|
||||||
|
final liveness = switch (0) {
|
||||||
|
_ when widget.offline => _Liveness.offlineSim,
|
||||||
|
_ when sync.isHalted => _Liveness.halted,
|
||||||
|
_ when sync.isSyncing => _Liveness.syncing,
|
||||||
|
// Bills waiting is normal for a few seconds after a sale; it is only
|
||||||
|
// worth flagging once they are visibly piling up.
|
||||||
|
_ when sync.pending > 0 => _Liveness.queued,
|
||||||
|
_ => _Liveness.live,
|
||||||
|
};
|
||||||
|
|
||||||
|
final (label, tone, surface, message) = switch (liveness) {
|
||||||
|
_Liveness.offlineSim => (
|
||||||
|
'OFFLINE (SIM)',
|
||||||
|
AppColors.warning,
|
||||||
|
AppColors.warningSurface,
|
||||||
|
'Simulate offline is ON in Settings — imports and syncs are being '
|
||||||
|
'failed deliberately.',
|
||||||
|
),
|
||||||
|
_Liveness.halted => (
|
||||||
|
'SYNC HALTED',
|
||||||
|
AppColors.danger,
|
||||||
|
AppColors.dangerSurface,
|
||||||
|
'Uploading stopped because retrying will not help: '
|
||||||
|
'${sync.lastError ?? 'the back office refused the batch'}. '
|
||||||
|
'Every bill is still safe on this terminal. Press Sync to try '
|
||||||
|
'again once it is sorted.',
|
||||||
|
),
|
||||||
|
_Liveness.syncing => (
|
||||||
|
'SYNCING',
|
||||||
|
AppColors.primary,
|
||||||
|
AppColors.primarySurface,
|
||||||
|
'Uploading bills to the back office.',
|
||||||
|
),
|
||||||
|
_Liveness.queued => (
|
||||||
|
'${sync.pending} QUEUED',
|
||||||
|
AppColors.warning,
|
||||||
|
AppColors.warningSurface,
|
||||||
|
'${sync.pending} bill(s) are stored on this terminal and waiting to '
|
||||||
|
'upload. They are safe; nothing is lost while the line is down.',
|
||||||
|
),
|
||||||
|
_Liveness.live => (
|
||||||
|
'LIVE',
|
||||||
|
AppColors.success,
|
||||||
|
AppColors.successSurface,
|
||||||
|
'Terminal is operating normally and everything rung has been '
|
||||||
|
'uploaded.',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: offline
|
message: message,
|
||||||
? 'Simulate offline is ON in Settings — imports and syncs are being '
|
|
||||||
'failed deliberately.'
|
|
||||||
: 'Terminal is operating normally.',
|
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: AppSpacing.md,
|
horizontal: AppSpacing.md,
|
||||||
@@ -210,7 +229,7 @@ class _LivePillState extends State<_LivePill>
|
|||||||
),
|
),
|
||||||
const SizedBox(width: AppSpacing.xs + 2),
|
const SizedBox(width: AppSpacing.xs + 2),
|
||||||
Text(
|
Text(
|
||||||
offline ? 'OFFLINE (SIM)' : 'LIVE',
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: tone,
|
color: tone,
|
||||||
fontSize: 10.5,
|
fontSize: 10.5,
|
||||||
@@ -293,11 +312,24 @@ class _ParkedBillsButton extends ConsumerWidget {
|
|||||||
'${Formatters.time(bill.parkedAt)}',
|
'${Formatters.time(bill.parkedAt)}',
|
||||||
),
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
|
final hadItems =
|
||||||
|
ref.read(cartControllerProvider).isNotEmpty;
|
||||||
await ref
|
await ref
|
||||||
.read(cartControllerProvider.notifier)
|
.read(cartControllerProvider.notifier)
|
||||||
.resume(bill);
|
.resume(bill);
|
||||||
ref.invalidate(parkedBillsProvider);
|
ref.invalidate(parkedBillsProvider);
|
||||||
if (context.mounted) Navigator.of(context).pop();
|
if (!context.mounted) return;
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
if (hadItems) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'The cart you were on was saved back to Parked '
|
||||||
|
'bills.',
|
||||||
|
),
|
||||||
|
),);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -321,12 +353,22 @@ class _NewSaleButton extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
void start() {
|
Future<void> start() async {
|
||||||
ref.read(cartControllerProvider.notifier).reset();
|
final hadItems = ref.read(cartControllerProvider).isNotEmpty;
|
||||||
|
await ref.read(cartControllerProvider.notifier).startNewSale();
|
||||||
|
if (hadItems) ref.invalidate(parkedBillsProvider);
|
||||||
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
|
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
ScaffoldMessenger.of(context)
|
ScaffoldMessenger.of(context)
|
||||||
..hideCurrentSnackBar()
|
..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) {
|
if (compact) {
|
||||||
@@ -351,3 +393,77 @@ class _NewSaleButton extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Brand and outlet name, shown only in cashier mode.
|
||||||
|
///
|
||||||
|
/// The sidebar normally carries these; without it the bar reads as a fragment
|
||||||
|
/// of an app rather than the top of one.
|
||||||
|
class _CashierBrand extends ConsumerWidget {
|
||||||
|
const _CashierBrand();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final store = ref.watch(currentStoreProvider);
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const BrandMark(size: 32),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 170),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
store?.name ?? 'Nearle POS',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
user == null ? 'Cashier' : '${user.name} · Cashier',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
height: 1.25,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signing out, for both roles.
|
||||||
|
///
|
||||||
|
/// Opens the session-end chooser rather than signing out directly: a cashier
|
||||||
|
/// stepping away for ten minutes and a cashier finishing for the day want two
|
||||||
|
/// very different things to happen to the drawer and the catalogue.
|
||||||
|
class _LogoutButton extends ConsumerWidget {
|
||||||
|
const _LogoutButton();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return IconButton(
|
||||||
|
tooltip: 'Sign out',
|
||||||
|
onPressed: () => showSessionEndSheet(context, ref),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.danger,
|
||||||
|
backgroundColor: AppColors.dangerSurface,
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.logout_rounded),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,9 +84,10 @@ class _ProductCardState extends State<ProductCard> {
|
|||||||
children: [
|
children: [
|
||||||
Opacity(
|
Opacity(
|
||||||
opacity: disabled ? 0.4 : 1,
|
opacity: disabled ? 0.4 : 1,
|
||||||
child: Text(
|
child: _ProductVisual(
|
||||||
p.emoji,
|
imageUrl: p.imageUrl,
|
||||||
style: TextStyle(fontSize: emoji),
|
emoji: p.emoji,
|
||||||
|
size: emoji,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: tight ? 2 : AppSpacing.sm),
|
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/utils/formatters.dart';
|
||||||
import '../../../core/widgets/glass_card.dart';
|
import '../../../core/widgets/glass_card.dart';
|
||||||
import '../../../core/widgets/primary_button.dart';
|
import '../../../core/widgets/primary_button.dart';
|
||||||
|
import '../../../data/sync/sync_engine.dart';
|
||||||
import '../../../domain/entities/transaction.dart';
|
import '../../../domain/entities/transaction.dart';
|
||||||
import '../../modules/providers/printer_settings.dart';
|
import '../../modules/providers/printer_settings.dart';
|
||||||
import '../../pos/providers/cart_controller.dart';
|
import '../../pos/providers/cart_controller.dart';
|
||||||
|
import '../../pos/providers/catalog_providers.dart';
|
||||||
|
import '../../sync/providers/sync_controller.dart';
|
||||||
import '../widgets/receipt_preview.dart';
|
import '../widgets/receipt_preview.dart';
|
||||||
|
|
||||||
/// Confirmation screen. Counts down and starts the next sale on its own so an
|
/// Confirmation screen.
|
||||||
/// unattended terminal never sits on a finished bill.
|
///
|
||||||
|
/// 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 {
|
class ReceiptScreen extends ConsumerStatefulWidget {
|
||||||
const ReceiptScreen({super.key, required this.transaction});
|
const ReceiptScreen({super.key, required this.transaction});
|
||||||
|
|
||||||
@@ -32,9 +41,13 @@ class ReceiptScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
||||||
late int _seconds = AppConstants.postSaleResetDelay.inSeconds + 5;
|
late int _seconds = AppConstants.postSaleResetDelay.inSeconds;
|
||||||
Timer? _timer;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -82,13 +95,36 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
|||||||
|
|
||||||
void _newSale() {
|
void _newSale() {
|
||||||
_timer?.cancel();
|
_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();
|
ref.read(cartControllerProvider.notifier).reset();
|
||||||
if (mounted) context.go(AppRoutes.pos);
|
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();
|
_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);
|
if (mounted) context.go(AppRoutes.pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +285,7 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
|||||||
final sent = await ref
|
final sent = await ref
|
||||||
.read(receiptServiceProvider)
|
.read(receiptServiceProvider)
|
||||||
.sendToWhatsApp(txn);
|
.sendToWhatsApp(txn);
|
||||||
if (!context.mounted || sent) return;
|
if (!mounted || sent) return;
|
||||||
ScaffoldMessenger.of(context)
|
ScaffoldMessenger.of(context)
|
||||||
..hideCurrentSnackBar()
|
..hideCurrentSnackBar()
|
||||||
..showSnackBar(const SnackBar(
|
..showSnackBar(const SnackBar(
|
||||||
@@ -293,12 +329,23 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
|||||||
: 'New Sale',
|
: 'New Sale',
|
||||||
icon: Icons.add_shopping_cart_rounded,
|
icon: Icons.add_shopping_cart_rounded,
|
||||||
large: true,
|
large: true,
|
||||||
onPressed: _newSale,
|
onPressed: _voiding ? null : _newSale,
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.sm),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
TextButton(
|
TextButton.icon(
|
||||||
onPressed: _continueBilling,
|
onPressed: _voiding ? null : _cancelSale,
|
||||||
child: const Text('Back to billing screen'),
|
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,),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../app/providers.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/shift_report.dart';
|
||||||
import '../../../domain/entities/sync_event.dart';
|
import '../../../domain/entities/sync_event.dart';
|
||||||
import '../../../domain/repositories/sync_repository.dart';
|
import '../../../domain/repositories/sync_repository.dart';
|
||||||
@@ -167,6 +172,12 @@ class OrderSyncController extends StateNotifier<OrderSyncState> {
|
|||||||
bool get isRunning => state is SyncRunning;
|
bool get isRunning => state is SyncRunning;
|
||||||
|
|
||||||
/// Uploads every bill at sync_status = 0 and flips the accepted ones to 1.
|
/// Uploads every bill at sync_status = 0 and flips the accepted ones to 1.
|
||||||
|
///
|
||||||
|
/// Goes through the engine rather than straight to the repository, so a
|
||||||
|
/// cashier pressing sync while a background drain is already mid-flight
|
||||||
|
/// joins it instead of starting a second pass over the same rows. It also
|
||||||
|
/// clears a halt: pressing the button is how you retry after the back office
|
||||||
|
/// has been fixed.
|
||||||
Future<SyncOutcome> run() async {
|
Future<SyncOutcome> run() async {
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
return const SyncOutcome(attempted: 0, uploaded: 0);
|
return const SyncOutcome(attempted: 0, uploaded: 0);
|
||||||
@@ -174,7 +185,7 @@ class OrderSyncController extends StateNotifier<OrderSyncState> {
|
|||||||
|
|
||||||
state = const SyncRunning(0, 'Starting…');
|
state = const SyncRunning(0, 'Starting…');
|
||||||
|
|
||||||
final outcome = await _ref.read(syncRepositoryProvider).syncOrders(
|
final outcome = await _ref.read(syncEngineProvider).syncNow(
|
||||||
onProgress: (progress, stage) {
|
onProgress: (progress, stage) {
|
||||||
if (mounted) state = SyncRunning(progress, stage);
|
if (mounted) state = SyncRunning(progress, stage);
|
||||||
},
|
},
|
||||||
@@ -192,3 +203,88 @@ final orderSyncProvider =
|
|||||||
StateNotifierProvider<OrderSyncController, OrderSyncState>(
|
StateNotifierProvider<OrderSyncController, OrderSyncState>(
|
||||||
(ref) => OrderSyncController(ref),
|
(ref) => OrderSyncController(ref),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ------------------------------------------------------- Background drain
|
||||||
|
/// Brings the queue-and-drain machinery up, once, when the shell mounts.
|
||||||
|
///
|
||||||
|
/// Deliberately not gated on sign-in: a terminal that boots holding yesterday's
|
||||||
|
/// bills should be emptying its queue before anyone reaches the till.
|
||||||
|
///
|
||||||
|
/// Overridden to a no-op in widget tests, which have no network stack and
|
||||||
|
/// cannot drive real disk I/O on a fake clock.
|
||||||
|
final syncBootstrapProvider = FutureProvider<void>((ref) async {
|
||||||
|
// Restore the route this terminal was pointed at. Without this the settings
|
||||||
|
// are written on Save and then silently ignored on the next launch, which
|
||||||
|
// reads exactly like they never saved.
|
||||||
|
final store = ref.read(localStoreProvider);
|
||||||
|
if (store.isReady) {
|
||||||
|
ref.read(syncConfigProvider.notifier).state =
|
||||||
|
await store.syncConfig.load(ref.read(syncConfigProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
await ref.read(connectivityServiceProvider).start();
|
||||||
|
|
||||||
|
final engine = ref.read(syncEngineProvider);
|
||||||
|
|
||||||
|
// A background drain moves bills out of the pending set, so the tallies and
|
||||||
|
// shift totals on screen are stale the moment one finishes.
|
||||||
|
var wasSyncing = false;
|
||||||
|
final subscription = engine.states.listen((state) {
|
||||||
|
if (wasSyncing && !state.isSyncing) {
|
||||||
|
ref.read(orderVersionProvider.notifier).state++;
|
||||||
|
}
|
||||||
|
wasSyncing = state.isSyncing;
|
||||||
|
});
|
||||||
|
ref.onDispose(subscription.cancel);
|
||||||
|
|
||||||
|
await engine.start();
|
||||||
|
|
||||||
|
// The retained presence record needs a Last Will to pair with, so it genuinely
|
||||||
|
// only exists on the broker. The heartbeat below does not, and is started for
|
||||||
|
// every route.
|
||||||
|
final transport = ref.read(orderTransportProvider);
|
||||||
|
if (transport is MqttOrderTransport) {
|
||||||
|
final reporter = PresenceReporter(
|
||||||
|
transport: transport,
|
||||||
|
terminal: ref.read(terminalIdentityProvider),
|
||||||
|
config: ref.read(syncConfigProvider),
|
||||||
|
engine: engine,
|
||||||
|
appVersion: AppConstants.appVersion,
|
||||||
|
catalogueRevision: () async =>
|
||||||
|
ref.read(syncRepositoryProvider).catalogueRevision,
|
||||||
|
);
|
||||||
|
ref.onDispose(reporter.dispose);
|
||||||
|
await reporter.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 30-second heartbeat the head-office board reads. Separate from the
|
||||||
|
// retained presence record above: that one is paired with the Last Will and
|
||||||
|
// answers "is this till alive", while this carries queue depth, today's
|
||||||
|
// trading and hardware state — what tells a till that is merely quiet from
|
||||||
|
// one that has stopped uploading.
|
||||||
|
//
|
||||||
|
// Outside the MQTT check on purpose. It used to be inside, which meant a shop
|
||||||
|
// on the HTTP route uploaded every bill correctly and never appeared on the
|
||||||
|
// board at all — with nothing logged, because nothing had failed. Both routes
|
||||||
|
// can carry a heartbeat now, and the transport decides how.
|
||||||
|
{
|
||||||
|
final health = HealthReporter(
|
||||||
|
transport: transport,
|
||||||
|
terminal: ref.read(terminalIdentityProvider),
|
||||||
|
config: ref.read(syncConfigProvider),
|
||||||
|
engine: engine,
|
||||||
|
repository: ref.read(syncRepositoryProvider),
|
||||||
|
appVersion: AppConstants.appVersion,
|
||||||
|
// Read on each beat, so re-pointing the printer in Settings takes effect
|
||||||
|
// without a restart.
|
||||||
|
printerEndpoint: () {
|
||||||
|
final printer = ref.read(printerSettingsProvider);
|
||||||
|
final host = printer.drawerHost;
|
||||||
|
if (host == null || host.isEmpty) return null;
|
||||||
|
return (host: host, port: printer.drawerPort);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ref.onDispose(health.dispose);
|
||||||
|
await health.start();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import '../../../core/utils/formatters.dart';
|
|||||||
import '../../../core/widgets/primary_button.dart';
|
import '../../../core/widgets/primary_button.dart';
|
||||||
import '../../auth/providers/auth_controller.dart';
|
import '../../auth/providers/auth_controller.dart';
|
||||||
import '../../pos/providers/cart_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 '../../../domain/repositories/sync_repository.dart';
|
||||||
import '../providers/sync_controller.dart';
|
import '../providers/sync_controller.dart';
|
||||||
|
|
||||||
@@ -35,11 +37,54 @@ class _SignOutDialog extends ConsumerStatefulWidget {
|
|||||||
class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
||||||
SyncOutcome? _result;
|
SyncOutcome? _result;
|
||||||
|
|
||||||
void _finish() {
|
Future<void> _finish() async {
|
||||||
ref.read(cartControllerProvider.notifier).reset();
|
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();
|
Navigator.of(context).pop();
|
||||||
context.go(AppRoutes.login);
|
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 {
|
Future<void> _pushThenFinish() async {
|
||||||
@@ -63,8 +108,10 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
|||||||
final pushing = ref.watch(orderSyncProvider) is SyncRunning;
|
final pushing = ref.watch(orderSyncProvider) is SyncRunning;
|
||||||
final failed = _result != null && !_result!.isSuccess;
|
final failed = _result != null && !_result!.isSuccess;
|
||||||
|
|
||||||
|
final cashier = ref.watch(isCashierModeProvider);
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('End shift'),
|
title: Text(cashier ? 'End shift' : 'Sign out'),
|
||||||
contentPadding: const EdgeInsets.fromLTRB(
|
contentPadding: const EdgeInsets.fromLTRB(
|
||||||
AppSpacing.xxl,
|
AppSpacing.xxl,
|
||||||
AppSpacing.lg,
|
AppSpacing.lg,
|
||||||
@@ -79,6 +126,26 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
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)
|
if (cart.isNotEmpty)
|
||||||
_Banner(
|
_Banner(
|
||||||
icon: Icons.warning_amber_rounded,
|
icon: Icons.warning_amber_rounded,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
||||||
|
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
#include <printing/printing_plugin.h>
|
#include <printing/printing_plugin.h>
|
||||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
@@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
|||||||
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
|
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
|
||||||
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
|
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
|
||||||
|
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||||
|
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) printing_registrar =
|
g_autoptr(FlPluginRegistrar) printing_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin");
|
||||||
printing_plugin_register_with_registrar(printing_registrar);
|
printing_plugin_register_with_registrar(printing_registrar);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
audioplayers_linux
|
audioplayers_linux
|
||||||
|
flutter_secure_storage_linux
|
||||||
printing
|
printing
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ import FlutterMacOS
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import audioplayers_darwin
|
import audioplayers_darwin
|
||||||
|
import connectivity_plus
|
||||||
|
import flutter_secure_storage_darwin
|
||||||
import printing
|
import printing
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
|
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
|
||||||
|
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||||
|
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||||
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
|
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
|
|||||||
@@ -6,7 +6,11 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.cs.allow-jit</key>
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.network.server</key>
|
<key>com.apple.security.network.server</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.print</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -4,5 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.print</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
120
pubspec.lock
120
pubspec.lock
@@ -137,14 +137,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
crypto:
|
connectivity_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: connectivity_plus
|
||||||
|
sha256: "762c99f890ca8bf87f7337236f99edd42793843bc6c3631da294a76653a54bd0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.3.1"
|
||||||
|
connectivity_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: connectivity_plus_platform_interface
|
||||||
|
sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
|
crypto:
|
||||||
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.13"
|
||||||
equatable:
|
equatable:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -153,6 +177,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
version: "2.1.0"
|
||||||
|
event_bus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: event_bus
|
||||||
|
sha256: "1a55e97923769c286d295240048fc180e7b0768902c3c2e869fe059aafa15304"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.1"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -169,6 +201,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
ffi_leak_tracker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ffi_leak_tracker
|
||||||
|
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.2"
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -214,6 +254,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.1"
|
version: "2.6.1"
|
||||||
|
flutter_secure_storage:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage
|
||||||
|
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.3.1"
|
||||||
|
flutter_secure_storage_darwin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_darwin
|
||||||
|
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.2"
|
||||||
|
flutter_secure_storage_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_linux
|
||||||
|
sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
|
flutter_secure_storage_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_platform_interface
|
||||||
|
sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.3"
|
||||||
|
flutter_secure_storage_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_web
|
||||||
|
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
|
flutter_secure_storage_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_windows
|
||||||
|
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.2.2"
|
||||||
flutter_shaders:
|
flutter_shaders:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -265,7 +353,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.0.2"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
@@ -384,6 +472,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.18.0"
|
||||||
|
mqtt_client:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: mqtt_client
|
||||||
|
sha256: "41c8edd3bc8efc80c1c8ebfb40081c24d12d13085faca96b9280a624eca2d893"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.11.11"
|
||||||
native_toolchain_c:
|
native_toolchain_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -392,6 +488,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.19.2"
|
version: "0.19.2"
|
||||||
|
nm:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: nm
|
||||||
|
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.0"
|
||||||
objective_c:
|
objective_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -625,10 +729,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqlite3
|
name: sqlite3
|
||||||
sha256: c73fd75df1332d76a6257f4823ae4df9c791f522b97e4a60cbcad214de1becf4
|
sha256: "64b2c63c8232dd20d14b34105a81ebfd74320442e8451f836179ec89986aa478"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.5.0"
|
version: "3.5.1"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -789,6 +893,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.4.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ dependencies:
|
|||||||
audioplayers: ^6.0.0
|
audioplayers: ^6.0.0
|
||||||
pdf: ^3.11.0
|
pdf: ^3.11.0
|
||||||
printing: ^5.13.0
|
printing: ^5.13.0
|
||||||
|
mqtt_client: ^10.11.11
|
||||||
|
connectivity_plus: ^7.3.1
|
||||||
|
http: ^1.6.0
|
||||||
|
crypto: ^3.0.7
|
||||||
|
flutter_secure_storage: ^10.3.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -49,3 +54,4 @@ flutter:
|
|||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
assets:
|
assets:
|
||||||
- assets/sounds/
|
- assets/sounds/
|
||||||
|
- assets/images/
|
||||||
|
|||||||
533
test/unit/catalogue_sync_test.dart
Normal file
533
test/unit/catalogue_sync_test.dart
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:nearle_pos/core/config/sync_config.dart';
|
||||||
|
import 'package:nearle_pos/data/datasources/local_store.dart';
|
||||||
|
import 'package:nearle_pos/data/datasources/seed_data.dart';
|
||||||
|
import 'package:nearle_pos/data/local/app_database.dart';
|
||||||
|
import 'package:nearle_pos/data/remote/catalogue_source.dart';
|
||||||
|
import 'package:nearle_pos/data/remote/catalogue_wire.dart';
|
||||||
|
import 'package:nearle_pos/data/remote/http_catalogue_source.dart';
|
||||||
|
import 'package:nearle_pos/data/repositories/product_repository_impl.dart';
|
||||||
|
import 'package:nearle_pos/data/repositories/customer_repository_impl.dart';
|
||||||
|
import 'package:nearle_pos/data/repositories/transaction_repository_impl.dart';
|
||||||
|
import 'package:nearle_pos/domain/entities/cart.dart';
|
||||||
|
import 'package:nearle_pos/domain/entities/customer.dart';
|
||||||
|
import 'package:nearle_pos/domain/entities/product.dart';
|
||||||
|
import 'package:nearle_pos/domain/entities/transaction.dart';
|
||||||
|
import 'package:nearle_pos/domain/usecases/checkout_sale.dart';
|
||||||
|
|
||||||
|
const _config = SyncConfig(
|
||||||
|
transport: TransportKind.http,
|
||||||
|
httpBaseUrl: 'https://api.example.com',
|
||||||
|
apiKey: 'k',
|
||||||
|
storeId: 'store-01',
|
||||||
|
terminalId: 'T4A9',
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object?> _product(String id, {double price = 50}) => {
|
||||||
|
'id': id,
|
||||||
|
'name': 'Product $id',
|
||||||
|
'barcode': 'bc-$id',
|
||||||
|
'sku': 'sku-$id',
|
||||||
|
'category': 'grocery',
|
||||||
|
'price': price,
|
||||||
|
'stock': 20,
|
||||||
|
'gst_rate': 0.05,
|
||||||
|
};
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('wire format', () {
|
||||||
|
test('a full record round-trips', () {
|
||||||
|
final product = CatalogueWire.productFromJson({
|
||||||
|
..._product('p1'),
|
||||||
|
'mrp': 60.0,
|
||||||
|
'emoji': '🍞',
|
||||||
|
'unit': 'kilogram',
|
||||||
|
'hsn_code': '1905',
|
||||||
|
'brand': 'Nearle',
|
||||||
|
'is_active': false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(product.id, 'p1');
|
||||||
|
expect(product.mrp, 60);
|
||||||
|
expect(product.unit, UnitOfMeasure.kilogram);
|
||||||
|
expect(product.hsnCode, '1905');
|
||||||
|
expect(product.isActive, isFalse);
|
||||||
|
|
||||||
|
final json = CatalogueWire.productToJson(product);
|
||||||
|
expect(CatalogueWire.productFromJson(json), product);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing optional fields take sensible defaults', () {
|
||||||
|
// A catalogue of 4,000 products must not fail to import over one absent
|
||||||
|
// emoji.
|
||||||
|
final product = CatalogueWire.productFromJson({
|
||||||
|
'id': 'p1',
|
||||||
|
'name': 'Bread',
|
||||||
|
'barcode': '890',
|
||||||
|
'price': 40.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(product.sku, 'p1', reason: 'falls back to the id');
|
||||||
|
expect(product.emoji, '📦');
|
||||||
|
expect(product.unit, UnitOfMeasure.piece);
|
||||||
|
expect(product.isActive, isTrue,
|
||||||
|
reason: 'an omitted flag is not a withdrawn catalogue',);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a record with no price or barcode is refused, not dropped', () {
|
||||||
|
// A silently dropped product is a shelf item that scans to nothing,
|
||||||
|
// discovered with a queue waiting.
|
||||||
|
expect(
|
||||||
|
() => CatalogueWire.productFromJson({'id': 'p1', 'name': 'X'}),
|
||||||
|
throwsA(isA<CatalogueFormatException>()),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => CatalogueWire.productFromJson({
|
||||||
|
'id': 'p1',
|
||||||
|
'name': 'X',
|
||||||
|
'barcode': '890',
|
||||||
|
}),
|
||||||
|
throwsA(isA<CatalogueFormatException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a GST rate is read the same whether sent as 18 or 0.18', () {
|
||||||
|
// Back offices disagree about which they mean, and getting it wrong
|
||||||
|
// silently changes the tax on every line.
|
||||||
|
double rate(Object? raw) => CatalogueWire.productFromJson({
|
||||||
|
..._product('p1'),
|
||||||
|
'gst_rate': raw,
|
||||||
|
}).gstRate;
|
||||||
|
|
||||||
|
expect(rate(18), 0.18);
|
||||||
|
expect(rate(0.18), 0.18);
|
||||||
|
expect(rate(5), 0.05);
|
||||||
|
expect(rate(0), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown category files under Grocery rather than failing', () {
|
||||||
|
// The item still scans, still prices, still bills. Refusing the whole
|
||||||
|
// import over a display detail would be far worse.
|
||||||
|
final product = CatalogueWire.productFromJson({
|
||||||
|
..._product('p1'),
|
||||||
|
'category': 'frozen-desserts',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(product.category, ProductCategory.grocery);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a category is matched by name or by label', () {
|
||||||
|
ProductCategory of(String raw) => CatalogueWire.productFromJson({
|
||||||
|
..._product('p1'),
|
||||||
|
'category': raw,
|
||||||
|
}).category;
|
||||||
|
|
||||||
|
expect(of('personalCare'), ProductCategory.personalCare);
|
||||||
|
expect(of('Personal Care'), ProductCategory.personalCare);
|
||||||
|
expect(of('BEVERAGES'), ProductCategory.beverages);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('customers carry their loyalty balance across', () {
|
||||||
|
final customer = CatalogueWire.customerFromJson({
|
||||||
|
'id': 'c1',
|
||||||
|
'name': 'Meena',
|
||||||
|
'mobile': '9840000001',
|
||||||
|
'loyalty_points': 420,
|
||||||
|
'lifetime_spend': 61000.0,
|
||||||
|
'last_visit_at': '2026-07-30T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(customer.loyaltyPoints, 420);
|
||||||
|
expect(customer.tier, MembershipTier.gold);
|
||||||
|
expect(customer.lastVisitAt, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a date is accepted as ISO text or epoch milliseconds', () {
|
||||||
|
DateTime? born(Object? raw) => CatalogueWire.customerFromJson({
|
||||||
|
'id': 'c1',
|
||||||
|
'name': 'M',
|
||||||
|
'mobile': '98',
|
||||||
|
'date_of_birth': raw,
|
||||||
|
}).dateOfBirth;
|
||||||
|
|
||||||
|
expect(born('1990-05-02'), DateTime(1990, 5, 2));
|
||||||
|
expect(born(641606400000), isNotNull);
|
||||||
|
expect(born('not a date'), isNull);
|
||||||
|
expect(born(null), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('http source', () {
|
||||||
|
HttpCatalogueSource sourceReturning(
|
||||||
|
List<Map<String, Object?>> pages, {
|
||||||
|
List<Uri>? record,
|
||||||
|
}) {
|
||||||
|
var index = 0;
|
||||||
|
return HttpCatalogueSource(
|
||||||
|
config: _config,
|
||||||
|
client: _FakeClient((request) {
|
||||||
|
record?.add(request.url);
|
||||||
|
final body = pages[index < pages.length ? index : pages.length - 1];
|
||||||
|
index++;
|
||||||
|
return http.Response(jsonEncode(body), 200);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a single page is read straight through', () async {
|
||||||
|
final source = sourceReturning([
|
||||||
|
{
|
||||||
|
'revision': 'rev-1',
|
||||||
|
'is_delta': false,
|
||||||
|
'has_more': false,
|
||||||
|
'products': [_product('p1'), _product('p2')],
|
||||||
|
'customers': [
|
||||||
|
{'id': 'c1', 'name': 'Meena', 'mobile': '9840000001'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
final snapshot = await source.fetch();
|
||||||
|
|
||||||
|
expect(snapshot.products, hasLength(2));
|
||||||
|
expect(snapshot.customers, hasLength(1));
|
||||||
|
expect(snapshot.revision, 'rev-1');
|
||||||
|
expect(snapshot.isDelta, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every page is followed until has_more stops', () async {
|
||||||
|
// A supermarket catalogue is tens of thousands of rows; a single response
|
||||||
|
// times out on a shop's line.
|
||||||
|
final source = sourceReturning([
|
||||||
|
{
|
||||||
|
'revision': 'rev-2',
|
||||||
|
'has_more': true,
|
||||||
|
'products': [_product('p1')],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'revision': 'rev-2',
|
||||||
|
'has_more': true,
|
||||||
|
'products': [_product('p2')],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'revision': 'rev-2',
|
||||||
|
'has_more': false,
|
||||||
|
'products': [_product('p3')],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
final snapshot = await source.fetch();
|
||||||
|
|
||||||
|
expect(snapshot.products.map((p) => p.id), ['p1', 'p2', 'p3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the revision already held is sent as `since`', () async {
|
||||||
|
final urls = <Uri>[];
|
||||||
|
final source = sourceReturning(
|
||||||
|
[
|
||||||
|
{'revision': 'rev-9', 'is_delta': true, 'has_more': false},
|
||||||
|
],
|
||||||
|
record: urls,
|
||||||
|
);
|
||||||
|
|
||||||
|
await source.fetch(since: 'rev-8');
|
||||||
|
|
||||||
|
expect(urls.single.queryParameters['since'], 'rev-8');
|
||||||
|
expect(urls.single.queryParameters['terminal_id'], 'T4A9');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a delta is flagged as one, and carries its withdrawals', () async {
|
||||||
|
final source = sourceReturning([
|
||||||
|
{
|
||||||
|
'revision': 'rev-9',
|
||||||
|
'is_delta': true,
|
||||||
|
'has_more': false,
|
||||||
|
'products': [_product('p1', price: 55)],
|
||||||
|
'retired_product_ids': ['p7', 'p8'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
final snapshot = await source.fetch(since: 'rev-8');
|
||||||
|
|
||||||
|
expect(snapshot.isDelta, isTrue);
|
||||||
|
expect(snapshot.retiredProductIds, ['p7', 'p8']);
|
||||||
|
expect(snapshot.changeCount, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty delta means already up to date', () async {
|
||||||
|
final source = sourceReturning([
|
||||||
|
{'revision': 'rev-8', 'is_delta': true, 'has_more': false},
|
||||||
|
]);
|
||||||
|
|
||||||
|
final snapshot = await source.fetch(since: 'rev-8');
|
||||||
|
expect(snapshot.isEmpty, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a bad credential is not retryable', () async {
|
||||||
|
final source = HttpCatalogueSource(
|
||||||
|
config: _config,
|
||||||
|
client: _FakeClient((_) => http.Response('nope', 401)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
source.fetch(),
|
||||||
|
throwsA(isA<CatalogueSyncException>()
|
||||||
|
.having((e) => e.retryable, 'retryable', isFalse),),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a server error is retryable', () async {
|
||||||
|
final source = HttpCatalogueSource(
|
||||||
|
config: _config,
|
||||||
|
client: _FakeClient((_) => http.Response('boom', 503)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
source.fetch(),
|
||||||
|
throwsA(isA<CatalogueSyncException>()
|
||||||
|
.having((e) => e.retryable, 'retryable', isTrue),),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unconfigured endpoint fails fast', () async {
|
||||||
|
final source = HttpCatalogueSource(
|
||||||
|
config: const SyncConfig(transport: TransportKind.http),
|
||||||
|
client: _FakeClient((_) => http.Response('{}', 200)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
source.fetch(),
|
||||||
|
throwsA(isA<CatalogueSyncException>()
|
||||||
|
.having((e) => e.retryable, 'retryable', isFalse),),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a server that never stops paging is cut off', () async {
|
||||||
|
// A bad deployment must not become an infinite request loop against a
|
||||||
|
// shop's connection.
|
||||||
|
var calls = 0;
|
||||||
|
final source = HttpCatalogueSource(
|
||||||
|
config: _config,
|
||||||
|
client: _FakeClient((_) {
|
||||||
|
calls++;
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'revision': 'r', 'has_more': true, 'products': []}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(source.fetch(), throwsA(isA<CatalogueSyncException>()));
|
||||||
|
expect(calls, lessThanOrEqualTo(HttpCatalogueSource.maxPages + 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one malformed product fails the import rather than vanishing',
|
||||||
|
() async {
|
||||||
|
final source = sourceReturning([
|
||||||
|
{
|
||||||
|
'revision': 'rev-1',
|
||||||
|
'has_more': false,
|
||||||
|
'products': [
|
||||||
|
_product('p1'),
|
||||||
|
{'id': 'p2', 'name': 'No barcode'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
source.fetch(),
|
||||||
|
throwsA(isA<CatalogueFormatException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('applying to the terminal', () {
|
||||||
|
late LocalStore store;
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
LocalStore.registerSeed(
|
||||||
|
products: SeedData.products,
|
||||||
|
customers: SeedData.customers,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
store = LocalStore.instance;
|
||||||
|
await store.reset(withCatalogue: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a delta leaves everything it does not mention alone', () async {
|
||||||
|
// Applied as a full snapshot, the first morning price change would empty
|
||||||
|
// the shelf.
|
||||||
|
final before = store.products.length;
|
||||||
|
final milk = store.products.firstWhere((p) => p.barcode == '8901234500011');
|
||||||
|
|
||||||
|
await store.importCatalogue(
|
||||||
|
products: [milk.copyWith(price: 71)],
|
||||||
|
customers: const [],
|
||||||
|
revision: 'rev-2',
|
||||||
|
at: DateTime.now(),
|
||||||
|
isDelta: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(store.products, hasLength(before));
|
||||||
|
expect(store.productById(milk.id)!.price, 71);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a full snapshot withdraws what it omits', () async {
|
||||||
|
final milk = store.products.firstWhere((p) => p.barcode == '8901234500011');
|
||||||
|
|
||||||
|
await store.importCatalogue(
|
||||||
|
products: [milk],
|
||||||
|
customers: const [],
|
||||||
|
revision: 'rev-3',
|
||||||
|
at: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(store.products, hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a retired product is withdrawn, not deleted', () async {
|
||||||
|
// An order line already recorded points at it; a hard delete would orphan
|
||||||
|
// a bill's history.
|
||||||
|
final milk = store.products.firstWhere((p) => p.barcode == '8901234500011');
|
||||||
|
|
||||||
|
await store.importCatalogue(
|
||||||
|
products: const [],
|
||||||
|
customers: const [],
|
||||||
|
revision: 'rev-4',
|
||||||
|
at: DateTime.now(),
|
||||||
|
isDelta: true,
|
||||||
|
retiredProductIds: [milk.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(store.productByBarcode('8901234500011'), isNull,
|
||||||
|
reason: 'a withdrawn product must not scan',);
|
||||||
|
|
||||||
|
// The cache holds only what is sellable, so the surviving row has to be
|
||||||
|
// checked on disk — which is the point: an order line already recorded
|
||||||
|
// still resolves to a real product.
|
||||||
|
final rows = await AppDatabase.instance.db.query(
|
||||||
|
'products',
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [milk.id],
|
||||||
|
);
|
||||||
|
expect(rows, hasLength(1));
|
||||||
|
expect(rows.single['is_active'], 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a locally added shopper survives a pull', () async {
|
||||||
|
final customers = CustomerRepositoryImpl(store);
|
||||||
|
final walkIn = await customers.create(const Customer(
|
||||||
|
id: 'ignored',
|
||||||
|
name: 'Local Only',
|
||||||
|
mobile: '9000000077',
|
||||||
|
),);
|
||||||
|
|
||||||
|
await store.importCatalogue(
|
||||||
|
products: const [],
|
||||||
|
customers: const [],
|
||||||
|
revision: 'rev-5',
|
||||||
|
at: DateTime.now(),
|
||||||
|
isDelta: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await customers.findById(walkIn.id), isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a delta does not subtract already-sold stock a second time',
|
||||||
|
() async {
|
||||||
|
// The replay exists because a *full* pull overwrites stock with a server
|
||||||
|
// figure that predates local sales. Running it over a delta that never
|
||||||
|
// carried that product would quietly empty a shelf that is full.
|
||||||
|
final products = ProductRepositoryImpl(store);
|
||||||
|
final checkout = CheckoutSale(
|
||||||
|
productRepository: products,
|
||||||
|
customerRepository: CustomerRepositoryImpl(store),
|
||||||
|
transactionRepository: TransactionRepositoryImpl(store),
|
||||||
|
);
|
||||||
|
|
||||||
|
final milk = (await products.findByBarcode('8901234500011'))!;
|
||||||
|
final opening = milk.stock;
|
||||||
|
|
||||||
|
await checkout(
|
||||||
|
cart: Cart(lines: [CartLine(product: milk, quantity: 4)]),
|
||||||
|
payments: const [
|
||||||
|
PaymentSplit(method: PaymentMethod.cash, amount: 248, tendered: 250),
|
||||||
|
],
|
||||||
|
cashierName: 'Divya',
|
||||||
|
terminalId: 'T0TEST',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await products.findById(milk.id))!.stock, opening - 4);
|
||||||
|
|
||||||
|
// A delta about a *different* product must not touch the milk count.
|
||||||
|
final other = store.products.firstWhere((p) => p.id != milk.id);
|
||||||
|
await store.importCatalogue(
|
||||||
|
products: [other.copyWith(price: other.price + 1)],
|
||||||
|
customers: const [],
|
||||||
|
revision: 'rev-6',
|
||||||
|
at: DateTime.now(),
|
||||||
|
isDelta: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await products.findById(milk.id))!.stock, opening - 4,
|
||||||
|
reason: 'the sold units must not be subtracted again',);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a delta carrying the sold product replays its committed stock',
|
||||||
|
() async {
|
||||||
|
// The other half of the same rule: when the server *does* send a fresh
|
||||||
|
// count for a product, that figure predates the local sale and the units
|
||||||
|
// would otherwise reappear on the shelf.
|
||||||
|
final products = ProductRepositoryImpl(store);
|
||||||
|
final checkout = CheckoutSale(
|
||||||
|
productRepository: products,
|
||||||
|
customerRepository: CustomerRepositoryImpl(store),
|
||||||
|
transactionRepository: TransactionRepositoryImpl(store),
|
||||||
|
);
|
||||||
|
|
||||||
|
final milk = (await products.findByBarcode('8901234500011'))!;
|
||||||
|
final opening = milk.stock;
|
||||||
|
|
||||||
|
await checkout(
|
||||||
|
cart: Cart(lines: [CartLine(product: milk, quantity: 4)]),
|
||||||
|
payments: const [
|
||||||
|
PaymentSplit(method: PaymentMethod.cash, amount: 248, tendered: 250),
|
||||||
|
],
|
||||||
|
cashierName: 'Divya',
|
||||||
|
terminalId: 'T0TEST',
|
||||||
|
);
|
||||||
|
|
||||||
|
await store.importCatalogue(
|
||||||
|
products: [milk],
|
||||||
|
customers: const [],
|
||||||
|
revision: 'rev-7',
|
||||||
|
at: DateTime.now(),
|
||||||
|
isDelta: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await products.findById(milk.id))!.stock, opening - 4,
|
||||||
|
reason: 'the server count is stale by exactly the unsynced sales',);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeClient extends http.BaseClient {
|
||||||
|
_FakeClient(this.respond);
|
||||||
|
|
||||||
|
final http.Response Function(http.BaseRequest) respond;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||||
|
final response = respond(request);
|
||||||
|
return http.StreamedResponse(
|
||||||
|
Stream.value(utf8.encode(response.body)),
|
||||||
|
response.statusCode,
|
||||||
|
request: request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ void main() {
|
|||||||
PaymentSplit(method: PaymentMethod.cash, amount: 124, tendered: 200),
|
PaymentSplit(method: PaymentMethod.cash, amount: 124, tendered: 200),
|
||||||
],
|
],
|
||||||
cashierName: 'Suriya',
|
cashierName: 'Suriya',
|
||||||
|
terminalId: 'T0TEST',
|
||||||
);
|
);
|
||||||
|
|
||||||
final txn = result.transaction;
|
final txn = result.transaction;
|
||||||
@@ -83,6 +84,7 @@ void main() {
|
|||||||
PaymentSplit(method: PaymentMethod.cash, amount: 124, tendered: 124),
|
PaymentSplit(method: PaymentMethod.cash, amount: 124, tendered: 124),
|
||||||
],
|
],
|
||||||
cashierName: 'Suriya',
|
cashierName: 'Suriya',
|
||||||
|
terminalId: 'T0TEST',
|
||||||
);
|
);
|
||||||
|
|
||||||
final milk = await products.findByBarcode('8901234500011');
|
final milk = await products.findByBarcode('8901234500011');
|
||||||
@@ -98,6 +100,7 @@ void main() {
|
|||||||
PaymentSplit(method: PaymentMethod.cash, amount: 124, tendered: 124),
|
PaymentSplit(method: PaymentMethod.cash, amount: 124, tendered: 124),
|
||||||
],
|
],
|
||||||
cashierName: 'Suriya',
|
cashierName: 'Suriya',
|
||||||
|
terminalId: 'T0TEST',
|
||||||
);
|
);
|
||||||
|
|
||||||
final history = await transactions.history();
|
final history = await transactions.history();
|
||||||
@@ -119,6 +122,7 @@ void main() {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
cashierName: 'Suriya',
|
cashierName: 'Suriya',
|
||||||
|
terminalId: 'T0TEST',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.transaction.isSplit, isTrue);
|
expect(result.transaction.isSplit, isTrue);
|
||||||
@@ -140,6 +144,7 @@ void main() {
|
|||||||
PaymentSplit(method: PaymentMethod.cash, amount: 122, tendered: 200),
|
PaymentSplit(method: PaymentMethod.cash, amount: 122, tendered: 200),
|
||||||
],
|
],
|
||||||
cashierName: 'Suriya',
|
cashierName: 'Suriya',
|
||||||
|
terminalId: 'T0TEST',
|
||||||
);
|
);
|
||||||
|
|
||||||
final updated = result.updatedCustomer!;
|
final updated = result.updatedCustomer!;
|
||||||
@@ -158,6 +163,7 @@ void main() {
|
|||||||
PaymentSplit(method: PaymentMethod.cash, amount: 0),
|
PaymentSplit(method: PaymentMethod.cash, amount: 0),
|
||||||
],
|
],
|
||||||
cashierName: 'Suriya',
|
cashierName: 'Suriya',
|
||||||
|
terminalId: 'T0TEST',
|
||||||
),
|
),
|
||||||
throwsA(isA<CheckoutFailure>()),
|
throwsA(isA<CheckoutFailure>()),
|
||||||
);
|
);
|
||||||
@@ -166,7 +172,12 @@ void main() {
|
|||||||
test('refuses a bill with no tender', () async {
|
test('refuses a bill with no tender', () async {
|
||||||
final cart = await milkCart();
|
final cart = await milkCart();
|
||||||
expect(
|
expect(
|
||||||
() => checkout(cart: cart, payments: const [], cashierName: 'Suriya'),
|
() => checkout(
|
||||||
|
cart: cart,
|
||||||
|
payments: const [],
|
||||||
|
cashierName: 'Suriya',
|
||||||
|
terminalId: 'T0TEST',
|
||||||
|
),
|
||||||
throwsA(isA<CheckoutFailure>()),
|
throwsA(isA<CheckoutFailure>()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -180,6 +191,7 @@ void main() {
|
|||||||
PaymentSplit(method: PaymentMethod.cash, amount: 100, tendered: 100),
|
PaymentSplit(method: PaymentMethod.cash, amount: 100, tendered: 100),
|
||||||
],
|
],
|
||||||
cashierName: 'Suriya',
|
cashierName: 'Suriya',
|
||||||
|
terminalId: 'T0TEST',
|
||||||
),
|
),
|
||||||
throwsA(isA<CheckoutFailure>()),
|
throwsA(isA<CheckoutFailure>()),
|
||||||
);
|
);
|
||||||
@@ -198,6 +210,7 @@ void main() {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
cashierName: 'Suriya',
|
cashierName: 'Suriya',
|
||||||
|
terminalId: 'T0TEST',
|
||||||
),
|
),
|
||||||
throwsA(isA<CheckoutFailure>()),
|
throwsA(isA<CheckoutFailure>()),
|
||||||
);
|
);
|
||||||
@@ -206,7 +219,12 @@ void main() {
|
|||||||
test('leaves stock untouched when validation fails', () async {
|
test('leaves stock untouched when validation fails', () async {
|
||||||
final cart = await milkCart();
|
final cart = await milkCart();
|
||||||
try {
|
try {
|
||||||
await checkout(cart: cart, payments: const [], cashierName: 'Suriya');
|
await checkout(
|
||||||
|
cart: cart,
|
||||||
|
payments: const [],
|
||||||
|
cashierName: 'Suriya',
|
||||||
|
terminalId: 'T0TEST',
|
||||||
|
);
|
||||||
} on CheckoutFailure {
|
} on CheckoutFailure {
|
||||||
// expected
|
// expected
|
||||||
}
|
}
|
||||||
|
|||||||
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 {}
|
||||||
|
}
|
||||||
175
test/unit/fleet_identity_test.dart
Normal file
175
test/unit/fleet_identity_test.dart
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:nearle_pos/core/config/sync_config.dart';
|
||||||
|
import 'package:nearle_pos/core/utils/formatters.dart';
|
||||||
|
import 'package:nearle_pos/data/datasources/local_store.dart';
|
||||||
|
import 'package:nearle_pos/data/datasources/seed_data.dart';
|
||||||
|
import 'package:nearle_pos/data/local/app_database.dart';
|
||||||
|
import 'package:nearle_pos/data/local/terminal_identity.dart';
|
||||||
|
import 'package:nearle_pos/data/repositories/customer_repository_impl.dart';
|
||||||
|
import 'package:nearle_pos/data/repositories/product_repository_impl.dart';
|
||||||
|
import 'package:nearle_pos/data/repositories/transaction_repository_impl.dart';
|
||||||
|
import 'package:nearle_pos/domain/entities/cart.dart';
|
||||||
|
import 'package:nearle_pos/domain/entities/transaction.dart';
|
||||||
|
import 'package:nearle_pos/domain/usecases/checkout_sale.dart';
|
||||||
|
|
||||||
|
/// What has to hold when the same build is installed on 100 tills.
|
||||||
|
///
|
||||||
|
/// Every one of these was broken: the terminal id was the literal `TERM-01` in
|
||||||
|
/// five places, so the whole fleet shared one identity, one set of MQTT topics
|
||||||
|
/// and one invoice series.
|
||||||
|
void main() {
|
||||||
|
late LocalStore store;
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
LocalStore.registerSeed(
|
||||||
|
products: SeedData.products,
|
||||||
|
customers: SeedData.customers,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
store = LocalStore.instance;
|
||||||
|
await store.reset(withCatalogue: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('identity', () {
|
||||||
|
test('a device mints an identity on first run and keeps it', () async {
|
||||||
|
final identityStore = TerminalIdentityStore(store.catalogue);
|
||||||
|
|
||||||
|
final first = await identityStore.load();
|
||||||
|
expect(first.deviceId, isNotEmpty);
|
||||||
|
expect(first.code, startsWith('T'));
|
||||||
|
|
||||||
|
// A second read must not re-mint. If it did, a terminal would change
|
||||||
|
// identity on every restart and orphan the bills it had already written.
|
||||||
|
final second = await identityStore.load();
|
||||||
|
expect(second.deviceId, first.deviceId);
|
||||||
|
expect(second.code, first.code);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two devices get different codes', () {
|
||||||
|
// Derived from the device UUID rather than a counter, because there is no
|
||||||
|
// shared counter to draw from — each till mints alone and offline.
|
||||||
|
final a = TerminalIdentityStore.codeFor(
|
||||||
|
'a1b2c3d4-0000-0000-0000-000000000000',
|
||||||
|
);
|
||||||
|
final b = TerminalIdentityStore.codeFor(
|
||||||
|
'9f8e7d6c-0000-0000-0000-000000000000',
|
||||||
|
);
|
||||||
|
expect(a, 'TA1B2');
|
||||||
|
expect(b, 'T9F8E');
|
||||||
|
expect(a, isNot(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renaming keeps the device id, so history still points at the till',
|
||||||
|
() async {
|
||||||
|
final identityStore = TerminalIdentityStore(store.catalogue);
|
||||||
|
final before = await identityStore.load();
|
||||||
|
|
||||||
|
await identityStore.rename(code: 'till7', name: 'Counter 7');
|
||||||
|
final after = await identityStore.load();
|
||||||
|
|
||||||
|
expect(after.code, 'TILL7');
|
||||||
|
expect(after.name, 'Counter 7');
|
||||||
|
expect(after.deviceId, before.deviceId,
|
||||||
|
reason: 'the machine identity must survive a re-code',);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the identity is loaded by the store before anything is written',
|
||||||
|
() async {
|
||||||
|
expect(store.isReady, isTrue);
|
||||||
|
expect(store.terminal.deviceId, isNotEmpty);
|
||||||
|
expect(store.terminal.code, startsWith('T'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('invoice numbers', () {
|
||||||
|
test('two terminals ringing their first sale do not collide', () {
|
||||||
|
// The counter lives in each till's own database, so without the terminal
|
||||||
|
// code every device in the fleet mints INV-2608-00001 for its first sale.
|
||||||
|
final date = DateTime(2026, 8, 1);
|
||||||
|
final onTillA = Formatters.invoiceNumber(1, date, terminalCode: 'TA1B2');
|
||||||
|
final onTillB = Formatters.invoiceNumber(1, date, terminalCode: 'T9F8E');
|
||||||
|
|
||||||
|
expect(onTillA, 'INV-2608-TA1B2-00001');
|
||||||
|
expect(onTillA, isNot(onTillB));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a real sale carries this terminal\'s code', () async {
|
||||||
|
final products = ProductRepositoryImpl(store);
|
||||||
|
final checkout = CheckoutSale(
|
||||||
|
productRepository: products,
|
||||||
|
customerRepository: CustomerRepositoryImpl(store),
|
||||||
|
transactionRepository: TransactionRepositoryImpl(store),
|
||||||
|
);
|
||||||
|
|
||||||
|
final milk = (await products.findByBarcode('8901234500011'))!;
|
||||||
|
final result = await checkout(
|
||||||
|
cart: Cart(lines: [CartLine(product: milk, quantity: 1)]),
|
||||||
|
payments: const [
|
||||||
|
PaymentSplit(method: PaymentMethod.cash, amount: 62, tendered: 100),
|
||||||
|
],
|
||||||
|
cashierName: 'Divya',
|
||||||
|
terminalId: store.terminal.code,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.transaction.invoiceNumber, contains(store.terminal.code));
|
||||||
|
expect(result.transaction.terminalId, store.terminal.code);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('topics', () {
|
||||||
|
test('two terminals in one store publish on different topics', () {
|
||||||
|
const a = SyncConfig(storeId: 'store-01', terminalId: 'TA1B2');
|
||||||
|
const b = SyncConfig(storeId: 'store-01', terminalId: 'T9F8E');
|
||||||
|
|
||||||
|
expect(a.orderTopic, isNot(b.orderTopic));
|
||||||
|
expect(a.statusTopic, isNot(b.statusTopic));
|
||||||
|
// A shared client id would evict the other till from the broker on every
|
||||||
|
// connect, in a loop.
|
||||||
|
expect(a.clientId, isNot(b.clientId));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the catalogue topic is shared, because a price change is store-wide',
|
||||||
|
() {
|
||||||
|
const a = SyncConfig(storeId: 'store-01', terminalId: 'TA1B2');
|
||||||
|
const b = SyncConfig(storeId: 'store-01', terminalId: 'T9F8E');
|
||||||
|
expect(a.catalogueTopic, b.catalogueTopic);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('topics translate to NATS subjects', () {
|
||||||
|
// NATS' MQTT gateway maps / to . — this is what a JetStream consumer
|
||||||
|
// binds to.
|
||||||
|
const config = SyncConfig(storeId: 'store-01', terminalId: 'TA1B2');
|
||||||
|
expect(
|
||||||
|
SyncConfig.asNatsSubject(config.orderTopic),
|
||||||
|
'nearle.pos.store-01.TA1B2.order',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
SyncConfig.asNatsSubject(config.statusTopic),
|
||||||
|
'nearle.pos.store-01.TA1B2.status',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
SyncConfig.asNatsSubject(config.healthTopic),
|
||||||
|
'nearle.pos.store-01.TA1B2.health',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('database pragmas', () {
|
||||||
|
test('foreign keys are enforced', () async {
|
||||||
|
final rows =
|
||||||
|
await AppDatabase.instance.db.rawQuery('PRAGMA foreign_keys');
|
||||||
|
expect(rows.first.values.first, 1,
|
||||||
|
reason: 'order_items must cascade with their order',);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a contended lock waits instead of throwing', () async {
|
||||||
|
// Default is 0, which surfaces at checkout as "database is locked" —
|
||||||
|
// a failed sale with a customer standing there.
|
||||||
|
final rows =
|
||||||
|
await AppDatabase.instance.db.rawQuery('PRAGMA busy_timeout');
|
||||||
|
expect(rows.first.values.first, greaterThanOrEqualTo(5000));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
156
test/unit/hardware_config_test.dart
Normal file
156
test/unit/hardware_config_test.dart
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:nearle_pos/core/config/sync_config.dart';
|
||||||
|
import 'package:nearle_pos/core/services/cash_drawer_service.dart';
|
||||||
|
import 'package:nearle_pos/data/datasources/local_store.dart';
|
||||||
|
import 'package:nearle_pos/data/datasources/seed_data.dart';
|
||||||
|
import 'package:nearle_pos/data/local/sync_config_store.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(() {
|
||||||
|
LocalStore.registerSeed(
|
||||||
|
products: SeedData.products,
|
||||||
|
customers: SeedData.customers,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('cash drawer', () {
|
||||||
|
test('sends the ESC/POS kick to the printer', () async {
|
||||||
|
// Previously a debugPrint, so the drawer never opened. The PDF pipeline
|
||||||
|
// cannot carry these bytes — the platform driver renders a document, it
|
||||||
|
// does not pass raw commands through — so this goes over a socket.
|
||||||
|
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
final received = Completer<List<int>>();
|
||||||
|
|
||||||
|
server.listen((socket) {
|
||||||
|
socket.listen((bytes) {
|
||||||
|
if (!received.isCompleted) received.complete(bytes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
final result = await CashDrawerService()
|
||||||
|
.open(host: server.address.address, port: server.port);
|
||||||
|
|
||||||
|
expect(result, DrawerResult.opened);
|
||||||
|
expect(await received.future, CashDrawerService.kickCommand);
|
||||||
|
// ESC p 0 25 250 — pin 2, the near-universal wiring.
|
||||||
|
expect(CashDrawerService.kickCommand, [27, 112, 0, 25, 250]);
|
||||||
|
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a blank address is not an error', () async {
|
||||||
|
// Plenty of shops take card only, or open the drawer by hand. A USB
|
||||||
|
// printer also has no raw path from Flutter, so this is the honest state
|
||||||
|
// rather than a failure to report.
|
||||||
|
expect(await CashDrawerService().open(), DrawerResult.notConfigured);
|
||||||
|
expect(
|
||||||
|
await CashDrawerService().open(host: ' '),
|
||||||
|
DrawerResult.notConfigured,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unreachable printer reports it instead of hanging the sale',
|
||||||
|
() async {
|
||||||
|
// Port 1 on loopback refuses immediately.
|
||||||
|
final result =
|
||||||
|
await CashDrawerService().open(host: '127.0.0.1', port: 1);
|
||||||
|
|
||||||
|
expect(result, DrawerResult.unreachable);
|
||||||
|
expect(result.isSuccess, isFalse);
|
||||||
|
expect(result.message, contains('Could not reach'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every outcome explains itself to the cashier', () {
|
||||||
|
for (final result in DrawerResult.values) {
|
||||||
|
expect(result.message, isNotEmpty);
|
||||||
|
}
|
||||||
|
expect(DrawerResult.opened.isSuccess, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('sync configuration', () {
|
||||||
|
late LocalStore store;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
store = LocalStore.instance;
|
||||||
|
await store.reset(withCatalogue: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the route survives a restart', () async {
|
||||||
|
// Held only in memory these had to be retyped after every restart, which
|
||||||
|
// on a shop-floor terminal means they end up on a sticky note instead.
|
||||||
|
const configured = SyncConfig(
|
||||||
|
transport: TransportKind.mqtt,
|
||||||
|
brokerHost: 'nats.example.com',
|
||||||
|
brokerPort: 1883,
|
||||||
|
useTls: false,
|
||||||
|
storeId: 'store-77',
|
||||||
|
terminalId: 'T9A1',
|
||||||
|
);
|
||||||
|
|
||||||
|
await store.syncConfig.save(configured);
|
||||||
|
|
||||||
|
// A fresh store object, as if the app had been relaunched.
|
||||||
|
final reloaded = await SyncConfigStore(store.catalogue)
|
||||||
|
.load(const SyncConfig(storeId: 'store-77', terminalId: 'T9A1'));
|
||||||
|
|
||||||
|
expect(reloaded.transport, TransportKind.mqtt);
|
||||||
|
expect(reloaded.brokerHost, 'nats.example.com');
|
||||||
|
expect(reloaded.brokerPort, 1883);
|
||||||
|
expect(reloaded.useTls, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no credential is written to the database', () async {
|
||||||
|
// The whole reason they go to the platform keystore. SQLite holds the
|
||||||
|
// bills, on a machine behind a shop counter.
|
||||||
|
await store.syncConfig.save(const SyncConfig(
|
||||||
|
transport: TransportKind.mqtt,
|
||||||
|
brokerHost: 'nats.example.com',
|
||||||
|
username: 'till-04',
|
||||||
|
password: 'sup3rs3cret',
|
||||||
|
apiKey: 'ak_live_9f21',
|
||||||
|
),);
|
||||||
|
|
||||||
|
final rows = await store.catalogue.allMeta();
|
||||||
|
final everything = rows.entries.map((e) => '${e.key}=${e.value}').join('|');
|
||||||
|
|
||||||
|
expect(everything, isNot(contains('sup3rs3cret')));
|
||||||
|
expect(everything, isNot(contains('ak_live_9f21')));
|
||||||
|
// Non-secret settings are expected to be there — that is the point of
|
||||||
|
// the split.
|
||||||
|
expect(everything, contains('nats.example.com'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the terminal identity is never overwritten by a saved route',
|
||||||
|
() async {
|
||||||
|
// Store and terminal ids belong to the device. Re-pointing a till at a
|
||||||
|
// different broker must not change who it is, or its bills and its
|
||||||
|
// presence records stop lining up.
|
||||||
|
await store.syncConfig.save(const SyncConfig(
|
||||||
|
transport: TransportKind.http,
|
||||||
|
httpBaseUrl: 'https://api.example.com',
|
||||||
|
storeId: 'wrong-store',
|
||||||
|
terminalId: 'WRONG',
|
||||||
|
),);
|
||||||
|
|
||||||
|
final reloaded = await SyncConfigStore(store.catalogue).load(
|
||||||
|
const SyncConfig(storeId: 'store-01', terminalId: 'T4A9'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reloaded.storeId, 'store-01');
|
||||||
|
expect(reloaded.terminalId, 'T4A9');
|
||||||
|
expect(reloaded.httpBaseUrl, 'https://api.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unconfigured terminal falls back rather than failing', () async {
|
||||||
|
final loaded = await SyncConfigStore(store.catalogue)
|
||||||
|
.load(const SyncConfig());
|
||||||
|
|
||||||
|
expect(loaded.transport, TransportKind.simulated);
|
||||||
|
expect(loaded.isConfigured, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user