Merge branch 'fix/billing-data-integrity'
Eight commits taking the terminal from a demo to something a fleet can run. - Bills drain to the back office automatically over MQTT or HTTP, with application-level acknowledgement, backoff and a 7-day recovery window. - Every terminal has its own identity, so 100 devices no longer share one set of MQTT topics, one client id and one invoice series. - Staff PINs moved out of the shipped binary into PBKDF2 hashes. - Cash drawer, store details, staff management and promos all built for real rather than mocked. - The catalogue now pulls from a real endpoint with paged delta sync. 234 tests passing, analyzer clean.
This commit is contained in:
251
docs/sync-contract.md
Normal file
251
docs/sync-contract.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# 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 |
|
||||
|---|---|---|---|
|
||||
| `pos/{store}/{terminal}/order` | till → cloud | 1 | no |
|
||||
| `pos/{store}/{terminal}/ack` | cloud → till | 1 | no |
|
||||
| `pos/{store}/{terminal}/status` | till → cloud | 1 | **yes** |
|
||||
| `pos/{store}/{terminal}/command` | cloud → till | 1 | no |
|
||||
| `pos/{store}/catalogue` | cloud → all tills | 1 | **yes** |
|
||||
|
||||
`{store}` and `{terminal}` come from the device's own identity, minted on first
|
||||
run and stored in its database. They are never literals — 100 tills sharing one
|
||||
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 NATS
|
||||
|
||||
The MQTT gateway maps `/` to `.`, so the topics above arrive as subjects and a
|
||||
JetStream consumer binds to them directly:
|
||||
|
||||
| Purpose | Subject |
|
||||
|---|---|
|
||||
| Every till's bills | `pos.*.*.order` |
|
||||
| Every till's presence | `pos.*.*.status` |
|
||||
| One store's bills | `pos.store-01.*.order` |
|
||||
| Ack back to one till | `pos.store-01.T4A9.ack` |
|
||||
|
||||
`SyncConfig.asNatsSubject()` does the translation, so a consumer's subject can
|
||||
be read off the terminal rather than guessed.
|
||||
|
||||
Two things to get right on the NATS side:
|
||||
|
||||
- **The stream must be durable and file-backed.** A memory stream loses a shop's
|
||||
bills on a server restart, and the till has already been told they landed.
|
||||
- **Publish the ack from the consumer, after the database commit** — not from an
|
||||
ingest handler that has merely queued the work. The ack is the terminal's
|
||||
only evidence, and it deletes its copy seven days later on the strength of it.
|
||||
|
||||
### 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** — `pos/{store}/{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** — `pos/{store}/{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.
|
||||
|
||||
## 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 `pos/{store}/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.
|
||||
- **Credentials survive a restart.** The back-office dialog writes the broker
|
||||
host, port, TLS flag and credentials into `syncConfigProvider`, which is
|
||||
in-memory. Terminal name and store id persist (they live in the database);
|
||||
the credentials do not, and must be re-entered after a restart. Persisting
|
||||
them means encrypting them at rest, which is the next piece of work.
|
||||
- **Historical correction.** Bills already synced by an older build went up
|
||||
with an overstated total. Nothing here fixes that; it needs a server-side
|
||||
reconciliation against `bill_discount`.
|
||||
- **Pushing customers upward.** A shopper registered at the till stays on that
|
||||
terminal and rides along on the bills they appear on. There is no
|
||||
customer-create endpoint yet, so two terminals registering the same mobile
|
||||
number will each hold their own row until the back office reconciles them.
|
||||
@@ -4,12 +4,17 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/constants/app_constants.dart';
|
||||
import '../core/router/app_router.dart';
|
||||
import '../core/theme/app_theme.dart';
|
||||
import '../presentation/sync/providers/sync_controller.dart';
|
||||
|
||||
class NearlePosApp extends ConsumerWidget {
|
||||
const NearlePosApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Watched, not awaited: the till opens immediately and the queue drains
|
||||
// behind it. Nothing on screen depends on this having finished.
|
||||
ref.watch(syncBootstrapProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: AppConstants.appName,
|
||||
debugShowCheckedModeBanner: false,
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/config/sync_config.dart';
|
||||
import '../core/services/connectivity_service.dart';
|
||||
import '../core/services/receipt_service.dart';
|
||||
import '../core/services/sound_service.dart';
|
||||
import '../data/datasources/local_store.dart';
|
||||
import '../data/repositories/customer_repository_impl.dart';
|
||||
import '../data/repositories/product_repository_impl.dart';
|
||||
import '../data/datasources/remote_catalogue_source.dart';
|
||||
import '../data/remote/catalogue_source.dart';
|
||||
import '../data/remote/http_catalogue_source.dart';
|
||||
import '../data/remote/simulated_catalogue_source.dart';
|
||||
import '../data/remote/http_order_transport.dart';
|
||||
import '../data/remote/mqtt_order_transport.dart';
|
||||
import '../data/remote/order_transport.dart';
|
||||
import '../data/remote/simulated_order_transport.dart';
|
||||
import '../data/repositories/store_repository_impl.dart';
|
||||
import '../data/repositories/sync_repository_impl.dart';
|
||||
import '../data/repositories/transaction_repository_impl.dart';
|
||||
import '../data/local/terminal_identity.dart';
|
||||
import '../data/sync/sync_engine.dart';
|
||||
import '../domain/repositories/customer_repository.dart';
|
||||
import '../domain/repositories/product_repository.dart';
|
||||
import '../domain/repositories/sync_repository.dart';
|
||||
import '../domain/repositories/transaction_repository.dart';
|
||||
import '../domain/entities/promo.dart';
|
||||
import '../domain/entities/store_account.dart';
|
||||
import '../domain/usecases/checkout_sale.dart';
|
||||
import '../presentation/auth/providers/auth_controller.dart';
|
||||
|
||||
/// Root data source. Overridden in tests with an in-memory double.
|
||||
final localStoreProvider = Provider<LocalStore>((ref) => LocalStore.instance);
|
||||
@@ -36,28 +50,130 @@ final transactionRepositoryProvider = Provider<TransactionRepository>(
|
||||
/// purpose, which reads as a real network fault.
|
||||
final simulateOfflineProvider = StateProvider<bool>((ref) => false);
|
||||
|
||||
/// Simulated back-office endpoints. Held as singletons so the offline toggle
|
||||
/// in Settings affects every call.
|
||||
final remoteCatalogueProvider = Provider<RemoteCatalogueSource>(
|
||||
(ref) => RemoteCatalogueSource(
|
||||
isOffline: () => ref.read(simulateOfflineProvider),
|
||||
),
|
||||
);
|
||||
/// Where products and customers come from.
|
||||
///
|
||||
/// Rebuilt when the route changes, and the old one closed, so a re-pointed
|
||||
/// terminal does not keep a stale client alive.
|
||||
final catalogueSourceProvider = Provider<CatalogueSource>((ref) {
|
||||
final config = ref.watch(syncConfigProvider);
|
||||
|
||||
final remoteOrderSinkProvider = Provider<RemoteOrderSink>(
|
||||
(ref) => RemoteOrderSink(
|
||||
isOffline: () => ref.read(simulateOfflineProvider),
|
||||
),
|
||||
);
|
||||
final source = switch (config.transport) {
|
||||
// MQTT carries the *notification* that the catalogue moved; the catalogue
|
||||
// itself is a bulk pull, which is an HTTP job. A broker is the wrong shape
|
||||
// for tens of thousands of rows.
|
||||
TransportKind.http || TransportKind.mqtt =>
|
||||
config.httpBaseUrl.isEmpty
|
||||
? SimulatedCatalogueSource(
|
||||
isOffline: () => ref.read(simulateOfflineProvider),
|
||||
)
|
||||
: HttpCatalogueSource(config: config),
|
||||
TransportKind.simulated => SimulatedCatalogueSource(
|
||||
isOffline: () => ref.read(simulateOfflineProvider),
|
||||
),
|
||||
};
|
||||
|
||||
ref.onDispose(source.dispose);
|
||||
return source;
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------- Sync
|
||||
/// How this terminal reaches the back office.
|
||||
///
|
||||
/// Defaults to the simulated route so a fresh install is usable with no broker
|
||||
/// and no endpoint; Settings re-points it.
|
||||
///
|
||||
/// Store and terminal ids always come from this device's own identity, never
|
||||
/// from a literal — two terminals publishing on the same topic is the failure
|
||||
/// this exists to prevent.
|
||||
final syncConfigProvider = StateProvider<SyncConfig>((ref) {
|
||||
final terminal = ref.watch(terminalIdentityProvider);
|
||||
return SyncConfig(
|
||||
storeId: terminal.storeId,
|
||||
terminalId: terminal.code,
|
||||
);
|
||||
});
|
||||
|
||||
/// Real network state, folded with the Settings offline switch.
|
||||
final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
|
||||
final service = ConnectivityService(
|
||||
isSimulatedOffline: () => ref.read(simulateOfflineProvider),
|
||||
);
|
||||
ref.onDispose(service.dispose);
|
||||
return service;
|
||||
});
|
||||
|
||||
/// The wire itself. Rebuilt when the configuration changes, and the old one is
|
||||
/// closed so a re-pointed terminal does not keep a stale broker session open.
|
||||
final orderTransportProvider = Provider<OrderTransport>((ref) {
|
||||
final config = ref.watch(syncConfigProvider);
|
||||
|
||||
final transport = switch (config.transport) {
|
||||
TransportKind.mqtt => MqttOrderTransport(config: config),
|
||||
TransportKind.http => HttpOrderTransport(config: config),
|
||||
TransportKind.simulated => SimulatedOrderTransport(
|
||||
isOffline: () => ref.read(simulateOfflineProvider),
|
||||
),
|
||||
};
|
||||
|
||||
ref.onDispose(transport.dispose);
|
||||
return transport;
|
||||
});
|
||||
|
||||
final syncRepositoryProvider = Provider<SyncRepository>(
|
||||
(ref) => SyncRepositoryImpl(
|
||||
ref.watch(localStoreProvider),
|
||||
ref.watch(remoteCatalogueProvider),
|
||||
ref.watch(remoteOrderSinkProvider),
|
||||
ref.watch(catalogueSourceProvider),
|
||||
ref.watch(orderTransportProvider),
|
||||
batchSize: ref.watch(syncConfigProvider).batchSize,
|
||||
),
|
||||
);
|
||||
|
||||
/// Decides when bills are uploaded. Started once, by the app shell.
|
||||
final syncEngineProvider = Provider<SyncEngine>((ref) {
|
||||
final transport = ref.watch(orderTransportProvider);
|
||||
|
||||
final engine = SyncEngine(
|
||||
repository: ref.watch(syncRepositoryProvider),
|
||||
connectivity: ref.watch(connectivityServiceProvider).onlineChanges,
|
||||
downlink: transport.downlink,
|
||||
onCatalogueChanged: () async {
|
||||
await ref.read(syncRepositoryProvider).importCatalogue();
|
||||
ref.invalidate(localStoreProvider);
|
||||
},
|
||||
);
|
||||
|
||||
ref.onDispose(engine.dispose);
|
||||
return engine;
|
||||
});
|
||||
|
||||
/// Live engine state for the header pill and the events screen.
|
||||
final syncEngineStateProvider = StreamProvider<SyncEngineState>((ref) {
|
||||
final engine = ref.watch(syncEngineProvider);
|
||||
return engine.states.map((s) => s);
|
||||
});
|
||||
|
||||
/// Store details and staff, read from this terminal's database.
|
||||
final storeRepositoryProvider = Provider<StoreRepositoryImpl>(
|
||||
(ref) => StoreRepositoryImpl(ref.watch(localStoreProvider)),
|
||||
);
|
||||
|
||||
/// The outlet, refreshed whenever staff or details change.
|
||||
final storeAccountProvider = FutureProvider<StoreAccount>(
|
||||
(ref) => ref.watch(storeRepositoryProvider).load(
|
||||
email: DemoCredentials.email,
|
||||
),
|
||||
);
|
||||
|
||||
/// Campaigns stored on this terminal.
|
||||
final promosProvider = FutureProvider<List<Promo>>(
|
||||
(ref) => ref.watch(localStoreProvider).promos.all(),
|
||||
);
|
||||
|
||||
/// Only the campaigns the till should be applying right now.
|
||||
final activePromosProvider = FutureProvider<List<Promo>>(
|
||||
(ref) => ref.watch(localStoreProvider).promos.all(activeOnly: true),
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------- Use cases
|
||||
final checkoutSaleProvider = Provider<CheckoutSale>(
|
||||
(ref) => CheckoutSale(
|
||||
@@ -87,13 +203,31 @@ class CashierSession {
|
||||
final String terminalId;
|
||||
}
|
||||
|
||||
final cashierSessionProvider = StateProvider<CashierSession>(
|
||||
(ref) => const CashierSession(
|
||||
/// Identity of the physical till, read from its own database.
|
||||
///
|
||||
/// Falls back only before the store has opened; every real read happens after
|
||||
/// `LocalStore.init`, which mints the identity if this device has never run
|
||||
/// before.
|
||||
final terminalIdentityProvider = Provider<TerminalIdentity>((ref) {
|
||||
final store = ref.watch(localStoreProvider);
|
||||
return store.isReady
|
||||
? store.terminal
|
||||
: const TerminalIdentity(
|
||||
deviceId: 'unopened',
|
||||
code: 'T0000',
|
||||
name: 'Terminal',
|
||||
storeId: 'store-01',
|
||||
);
|
||||
});
|
||||
|
||||
final cashierSessionProvider = StateProvider<CashierSession>((ref) {
|
||||
final terminal = ref.watch(terminalIdentityProvider);
|
||||
return CashierSession(
|
||||
name: 'Suriya',
|
||||
role: 'ADMIN',
|
||||
terminalId: 'TERM-01',
|
||||
),
|
||||
);
|
||||
terminalId: terminal.code,
|
||||
);
|
||||
});
|
||||
|
||||
/// Ticks once a minute to drive the header clock without rebuilding on every
|
||||
/// frame.
|
||||
|
||||
140
lib/core/config/sync_config.dart
Normal file
140
lib/core/config/sync_config.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
/// 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
|
||||
String get _base => 'pos/$storeId/$terminalId';
|
||||
|
||||
/// Uplink. Completed bills, QoS 1.
|
||||
String get orderTopic => '$_base/order';
|
||||
|
||||
/// 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.
|
||||
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';
|
||||
|
||||
/// Store-wide downlink: catalogue changes land here for every terminal.
|
||||
String get catalogueTopic => '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:
|
||||
/// `pos.*.*.order` for every till's bills, `pos.*.*.status` 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._();
|
||||
|
||||
static const String appName = 'Nearle POS';
|
||||
|
||||
/// Reported in every presence record. Across a fleet this is how you find
|
||||
/// the twelve tills still on last month's build when one of them misbehaves.
|
||||
static const String appVersion = '1.1.0';
|
||||
static const String storeName = 'Nearle Daily';
|
||||
static const String storeAddress = '12 Gandhipuram Main Rd, Coimbatore 641012';
|
||||
static const String storeGstin = '33ABCDE1234F1Z5';
|
||||
|
||||
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 'cash_drawer_service.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:printing/printing.dart';
|
||||
@@ -224,6 +226,11 @@ class ReceiptService {
|
||||
pw.SizedBox(height: 2),
|
||||
_amount('Gross Sales Value', gross),
|
||||
if (discount > 0) _amount('Total Discount', discount),
|
||||
// Named on the printed bill too. A shopper who came in for an advertised
|
||||
// offer needs to see it on the receipt, not just a total that happens to
|
||||
// be lower than the shelf price.
|
||||
for (final applied in cart.appliedPromos)
|
||||
_amount(' ${applied.promo.name}', applied.amount),
|
||||
_amount('Net Sales Value (Inclusive of GST)', cart.netAmount),
|
||||
if (cart.roundOff != 0) _amount('Round Off', cart.roundOff),
|
||||
_amount('Total Amount Paid', txn.total, bold: true),
|
||||
@@ -578,6 +585,10 @@ class ReceiptService {
|
||||
|
||||
if (cart.billDiscountTotal > 0) {
|
||||
b.writeln('Discount: -${Formatters.money(cart.billDiscountTotal)}');
|
||||
for (final applied in cart.appliedPromos) {
|
||||
b.writeln(' ${applied.promo.name}: '
|
||||
'-${Formatters.money(applied.amount)}');
|
||||
}
|
||||
}
|
||||
|
||||
b
|
||||
@@ -597,26 +608,17 @@ class ReceiptService {
|
||||
return b.toString();
|
||||
}
|
||||
|
||||
/// ESC/POS drawer kick: `ESC p m t1 t2` on pin 2.
|
||||
/// Opens the till drawer, if one is configured.
|
||||
///
|
||||
/// Most drawers are wired to the printer's RJ11 port and open when the
|
||||
/// printer receives this. It cannot be sent through the PDF pipeline — a
|
||||
/// PDF is rendered by the driver, not passed through as bytes — so this
|
||||
/// needs a raw channel to the printer.
|
||||
///
|
||||
/// On desktop with a driver-installed printer there is no raw path from
|
||||
/// Flutter, so this stays a no-op. Wire it up when you move to ESC/POS:
|
||||
/// send [drawerKickCommand] over the same socket or Bluetooth link that
|
||||
/// carries the receipt.
|
||||
Future<void> openCashDrawer() async {
|
||||
debugPrint(
|
||||
'Cash drawer kick requested — needs a raw ESC/POS channel, '
|
||||
'not the PDF driver. Command: ${drawerKickCommand.join(' ')}',
|
||||
);
|
||||
}
|
||||
|
||||
/// `ESC p 0 25 250` — pin 2, 50ms on, 500ms off.
|
||||
static const List<int> drawerKickCommand = [27, 112, 0, 25, 250];
|
||||
/// Delegates to [CashDrawerService], which talks raw ESC/POS over a socket.
|
||||
/// The PDF pipeline cannot carry the command — a PDF is rendered by the
|
||||
/// platform driver, which will not pass arbitrary bytes through to the
|
||||
/// device.
|
||||
Future<DrawerResult> openCashDrawer({
|
||||
String? host,
|
||||
int port = 9100,
|
||||
}) =>
|
||||
CashDrawerService().open(host: host, port: port);
|
||||
}
|
||||
|
||||
/// One GST rate's slice of a bill.
|
||||
|
||||
@@ -16,10 +16,8 @@ class SoundService {
|
||||
static final SoundService instance = SoundService._();
|
||||
|
||||
final AudioPlayer _player = AudioPlayer(playerId: 'nearle_pos_sfx');
|
||||
bool _enabled = true;
|
||||
|
||||
bool get enabled => _enabled;
|
||||
set enabled(bool value) => _enabled = value;
|
||||
/// Muted from Settings when a shop finds the beeps intrusive.
|
||||
bool enabled = true;
|
||||
|
||||
Future<void> preload() async {
|
||||
try {
|
||||
@@ -40,7 +38,7 @@ class SoundService {
|
||||
bool haptic = false,
|
||||
bool heavy = false,
|
||||
}) async {
|
||||
if (!_enabled) return;
|
||||
if (!enabled) return;
|
||||
|
||||
// Haptics matter on tablets where the speaker may be muted on the floor.
|
||||
if (heavy) {
|
||||
|
||||
@@ -59,9 +59,28 @@ class Formatters {
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
static String invoiceNumber(int sequence, DateTime date) {
|
||||
/// `INV-2608-T4A9-00042`.
|
||||
///
|
||||
/// The terminal code is not decoration. The sequence counter lives in each
|
||||
/// till's own database, so without it every terminal in the fleet mints
|
||||
/// `INV-2608-00001` for its first sale of the month — 100 different bills
|
||||
/// sharing one number, and no way to tell them apart on a receipt or in an
|
||||
/// audit. The order's UUID keeps the *data* distinct; this keeps the number
|
||||
/// a human quotes distinct too.
|
||||
///
|
||||
/// [terminalCode] is optional only so older call sites and fixtures keep
|
||||
/// working; anything that writes a real bill passes it.
|
||||
static String invoiceNumber(
|
||||
int sequence,
|
||||
DateTime date, {
|
||||
String? terminalCode,
|
||||
}) {
|
||||
final y = date.year.toString().substring(2);
|
||||
final m = date.month.toString().padLeft(2, '0');
|
||||
return 'INV-$y$m-${sequence.toString().padLeft(5, '0')}';
|
||||
final seq = sequence.toString().padLeft(5, '0');
|
||||
final code = (terminalCode == null || terminalCode.isEmpty)
|
||||
? ''
|
||||
: '$terminalCode-';
|
||||
return 'INV-$y$m-$code$seq';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import '../../domain/entities/sync_event.dart';
|
||||
import '../local/app_database.dart';
|
||||
import '../local/catalogue_dao.dart';
|
||||
import '../local/order_dao.dart';
|
||||
import '../local/promo_dao.dart';
|
||||
import '../local/staff_dao.dart';
|
||||
import '../local/sync_config_store.dart';
|
||||
import '../local/sync_log_dao.dart';
|
||||
import '../local/terminal_identity.dart';
|
||||
|
||||
/// Terminal-side storage facade.
|
||||
///
|
||||
@@ -20,6 +24,13 @@ class LocalStore {
|
||||
late CatalogueDao catalogue;
|
||||
late OrderDao orders;
|
||||
late SyncLogDao syncLog;
|
||||
late StaffDao staff;
|
||||
late PromoDao promos;
|
||||
late SyncConfigStore syncConfig;
|
||||
late TerminalIdentityStore identityStore;
|
||||
|
||||
/// Who this till is. Minted on first run, then stable forever.
|
||||
late TerminalIdentity terminal;
|
||||
|
||||
final Map<String, Product> _products = {};
|
||||
final Map<String, Customer> _customers = {};
|
||||
@@ -44,6 +55,18 @@ class LocalStore {
|
||||
catalogue = CatalogueDao(AppDatabase.instance.db);
|
||||
orders = OrderDao(AppDatabase.instance.db);
|
||||
syncLog = SyncLogDao(AppDatabase.instance.db);
|
||||
staff = StaffDao(AppDatabase.instance.db);
|
||||
promos = PromoDao(AppDatabase.instance.db);
|
||||
syncConfig = SyncConfigStore(catalogue);
|
||||
identityStore = TerminalIdentityStore(catalogue);
|
||||
|
||||
// A terminal with no staff cannot be signed into at all, so this runs
|
||||
// before anything else can ask who is on shift.
|
||||
await staff.seedIfEmpty();
|
||||
|
||||
// Before anything can be written or published: a bill stamped with the
|
||||
// wrong terminal cannot be traced back to the till that rang it.
|
||||
terminal = await identityStore.load();
|
||||
|
||||
await hydrate();
|
||||
_ready = true;
|
||||
@@ -67,6 +90,7 @@ class LocalStore {
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(int.parse(stamp));
|
||||
_catalogueRevision = await catalogue.meta(MetaKeys.catalogueRevision);
|
||||
terminal = await identityStore.load();
|
||||
_unsyncedOrders = await orders.unsyncedCount();
|
||||
|
||||
_syncEvents
|
||||
@@ -89,6 +113,12 @@ class LocalStore {
|
||||
if (!_ready) await init(inMemory: true);
|
||||
await AppDatabase.instance.clear();
|
||||
|
||||
// Staff and identity are cleared with everything else, and a terminal
|
||||
// without them cannot be signed into or stamp a bill. Re-minted here so a
|
||||
// reset leaves a usable till rather than a half-built one.
|
||||
await staff.seedIfEmpty();
|
||||
terminal = await identityStore.load();
|
||||
|
||||
if (withCatalogue) {
|
||||
// Imported here rather than at the top of the file so the seed data is
|
||||
// only pulled in by tests and the simulated remote source.
|
||||
@@ -119,13 +149,41 @@ class LocalStore {
|
||||
required List<Customer> customers,
|
||||
required String revision,
|
||||
required DateTime at,
|
||||
bool isDelta = false,
|
||||
List<String> retiredProductIds = const [],
|
||||
}) async {
|
||||
await catalogue.replaceCatalogue(products: products, customers: customers);
|
||||
if (isDelta) {
|
||||
// A change set must not withdraw what it does not mention. Applied as a
|
||||
// full snapshot, the first morning price change would empty the shelf.
|
||||
await catalogue.applyCatalogueDelta(
|
||||
products: products,
|
||||
customers: customers,
|
||||
retiredProductIds: retiredProductIds,
|
||||
);
|
||||
} else {
|
||||
await catalogue.replaceCatalogue(
|
||||
products: products,
|
||||
customers: customers,
|
||||
);
|
||||
}
|
||||
|
||||
// The server's stock figure predates any sale this terminal has made but
|
||||
// not yet uploaded, so those units would reappear on the shelf. Replay them
|
||||
// before anyone can bill against the inflated count.
|
||||
final committed = await orders.unsyncedStockCommitments();
|
||||
//
|
||||
// Scoped to the products the pull actually overwrote. A full snapshot
|
||||
// rewrites every row, so the replay covers everything; a delta rewrites
|
||||
// only what it carried, and replaying the rest would subtract those units a
|
||||
// second time from a count that was never reset — quietly emptying a shelf
|
||||
// that is full.
|
||||
var committed = await orders.unsyncedStockCommitments();
|
||||
if (isDelta) {
|
||||
final touched = products.map((p) => p.id).toSet();
|
||||
committed = {
|
||||
for (final entry in committed.entries)
|
||||
if (touched.contains(entry.key)) entry.key: entry.value,
|
||||
};
|
||||
}
|
||||
if (committed.isNotEmpty) {
|
||||
await catalogue.decrementStock(committed);
|
||||
}
|
||||
|
||||
@@ -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 const String _fileName = 'nearle_pos.db';
|
||||
static const int _version = 4;
|
||||
static const int _version = 7;
|
||||
|
||||
Database? _db;
|
||||
|
||||
@@ -60,7 +60,7 @@ class AppDatabase {
|
||||
path,
|
||||
options: OpenDatabaseOptions(
|
||||
version: _version,
|
||||
onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
||||
onConfigure: _configure,
|
||||
onCreate: (db, version) async => _createSchema(db),
|
||||
onUpgrade: (db, from, to) async {
|
||||
if (from < 2) await db.execute(_createDayArchive);
|
||||
@@ -70,6 +70,13 @@ class AppDatabase {
|
||||
);
|
||||
}
|
||||
if (from < 4) await _upgradeToV4(db, from: from);
|
||||
if (from < 5) await db.execute(_createStaff);
|
||||
if (from < 6) await db.execute(_createPromos);
|
||||
if (from < 7) {
|
||||
await db.execute(
|
||||
'ALTER TABLE ${Tables.orders} ADD COLUMN promos_json TEXT',
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -84,12 +91,35 @@ class AppDatabase {
|
||||
inMemoryDatabasePath,
|
||||
options: OpenDatabaseOptions(
|
||||
version: _version,
|
||||
onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
||||
onConfigure: _configure,
|
||||
onCreate: (db, version) async => _createSchema(db),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Pragmas applied on every connection, before any query runs.
|
||||
///
|
||||
/// Defaults are wrong for a till:
|
||||
///
|
||||
/// * **WAL** lets a read proceed while a write is in flight. On the rollback
|
||||
/// journal the product grid refreshing would block the sale being written.
|
||||
/// It also survives a power cut better: the database file is never left
|
||||
/// mid-rewrite.
|
||||
/// * **busy_timeout** makes a contended lock wait instead of throwing
|
||||
/// `database is locked` — which, at checkout, is a failed sale.
|
||||
/// * **synchronous = NORMAL** is the right trade under WAL: an fsync per
|
||||
/// transaction costs more than a POS can spare, and WAL still recovers a
|
||||
/// committed transaction after a crash. Only a host OS crash or power loss
|
||||
/// can lose the last commits, which is what the UPS is for.
|
||||
static Future<void> _configure(Database db) async {
|
||||
await db.execute('PRAGMA foreign_keys = ON');
|
||||
await db.execute('PRAGMA busy_timeout = 5000');
|
||||
|
||||
// In-memory databases have no WAL; asking for it is harmless but pointless.
|
||||
await db.execute('PRAGMA journal_mode = WAL');
|
||||
await db.execute('PRAGMA synchronous = NORMAL');
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
await _db?.close();
|
||||
_db = null;
|
||||
@@ -106,6 +136,8 @@ class AppDatabase {
|
||||
Tables.customers,
|
||||
Tables.parkedBills,
|
||||
Tables.syncLog,
|
||||
Tables.staff,
|
||||
Tables.promos,
|
||||
Tables.meta,
|
||||
]) {
|
||||
batch.delete(t);
|
||||
@@ -186,6 +218,10 @@ class AppDatabase {
|
||||
points_earned INTEGER NOT NULL DEFAULT 0,
|
||||
points_redeemed INTEGER NOT NULL DEFAULT 0,
|
||||
payments_json TEXT NOT NULL,
|
||||
-- Which campaigns fired, and for how much. Stored as amounts rather
|
||||
-- than ids: a bill read back next year must show what was actually
|
||||
-- given, not what today's rules would give.
|
||||
promos_json TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
|
||||
-- 0 = held on this terminal, 1 = accepted by the server
|
||||
@@ -242,6 +278,8 @@ class AppDatabase {
|
||||
|
||||
// ------------------------------------------------------------- archive
|
||||
await db.execute(_createDayArchive);
|
||||
await db.execute(_createStaff);
|
||||
await db.execute(_createPromos);
|
||||
|
||||
// ----------------------------------------------------------------- meta
|
||||
await db.execute('''
|
||||
@@ -304,6 +342,55 @@ const String _createSyncLog = '''
|
||||
/// here first — otherwise "Bills Today" would collapse to zero the moment a
|
||||
/// mid-shift sync ran. Keyed by cashier as well as date, because once the
|
||||
/// orders are gone this row is the only thing left to settle a till against.
|
||||
/// Staff who can sign in at this terminal.
|
||||
///
|
||||
/// Replaces three `StaffUser` literals with plaintext PINs that shipped inside
|
||||
/// every build. Only the PBKDF2 hash and its salt are stored — the PIN itself
|
||||
/// exists nowhere, including here.
|
||||
const String _createStaff = '''
|
||||
CREATE TABLE staff (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
pin_hash TEXT NOT NULL,
|
||||
pin_salt TEXT NOT NULL,
|
||||
-- Set on a seeded or reset account, cleared once the person picks their
|
||||
-- own, so a shop running a default PIN is at least visibly nagged.
|
||||
must_change_pin INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
''';
|
||||
|
||||
/// Campaigns the till applies automatically.
|
||||
///
|
||||
/// Kept local like everything else: a shop mid-promotion with a dead line still
|
||||
/// has to honour the price on the shelf edge.
|
||||
const String _createPromos = '''
|
||||
CREATE TABLE promos (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
value REAL NOT NULL DEFAULT 0,
|
||||
target_id TEXT,
|
||||
target_label TEXT,
|
||||
buy_quantity INTEGER NOT NULL DEFAULT 0,
|
||||
free_quantity INTEGER NOT NULL DEFAULT 0,
|
||||
min_bill_value REAL NOT NULL DEFAULT 0,
|
||||
max_discount REAL,
|
||||
valid_from INTEGER,
|
||||
valid_to INTEGER,
|
||||
-- Comma-separated DateTime.weekday values. Empty means every day.
|
||||
days_of_week TEXT NOT NULL DEFAULT '',
|
||||
stackable INTEGER NOT NULL DEFAULT 0,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
''';
|
||||
|
||||
const String _createDayArchive = '''
|
||||
CREATE TABLE day_archive (
|
||||
business_date TEXT NOT NULL,
|
||||
@@ -335,6 +422,8 @@ class Tables {
|
||||
static const String orderItems = 'order_items';
|
||||
static const String parkedBills = 'parked_bills';
|
||||
static const String syncLog = 'sync_log';
|
||||
static const String staff = 'staff';
|
||||
static const String promos = 'promos';
|
||||
static const String meta = 'app_meta';
|
||||
}
|
||||
|
||||
@@ -345,6 +434,40 @@ class MetaKeys {
|
||||
static const String catalogueRevision = 'catalogue_revision';
|
||||
static const String invoiceSequence = 'invoice_sequence';
|
||||
|
||||
/// Fleet identity. Minted once on first run and never changed — it is what
|
||||
/// ties a bill, an MQTT topic and a presence record to one physical till.
|
||||
static const String deviceId = 'terminal_device_id';
|
||||
|
||||
/// Short code stamped into invoice numbers, e.g. `T4A9`. Unique per device
|
||||
/// so two tills in the same shop cannot mint the same invoice.
|
||||
static const String terminalCode = 'terminal_code';
|
||||
|
||||
/// Human label shown in Settings and on the fleet board, e.g. "Counter 2".
|
||||
static const String terminalName = 'terminal_name';
|
||||
|
||||
static const String storeId = 'store_id';
|
||||
|
||||
/// Printed on every invoice, so they are a legal requirement rather than
|
||||
/// decoration — and must be editable without a rebuild.
|
||||
static const String storeName = 'store_name';
|
||||
static const String storeAddress = 'store_address';
|
||||
static const String storeGstin = 'store_gstin';
|
||||
static const String storePhone = 'store_phone';
|
||||
static const String storePlan = 'store_plan';
|
||||
|
||||
/// How this terminal reaches the back office. Non-secret only — the username,
|
||||
/// password and API key go to the platform keystore, not here.
|
||||
static const String syncTransport = 'sync_transport';
|
||||
static const String syncBrokerHost = 'sync_broker_host';
|
||||
static const String syncBrokerPort = 'sync_broker_port';
|
||||
static const String syncUseTls = 'sync_use_tls';
|
||||
static const String syncHttpBaseUrl = 'sync_http_base_url';
|
||||
|
||||
/// Network printer that owns the cash drawer, if it is not the receipt
|
||||
/// printer itself.
|
||||
static const String drawerHost = 'drawer_host';
|
||||
static const String drawerPort = 'drawer_port';
|
||||
|
||||
/// Printer chosen in Settings. Stored as the printer's `url`, which is what
|
||||
/// `Printing.directPrintPdf` needs to target it without a dialog.
|
||||
static const String printerUrl = 'printer_url';
|
||||
|
||||
@@ -173,6 +173,60 @@ class CatalogueDao {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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,
|
||||
customerToRow(c),
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore,
|
||||
);
|
||||
}
|
||||
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
}
|
||||
|
||||
/// Applies stock movement after a sale, clamped at zero.
|
||||
Future<void> decrementStock(Map<String, double> quantities) async {
|
||||
if (quantities.isEmpty) return;
|
||||
@@ -239,6 +293,16 @@ class CatalogueDao {
|
||||
return rows.isEmpty ? null : rows.first['value'] as String?;
|
||||
}
|
||||
|
||||
/// Every stored setting, for support and for tests that assert what is *not*
|
||||
/// in here — credentials, most of all.
|
||||
Future<Map<String, String>> allMeta() async {
|
||||
final rows = await _db.query(Tables.meta);
|
||||
return {
|
||||
for (final row in rows)
|
||||
row['key']! as String: (row['value'] as String?) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> setMeta(String key, String value) async {
|
||||
await _db.insert(
|
||||
Tables.meta,
|
||||
|
||||
@@ -5,15 +5,20 @@ import 'package:sqflite/sqflite.dart';
|
||||
import '../../domain/entities/cart.dart';
|
||||
import '../../domain/entities/customer.dart';
|
||||
import '../../domain/entities/product.dart';
|
||||
import '../../domain/entities/promo.dart';
|
||||
import '../../domain/entities/transaction.dart';
|
||||
import 'app_database.dart';
|
||||
import 'catalogue_dao.dart';
|
||||
|
||||
/// Persists bills.
|
||||
///
|
||||
/// Every completed sale lands here with `sync_status = 0`. The end-of-day
|
||||
/// upload selects those rows, sends them, and flips the accepted ones to 1.
|
||||
/// Nothing is ever deleted as part of syncing.
|
||||
/// Every completed sale lands here with `sync_status = 0`, which makes this
|
||||
/// table the terminal's outbox: the drain engine selects those rows, sends
|
||||
/// them, and flips the ones the back office confirmed to 1.
|
||||
///
|
||||
/// Confirmed rows are kept for [retentionWindow] so a batch the server later
|
||||
/// loses can be re-sent in full, then retired by [purgeSyncedBefore]. Syncing
|
||||
/// itself never deletes anything.
|
||||
class OrderDao {
|
||||
const OrderDao(this._db);
|
||||
|
||||
@@ -22,6 +27,9 @@ class OrderDao {
|
||||
static const int pending = 0;
|
||||
static const int synced = 1;
|
||||
|
||||
/// How long an accepted bill stays re-sendable on the terminal.
|
||||
static const Duration retentionWindow = Duration(days: 7);
|
||||
|
||||
static String businessDateOf(DateTime dt) =>
|
||||
'${dt.year.toString().padLeft(4, '0')}-'
|
||||
'${dt.month.toString().padLeft(2, '0')}-'
|
||||
@@ -85,6 +93,17 @@ class OrderDao {
|
||||
'subtotal': cart.subtotal,
|
||||
'line_discount': cart.lineDiscountTotal,
|
||||
'bill_discount': cart.billDiscountTotal,
|
||||
'promos_json': cart.appliedPromos.isEmpty
|
||||
? null
|
||||
: jsonEncode([
|
||||
for (final applied in cart.appliedPromos)
|
||||
{
|
||||
'id': applied.promo.id,
|
||||
'name': applied.promo.name,
|
||||
'type': applied.promo.type.name,
|
||||
'amount': applied.amount,
|
||||
},
|
||||
]),
|
||||
'loyalty_value': cart.loyaltyRedemptionValue,
|
||||
'taxable_amount': cart.taxableAmount,
|
||||
'tax_amount': cart.taxAmount,
|
||||
@@ -132,20 +151,26 @@ class OrderDao {
|
||||
Future<List<SaleTransaction>> recent({int limit = 100}) =>
|
||||
_query(orderBy: 'created_at DESC', limit: limit);
|
||||
|
||||
/// Bills for a day, optionally narrowed to one operator.
|
||||
/// Bills for a day that have *not* yet been accepted by the server.
|
||||
///
|
||||
/// A shift report that is settled against a till has to cover exactly the
|
||||
/// bills that cashier rang, not everything the terminal did that day.
|
||||
///
|
||||
/// Restricted to pending rows on purpose. The moment a bill is accepted its
|
||||
/// figures are folded into [Tables.dayArchive], and the report adds the two
|
||||
/// together — so an accepted row still sitting here during its retention
|
||||
/// window would be counted twice and inflate the day's takings.
|
||||
Future<List<SaleTransaction>> forBusinessDate(
|
||||
DateTime day, {
|
||||
String? cashierName,
|
||||
}) =>
|
||||
_query(
|
||||
where: cashierName == null
|
||||
? 'business_date = ?'
|
||||
: 'business_date = ? AND cashier_name = ?',
|
||||
? 'business_date = ? AND sync_status = ?'
|
||||
: 'business_date = ? AND sync_status = ? AND cashier_name = ?',
|
||||
whereArgs: [
|
||||
businessDateOf(day),
|
||||
pending,
|
||||
if (cashierName != null) cashierName,
|
||||
],
|
||||
);
|
||||
@@ -241,14 +266,19 @@ class OrderDao {
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------- Sync
|
||||
/// Folds accepted orders into the day archive, then deletes them.
|
||||
/// Folds accepted orders into the day archive and marks them synced.
|
||||
///
|
||||
/// Once the server holds a bill the terminal has no reason to keep it, so
|
||||
/// the rows go. Their figures are added to [Tables.dayArchive] first, so the
|
||||
/// shift totals a cashier sees do not collapse after a mid-shift sync.
|
||||
/// Both steps run in one transaction: if the delete fails the archive is
|
||||
/// rolled back with it, and nothing is counted twice.
|
||||
Future<void> archiveAndDelete(List<SaleTransaction> orders) async {
|
||||
/// Their figures are added to [Tables.dayArchive] so the shift totals a
|
||||
/// cashier sees do not collapse after a mid-shift sync, and the rows
|
||||
/// themselves are kept — at `sync_status = 1` — until [purgeSyncedBefore]
|
||||
/// retires them. Keeping them buys a recovery window: if the back office
|
||||
/// loses a batch, the full bills are still on the terminal and can be sent
|
||||
/// again. Once deleted only the archived totals survive, and a lost bill's
|
||||
/// line items are gone for good.
|
||||
///
|
||||
/// Both steps run in one transaction: if the status flip fails the archive
|
||||
/// rolls back with it, and nothing is counted twice.
|
||||
Future<void> archiveAccepted(List<SaleTransaction> orders) async {
|
||||
if (orders.isEmpty) return;
|
||||
|
||||
await _db.transaction((txn) async {
|
||||
@@ -331,17 +361,30 @@ class OrderDao {
|
||||
);
|
||||
}
|
||||
|
||||
// order_items goes with it via ON DELETE CASCADE.
|
||||
final ids = orders.map((o) => o.id).toList();
|
||||
final placeholders = List.filled(ids.length, '?').join(',');
|
||||
await txn.delete(
|
||||
Tables.orders,
|
||||
where: 'id IN ($placeholders)',
|
||||
whereArgs: ids,
|
||||
await txn.rawUpdate(
|
||||
'UPDATE ${Tables.orders} '
|
||||
'SET sync_status = ?, synced_at = ?, sync_error = NULL '
|
||||
'WHERE id IN ($placeholders)',
|
||||
[synced, DateTime.now().millisecondsSinceEpoch, ...ids],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Retires bills the server took delivery of more than [retention] ago.
|
||||
///
|
||||
/// Their archived totals are untouched and stay forever — this drops only the
|
||||
/// re-sendable copy once the recovery window has closed, so a terminal that
|
||||
/// trades for years does not carry every bill it has ever rung.
|
||||
///
|
||||
/// Returns how many rows went. `order_items` follows via ON DELETE CASCADE.
|
||||
Future<int> purgeSyncedBefore(DateTime cutoff) => _db.delete(
|
||||
Tables.orders,
|
||||
where: 'sync_status = ? AND synced_at IS NOT NULL AND synced_at < ?',
|
||||
whereArgs: [synced, cutoff.millisecondsSinceEpoch],
|
||||
);
|
||||
|
||||
/// Archived figures for a business day, one row per cashier.
|
||||
///
|
||||
/// Empty when nothing has synced yet. Pass [cashierName] to scope it to a
|
||||
@@ -407,6 +450,36 @@ class OrderDao {
|
||||
await _db.delete(Tables.parkedBills, where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
/// Rebuilds the campaigns recorded against a bill.
|
||||
///
|
||||
/// The stored rows carry the promo's name and amount rather than a live
|
||||
/// lookup, so a campaign that has since been edited or deleted still prints
|
||||
/// on a reissued receipt exactly as it was given.
|
||||
static List<AppliedPromo> _promosFromRow(String? json) {
|
||||
if (json == null || json.isEmpty) return const [];
|
||||
|
||||
try {
|
||||
return (jsonDecode(json) as List)
|
||||
.cast<Map<String, Object?>>()
|
||||
.map((p) => AppliedPromo(
|
||||
promo: Promo(
|
||||
id: (p['id'] as String?) ?? '',
|
||||
name: (p['name'] as String?) ?? 'Promotion',
|
||||
type: PromoType.values
|
||||
.where((t) => t.name == p['type'])
|
||||
.firstOrNull ??
|
||||
PromoType.flatOffBill,
|
||||
),
|
||||
amount: (p['amount'] as num?)?.toDouble() ?? 0,
|
||||
),)
|
||||
.toList();
|
||||
} on Object {
|
||||
// A bill that cannot name its campaigns is still a valid bill; the total
|
||||
// is on the row itself and does not depend on this.
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- Internals
|
||||
Future<List<SaleTransaction>> _query({
|
||||
String? where,
|
||||
@@ -499,19 +572,28 @@ class OrderDao {
|
||||
// payload, the day archive, the shift report — is overstated.
|
||||
final billDiscount = (o['bill_discount']! as num).toDouble();
|
||||
|
||||
// Campaigns are restored so a reprinted receipt still names what the
|
||||
// shopper was given. Their amounts are then *subtracted* from the manual
|
||||
// discount, because `bill_discount` already contains them — restoring both
|
||||
// at full value would discount the bill twice on the way back in.
|
||||
final promos = _promosFromRow(o['promos_json'] as String?);
|
||||
final promoTotal = promos.fold<double>(0, (sum, p) => sum + p.amount);
|
||||
final manualDiscount = (billDiscount - promoTotal).clamp(0, billDiscount);
|
||||
|
||||
return SaleTransaction(
|
||||
id: o['id']! as String,
|
||||
invoiceNumber: o['invoice_number']! as String,
|
||||
cart: Cart(
|
||||
lines: lines,
|
||||
customer: customer,
|
||||
billDiscount: billDiscount > 0
|
||||
billDiscount: manualDiscount > 0
|
||||
? Discount(
|
||||
type: DiscountType.flat,
|
||||
value: billDiscount,
|
||||
value: manualDiscount.toDouble(),
|
||||
reason: 'Bill discount',
|
||||
)
|
||||
: Discount.none,
|
||||
appliedPromos: promos,
|
||||
pointsRedeemed: (o['points_redeemed'] as int?) ?? 0,
|
||||
),
|
||||
payments: payments,
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
106
lib/data/local/terminal_identity.dart
Normal file
106
lib/data/local/terminal_identity.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
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.
|
||||
Future<TerminalIdentity> load({String defaultStoreId = 'store-01'}) 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
205
lib/data/remote/http_catalogue_source.dart
Normal file
205
lib/data/remote/http_catalogue_source.dart
Normal file
@@ -0,0 +1,205 @@
|
||||
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}
|
||||
/// Authorization: Bearer {apiKey}
|
||||
/// ```
|
||||
///
|
||||
/// ```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;
|
||||
|
||||
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 = 1;
|
||||
|
||||
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',
|
||||
'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();
|
||||
}
|
||||
154
lib/data/remote/http_order_transport.dart
Normal file
154
lib/data/remote/http_order_transport.dart
Normal file
@@ -0,0 +1,154 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
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.
|
||||
///
|
||||
/// 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;
|
||||
|
||||
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) async {
|
||||
if (orders.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}/orders');
|
||||
|
||||
http.Response response;
|
||||
try {
|
||||
response = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
if (config.apiKey != null)
|
||||
'authorization': 'Bearer ${config.apiKey}',
|
||||
// Lets the endpoint collapse a retried batch server-side rather
|
||||
// than relying on every order id being checked individually.
|
||||
'idempotency-key': _batchKey(orders),
|
||||
},
|
||||
body: jsonEncode({
|
||||
'schema': 1,
|
||||
'store_id': config.storeId,
|
||||
'terminal_id': config.terminalId,
|
||||
'sent_at': DateTime.now().toIso8601String(),
|
||||
'orders': orders,
|
||||
}),
|
||||
)
|
||||
.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);
|
||||
}
|
||||
|
||||
/// Stable for a given set of bills, so a retry after a timeout carries the
|
||||
/// same key as the attempt that may already have landed.
|
||||
String _batchKey(List<Map<String, Object?>> orders) =>
|
||||
orders.map((o) => o['id']).join('|').hashCode.toRadixString(16);
|
||||
|
||||
void _setReachable(bool value) {
|
||||
if (_reachable == value) return;
|
||||
_reachable = value;
|
||||
_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();
|
||||
}
|
||||
}
|
||||
342
lib/data/remote/mqtt_order_transport.dart
Normal file
342
lib/data/remote/mqtt_order_transport.dart
Normal file
@@ -0,0 +1,342 @@
|
||||
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) async {
|
||||
if (orders.isEmpty) return const PushReceipt(accepted: []);
|
||||
|
||||
await connect();
|
||||
|
||||
final batchId = _uuid.v4();
|
||||
final completer = Completer<PushReceipt>();
|
||||
_awaitingAck[batchId] = completer;
|
||||
|
||||
try {
|
||||
_publish(
|
||||
config.orderTopic,
|
||||
jsonEncode({
|
||||
'schema': 1,
|
||||
'batch_id': batchId,
|
||||
'store_id': config.storeId,
|
||||
'terminal_id': config.terminalId,
|
||||
'sent_at': DateTime.now().toIso8601String(),
|
||||
'orders': orders,
|
||||
}),
|
||||
);
|
||||
|
||||
return await completer.future.timeout(
|
||||
config.ackTimeout,
|
||||
onTimeout: () => throw TransportException(
|
||||
'The back office did not confirm the batch within '
|
||||
'${config.ackTimeout.inSeconds}s. The bills 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);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
99
lib/data/remote/order_transport.dart
Normal file
99
lib/data/remote/order_transport.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
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);
|
||||
|
||||
/// 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;
|
||||
|
||||
Future<void> dispose();
|
||||
}
|
||||
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() {}
|
||||
}
|
||||
62
lib/data/remote/simulated_order_transport.dart
Normal file
62
lib/data/remote/simulated_order_transport.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
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 {}
|
||||
|
||||
@override
|
||||
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async {
|
||||
await Future<void>.delayed(
|
||||
Duration(milliseconds: 400 + orders.length * 60),
|
||||
);
|
||||
|
||||
if (isOffline()) {
|
||||
throw const TransportException(
|
||||
'Simulate offline is ON in Settings, so the upload was failed on '
|
||||
'purpose. Every bill is still stored on this terminal.',
|
||||
);
|
||||
}
|
||||
|
||||
return PushReceipt(
|
||||
accepted: orders.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();
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,26 @@ import '../../domain/entities/sync_event.dart';
|
||||
import '../../domain/entities/transaction.dart';
|
||||
import '../../domain/repositories/sync_repository.dart';
|
||||
import '../datasources/local_store.dart';
|
||||
import '../datasources/remote_catalogue_source.dart';
|
||||
import '../local/order_dao.dart';
|
||||
import '../remote/catalogue_source.dart';
|
||||
import '../remote/order_transport.dart';
|
||||
|
||||
class SyncRepositoryImpl implements SyncRepository {
|
||||
SyncRepositoryImpl(this._store, this._catalogue, this._orderSink);
|
||||
SyncRepositoryImpl(
|
||||
this._store,
|
||||
this._catalogue,
|
||||
this._transport, {
|
||||
this.batchSize = 50,
|
||||
});
|
||||
|
||||
final LocalStore _store;
|
||||
final RemoteCatalogueSource _catalogue;
|
||||
final RemoteOrderSink _orderSink;
|
||||
final CatalogueSource _catalogue;
|
||||
final OrderTransport _transport;
|
||||
|
||||
/// Bills per publish. Kept modest because an MQTT broker will refuse an
|
||||
/// oversized message outright, and a terminal that has been offline for a
|
||||
/// day can easily hold hundreds of bills.
|
||||
final int batchSize;
|
||||
|
||||
static const _uuid = Uuid();
|
||||
|
||||
@@ -46,13 +57,21 @@ class SyncRepositoryImpl implements SyncRepository {
|
||||
final started = DateTime.now();
|
||||
|
||||
try {
|
||||
final snapshot = await _catalogue.fetch(onProgress: onProgress);
|
||||
// Carries the revision this terminal already holds, so the back office
|
||||
// can answer with just what has moved. On a normal morning that is a
|
||||
// handful of price changes rather than the whole book.
|
||||
final snapshot = await _catalogue.fetch(
|
||||
since: _store.catalogueRevision,
|
||||
onProgress: onProgress,
|
||||
);
|
||||
|
||||
await _store.importCatalogue(
|
||||
products: snapshot.products,
|
||||
customers: snapshot.customers,
|
||||
revision: snapshot.revision,
|
||||
at: snapshot.fetchedAt,
|
||||
isDelta: snapshot.isDelta,
|
||||
retiredProductIds: snapshot.retiredProductIds,
|
||||
);
|
||||
|
||||
final event = SyncEvent(
|
||||
@@ -61,12 +80,20 @@ class SyncRepositoryImpl implements SyncRepository {
|
||||
status: SyncStatus.synced,
|
||||
createdAt: started,
|
||||
syncedAt: DateTime.now(),
|
||||
summary: '${snapshot.products.length} products saved to SQLite '
|
||||
'· ${snapshot.revision}',
|
||||
summary: snapshot.isDelta
|
||||
? (snapshot.isEmpty
|
||||
? 'Already up to date · ${snapshot.revision}'
|
||||
: '${snapshot.changeCount} changes applied '
|
||||
'· ${snapshot.revision}')
|
||||
: '${snapshot.products.length} products saved to SQLite '
|
||||
'· ${snapshot.revision}',
|
||||
payload: {
|
||||
'products': snapshot.products.length,
|
||||
'customers': snapshot.customers.length,
|
||||
'retired': snapshot.retiredProductIds.length,
|
||||
'delta': snapshot.isDelta,
|
||||
'revision': snapshot.revision,
|
||||
'via': _catalogue.label,
|
||||
},
|
||||
attempts: 1,
|
||||
);
|
||||
@@ -176,13 +203,14 @@ class SyncRepositoryImpl implements SyncRepository {
|
||||
|
||||
var attempted = 0;
|
||||
var uploaded = 0;
|
||||
var refused = 0;
|
||||
final syncedInvoices = <String>[];
|
||||
|
||||
// `unsynced()` returns a bounded page. Draining it in a loop means a day
|
||||
// with more bills than one page still uploads completely, instead of
|
||||
// reporting success with the remainder silently left behind.
|
||||
while (true) {
|
||||
final batch = await _store.orders.unsynced();
|
||||
final batch = await _store.orders.unsynced(limit: batchSize);
|
||||
if (batch.isEmpty) break;
|
||||
|
||||
final ids = batch.map((o) => o.id).toList();
|
||||
@@ -193,36 +221,19 @@ class SyncRepositoryImpl implements SyncRepository {
|
||||
'Uploading $attempted of $total bills…',
|
||||
);
|
||||
|
||||
PushReceipt receipt;
|
||||
try {
|
||||
final accepted = await _orderSink.pushOrders(
|
||||
receipt = await _transport.pushOrders(
|
||||
batch.map(_orderToPayload).toList(),
|
||||
);
|
||||
|
||||
// Only what the server confirmed is archived and removed. Anything it
|
||||
// did not acknowledge stays on disk.
|
||||
final acceptedOrders =
|
||||
batch.where((o) => accepted.contains(o.id)).toList();
|
||||
await _store.orders.archiveAndDelete(acceptedOrders);
|
||||
await _store.refreshUnsyncedCount();
|
||||
|
||||
uploaded += acceptedOrders.length;
|
||||
syncedInvoices.addAll(acceptedOrders.map((o) => o.invoiceNumber));
|
||||
|
||||
final rejected = ids.where((id) => !accepted.contains(id)).toList();
|
||||
if (rejected.isNotEmpty) {
|
||||
await _store.orders.markFailed(rejected, 'Rejected by server');
|
||||
// Rejected rows stay pending, so the next page would return the same
|
||||
// bills forever. Stop and let the cashier retry.
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// Transport failed: record the attempt but leave every row at 0.
|
||||
} on Object catch (e) {
|
||||
// The outcome is unknown, so nothing is marked sent. The attempt is
|
||||
// recorded against the rows and every one of them stays at 0.
|
||||
await _store.orders.markFailed(ids, e.toString());
|
||||
await _store.refreshUnsyncedCount();
|
||||
|
||||
final remaining = await _store.orders.unsyncedCount();
|
||||
final pendingValue =
|
||||
batch.fold<double>(0, (s, o) => s + o.total);
|
||||
final pendingValue = batch.fold<double>(0, (s, o) => s + o.total);
|
||||
|
||||
await _log(SyncEvent(
|
||||
id: _uuid.v4(),
|
||||
@@ -238,29 +249,90 @@ class SyncRepositoryImpl implements SyncRepository {
|
||||
return SyncOutcome(
|
||||
attempted: attempted,
|
||||
uploaded: uploaded,
|
||||
rejected: refused,
|
||||
error: e.toString(),
|
||||
isRetryable: e is! TransportException || e.retryable,
|
||||
);
|
||||
}
|
||||
|
||||
// Only what the back office named is archived and marked synced.
|
||||
// Anything it stayed silent about is left pending — silence is not
|
||||
// acceptance, whatever the transport reported at its own layer.
|
||||
final acceptedIds = receipt.accepted.toSet();
|
||||
final acceptedOrders =
|
||||
batch.where((o) => acceptedIds.contains(o.id)).toList();
|
||||
await _store.orders.archiveAccepted(acceptedOrders);
|
||||
await _store.refreshUnsyncedCount();
|
||||
|
||||
uploaded += acceptedOrders.length;
|
||||
syncedInvoices.addAll(acceptedOrders.map((o) => o.invoiceNumber));
|
||||
|
||||
final unconfirmed = ids.where((id) => !acceptedIds.contains(id)).toList();
|
||||
if (unconfirmed.isNotEmpty) {
|
||||
for (final id in unconfirmed) {
|
||||
await _store.orders.markFailed(
|
||||
[id],
|
||||
receipt.rejected[id] ?? 'Not confirmed by the back office',
|
||||
);
|
||||
}
|
||||
refused += unconfirmed.length;
|
||||
|
||||
// These rows are still pending, so the next page would hand back the
|
||||
// same bills forever. Stop, and let a person look at why.
|
||||
final reasons = receipt.rejected.values.toSet().join('; ');
|
||||
await _log(SyncEvent(
|
||||
id: _uuid.v4(),
|
||||
type: SyncEventType.shiftReport,
|
||||
status: SyncStatus.failed,
|
||||
createdAt: started,
|
||||
summary: '$uploaded uploaded, ${unconfirmed.length} refused',
|
||||
error: reasons.isEmpty ? 'Not confirmed by the back office' : reasons,
|
||||
attempts: 1,
|
||||
),);
|
||||
|
||||
return SyncOutcome(
|
||||
attempted: attempted,
|
||||
uploaded: uploaded,
|
||||
rejected: refused,
|
||||
error: '${unconfirmed.length} bill(s) were not accepted'
|
||||
'${reasons.isEmpty ? '' : ': $reasons'}',
|
||||
// A refusal is a decision, not a fault. Retrying the same bytes gets
|
||||
// the same answer, so the engine halts instead of looping.
|
||||
isRetryable: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.call(0.95, 'Tidying up…');
|
||||
await purgeExpired();
|
||||
onProgress?.call(1, 'Done');
|
||||
|
||||
// Synced bills are deleted from the terminal, so this log line is the only
|
||||
// remaining record on the device that they went up.
|
||||
// Once the retention window closes this log line is the only remaining
|
||||
// record on the device that these bills went up.
|
||||
await _log(SyncEvent(
|
||||
id: _uuid.v4(),
|
||||
type: SyncEventType.shiftReport,
|
||||
status: SyncStatus.synced,
|
||||
createdAt: started,
|
||||
syncedAt: DateTime.now(),
|
||||
summary: '$uploaded of $attempted bills uploaded',
|
||||
summary: '$uploaded of $attempted bills uploaded '
|
||||
'via ${_transport.label}',
|
||||
payload: {'invoices': syncedInvoices},
|
||||
attempts: 1,
|
||||
),);
|
||||
|
||||
return SyncOutcome(attempted: attempted, uploaded: uploaded);
|
||||
return SyncOutcome(
|
||||
attempted: attempted,
|
||||
uploaded: uploaded,
|
||||
rejected: refused,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> purgeExpired() => _store.orders.purgeSyncedBefore(
|
||||
DateTime.now().subtract(OrderDao.retentionWindow),
|
||||
);
|
||||
|
||||
/// The JSON body sent per order.
|
||||
Map<String, Object?> _orderToPayload(SaleTransaction t) => {
|
||||
'id': t.id,
|
||||
@@ -277,6 +349,15 @@ class SyncRepositoryImpl implements SyncRepository {
|
||||
},
|
||||
'subtotal': t.cart.subtotal,
|
||||
'discount': t.cart.billDiscountTotal + t.cart.lineDiscountTotal,
|
||||
'promos': [
|
||||
for (final applied in t.cart.appliedPromos)
|
||||
{
|
||||
'id': applied.promo.id,
|
||||
'name': applied.promo.name,
|
||||
'type': applied.promo.type.name,
|
||||
'amount': applied.amount,
|
||||
},
|
||||
],
|
||||
'tax': t.cart.taxAmount,
|
||||
'round_off': t.cart.roundOff,
|
||||
'total': t.total,
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
360
lib/data/sync/sync_engine.dart
Normal file
360
lib/data/sync/sync_engine.dart
Normal file
@@ -0,0 +1,360 @@
|
||||
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 {
|
||||
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 'customer.dart';
|
||||
import 'product.dart';
|
||||
import 'promo.dart';
|
||||
|
||||
/// How a discount value should be interpreted.
|
||||
enum DiscountType { none, percentage, flat }
|
||||
@@ -100,6 +101,7 @@ class Cart extends Equatable {
|
||||
this.billDiscount = Discount.none,
|
||||
this.pointsRedeemed = 0,
|
||||
this.note,
|
||||
this.appliedPromos = const [],
|
||||
});
|
||||
|
||||
final List<CartLine> lines;
|
||||
@@ -108,6 +110,14 @@ class Cart extends Equatable {
|
||||
final int pointsRedeemed;
|
||||
final String? note;
|
||||
|
||||
/// Campaigns that fired on this bill.
|
||||
///
|
||||
/// Resolved by `PromoEngine` and handed in, rather than computed here: which
|
||||
/// campaigns exist is policy that changes weekly, and Cart owns arithmetic
|
||||
/// that must never be wrong. Stored as amounts so a bill read back years
|
||||
/// later shows what was actually given, not what today's rules would give.
|
||||
final List<AppliedPromo> appliedPromos;
|
||||
|
||||
static const Cart empty = Cart();
|
||||
|
||||
bool get isEmpty => lines.isEmpty;
|
||||
@@ -143,9 +153,17 @@ class Cart extends Equatable {
|
||||
|
||||
double get manualBillDiscountAmount => billDiscount.amountOn(subtotal);
|
||||
|
||||
/// What the automatic campaigns took off.
|
||||
double get promoDiscountAmount =>
|
||||
appliedPromos.fold(0.0, (sum, p) => sum + p.amount).asMoney;
|
||||
|
||||
/// All bill-level reductions combined.
|
||||
///
|
||||
/// Clamped to the subtotal so no combination of tier, campaign and manual
|
||||
/// discount can drive a bill below zero and turn a sale into a payout.
|
||||
double get billDiscountTotal =>
|
||||
(membershipDiscountAmount + manualBillDiscountAmount)
|
||||
(membershipDiscountAmount + manualBillDiscountAmount +
|
||||
promoDiscountAmount)
|
||||
.clamp(0, subtotal)
|
||||
.toDouble()
|
||||
.asMoney;
|
||||
@@ -240,6 +258,7 @@ class Cart extends Equatable {
|
||||
Discount? billDiscount,
|
||||
int? pointsRedeemed,
|
||||
String? note,
|
||||
List<AppliedPromo>? appliedPromos,
|
||||
}) {
|
||||
return Cart(
|
||||
lines: lines ?? this.lines,
|
||||
@@ -247,10 +266,11 @@ class Cart extends Equatable {
|
||||
billDiscount: billDiscount ?? this.billDiscount,
|
||||
pointsRedeemed: pointsRedeemed ?? this.pointsRedeemed,
|
||||
note: note ?? this.note,
|
||||
appliedPromos: appliedPromos ?? this.appliedPromos,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[lines, customer, billDiscount, pointsRedeemed, note];
|
||||
[lines, customer, billDiscount, pointsRedeemed, note, appliedPromos];
|
||||
}
|
||||
|
||||
202
lib/domain/entities/promo.dart
Normal file
202
lib/domain/entities/promo.dart
Normal file
@@ -0,0 +1,202 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../core/constants/app_constants.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);
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Deliberately carries no PIN. It used to, which meant the credential was in
|
||||
/// memory, in every widget that held a user, and — because the accounts were
|
||||
/// declared as constants — inside the shipped binary. Verification now happens
|
||||
/// in `StaffDao` against a stored hash, and nothing above the data layer ever
|
||||
/// sees the secret.
|
||||
class StaffUser extends Equatable {
|
||||
const StaffUser({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.role,
|
||||
required this.pin,
|
||||
this.mustChangePin = false,
|
||||
this.isActive = true,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final StaffRole role;
|
||||
|
||||
/// Four-digit quick-unlock code. Never rendered.
|
||||
final String pin;
|
||||
/// Set on a seeded or admin-reset account until the person picks their own.
|
||||
final bool mustChangePin;
|
||||
|
||||
/// Deactivated rather than deleted, so bills already rung keep pointing at a
|
||||
/// real person.
|
||||
final bool isActive;
|
||||
|
||||
StaffUser copyWith({
|
||||
String? name,
|
||||
StaffRole? role,
|
||||
bool? mustChangePin,
|
||||
bool? isActive,
|
||||
}) =>
|
||||
StaffUser(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
role: role ?? this.role,
|
||||
mustChangePin: mustChangePin ?? this.mustChangePin,
|
||||
isActive: isActive ?? this.isActive,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, name, role];
|
||||
List<Object?> get props => [id, name, role, mustChangePin, isActive];
|
||||
}
|
||||
|
||||
/// The registered outlet this terminal belongs to.
|
||||
@@ -58,6 +83,26 @@ class StoreAccount extends Equatable {
|
||||
final List<StaffUser> staff;
|
||||
final String plan;
|
||||
|
||||
StoreAccount copyWith({
|
||||
String? name,
|
||||
String? email,
|
||||
String? address,
|
||||
String? gstin,
|
||||
String? phone,
|
||||
List<StaffUser>? staff,
|
||||
String? plan,
|
||||
}) =>
|
||||
StoreAccount(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
email: email ?? this.email,
|
||||
address: address ?? this.address,
|
||||
gstin: gstin ?? this.gstin,
|
||||
phone: phone ?? this.phone,
|
||||
staff: staff ?? this.staff,
|
||||
plan: plan ?? this.plan,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, email];
|
||||
List<Object?> get props => [id, name, email, address, gstin, phone, plan];
|
||||
}
|
||||
|
||||
@@ -2,18 +2,30 @@ import '../entities/shift_report.dart';
|
||||
import '../entities/sync_event.dart';
|
||||
import '../entities/transaction.dart';
|
||||
|
||||
/// Result of one end-of-day upload.
|
||||
/// Result of one upload pass.
|
||||
class SyncOutcome {
|
||||
const SyncOutcome({
|
||||
required this.attempted,
|
||||
required this.uploaded,
|
||||
this.rejected = 0,
|
||||
this.error,
|
||||
this.isRetryable = true,
|
||||
});
|
||||
|
||||
final int attempted;
|
||||
final int uploaded;
|
||||
|
||||
/// Bills the back office looked at and refused. These stay on the terminal
|
||||
/// but sending them again unchanged will fail again, so they need a person.
|
||||
final int rejected;
|
||||
|
||||
final String? error;
|
||||
|
||||
/// Whether trying again could plausibly work. False for a bad credential or
|
||||
/// an unconfigured endpoint — the drain engine halts rather than retrying
|
||||
/// something that cannot succeed.
|
||||
final bool isRetryable;
|
||||
|
||||
bool get isSuccess => error == null;
|
||||
bool get hadNothingToDo => attempted == 0;
|
||||
int get remaining => attempted - uploaded;
|
||||
@@ -42,10 +54,11 @@ class OrderSyncRow {
|
||||
final String? error;
|
||||
}
|
||||
|
||||
/// The terminal's two network touchpoints.
|
||||
/// The terminal's network touchpoints.
|
||||
///
|
||||
/// Morning: pull the catalogue. End of day: upload every order still at
|
||||
/// `sync_status = 0`. Nothing else leaves the device.
|
||||
/// Pull the catalogue; upload every order still at `sync_status = 0`. Nothing
|
||||
/// else leaves the device. *When* the upload runs is not decided here — see
|
||||
/// `SyncEngine`, which owns triggers and retry policy.
|
||||
abstract class SyncRepository {
|
||||
bool get hasCatalogue;
|
||||
DateTime? get lastImportAt;
|
||||
@@ -71,12 +84,16 @@ abstract class SyncRepository {
|
||||
bool scopeToCashier = false,
|
||||
});
|
||||
|
||||
/// End-of-day step — uploads pending orders and flips the accepted ones to
|
||||
/// `sync_status = 1`. Failures leave every row untouched at 0.
|
||||
/// One upload pass — sends pending orders and flips the ones the back office
|
||||
/// confirmed to `sync_status = 1`. Failures leave every row untouched at 0.
|
||||
Future<SyncOutcome> syncOrders({
|
||||
void Function(double progress, String stage)? onProgress,
|
||||
});
|
||||
|
||||
/// Retires confirmed bills past their retention window. Archived totals are
|
||||
/// untouched.
|
||||
Future<int> purgeExpired();
|
||||
|
||||
Future<List<OrderSyncRow>> orderSyncRows({int limit = 200});
|
||||
|
||||
List<SyncEvent> get events;
|
||||
|
||||
151
lib/domain/services/promo_engine.dart
Normal file
151
lib/domain/services/promo_engine.dart
Normal file
@@ -0,0 +1,151 @@
|
||||
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,
|
||||
PromoType.percentOffCategory => _percentOfMatching(
|
||||
cart,
|
||||
promo.value,
|
||||
// Stored by enum name, which is stable across a label change —
|
||||
// renaming "Personal Care" must not silently switch off a campaign.
|
||||
(line) => line.product.category.name == promo.targetId,
|
||||
),
|
||||
PromoType.percentOffProduct => _percentOfMatching(
|
||||
cart,
|
||||
promo.value,
|
||||
(line) => line.product.id == promo.targetId,
|
||||
),
|
||||
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,
|
||||
bool Function(CartLine) matches,
|
||||
) {
|
||||
final base = cart.lines
|
||||
.where(matches)
|
||||
.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 List<PaymentSplit> payments,
|
||||
required String cashierName,
|
||||
String terminalId = 'TERM-01',
|
||||
required String terminalId,
|
||||
String? terminalCode,
|
||||
}) async {
|
||||
_validate(cart, payments);
|
||||
await _assertStockAvailable(cart);
|
||||
@@ -82,7 +83,11 @@ class CheckoutSale {
|
||||
|
||||
final transaction = SaleTransaction(
|
||||
id: _uuid.v4(),
|
||||
invoiceNumber: Formatters.invoiceNumber(sequence, now),
|
||||
invoiceNumber: Formatters.invoiceNumber(
|
||||
sequence,
|
||||
now,
|
||||
terminalCode: terminalCode ?? terminalId,
|
||||
),
|
||||
cart: cart,
|
||||
payments: payments,
|
||||
createdAt: now,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../domain/entities/store_account.dart';
|
||||
|
||||
/// Sign-in state for the terminal.
|
||||
@@ -31,7 +31,12 @@ class AuthFailure extends AuthState {
|
||||
final String message;
|
||||
}
|
||||
|
||||
/// Credentials that ship with the demo build.
|
||||
/// Store-level credentials for the unregistered build.
|
||||
///
|
||||
/// Still a constant, and deliberately so: this is the *store* login, not a
|
||||
/// person's, and it is replaced wholesale when the terminal is registered
|
||||
/// against a real back office. Staff PINs — the credential that actually opens
|
||||
/// a till drawer — are no longer here. They live hashed in the database.
|
||||
class DemoCredentials {
|
||||
const DemoCredentials._();
|
||||
|
||||
@@ -39,26 +44,11 @@ class DemoCredentials {
|
||||
static const String password = 'nearle123';
|
||||
}
|
||||
|
||||
const _demoStore = StoreAccount(
|
||||
id: 'store-001',
|
||||
name: AppConstants.storeName,
|
||||
email: DemoCredentials.email,
|
||||
address: AppConstants.storeAddress,
|
||||
gstin: AppConstants.storeGstin,
|
||||
phone: AppConstants.storePhone,
|
||||
staff: [
|
||||
StaffUser(id: 'u1', name: 'Suriya', role: StaffRole.admin, pin: '1234'),
|
||||
StaffUser(id: 'u2', name: 'Divya', role: StaffRole.manager, pin: '2345'),
|
||||
StaffUser(id: 'u3', name: 'Rahul', role: StaffRole.cashier, pin: '3456'),
|
||||
],
|
||||
);
|
||||
|
||||
/// Validates store credentials and holds the signed-in session.
|
||||
///
|
||||
/// Backed by a hardcoded account for now; swapping in a real identity provider
|
||||
/// means changing only [signIn].
|
||||
class AuthController extends StateNotifier<AuthState> {
|
||||
AuthController() : super(const Unauthenticated());
|
||||
AuthController(this._ref) : super(const Unauthenticated());
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
Future<bool> signIn({
|
||||
required String email,
|
||||
@@ -81,15 +71,59 @@ class AuthController extends StateNotifier<AuthState> {
|
||||
return false;
|
||||
}
|
||||
|
||||
state = Authenticated(store: _demoStore, user: _demoStore.staff.first);
|
||||
final store = await _ref.read(storeAccountProvider.future);
|
||||
final staff = store.staff;
|
||||
|
||||
if (staff.isEmpty) {
|
||||
state = const AuthFailure(
|
||||
'This terminal has no staff accounts. Reinstall to seed them.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// The first admin, or whoever is there. A person switches to their own
|
||||
// account at the till.
|
||||
final opener = staff.firstWhere(
|
||||
(s) => s.role == StaffRole.admin,
|
||||
orElse: () => staff.first,
|
||||
);
|
||||
|
||||
state = Authenticated(store: store, user: opener);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Switches the active operator without signing the store out.
|
||||
void switchUser(StaffUser user) {
|
||||
/// 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.
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Re-reads the store after staff or details change, keeping the session.
|
||||
Future<void> refreshStore() async {
|
||||
final current = state;
|
||||
if (current is! Authenticated) return;
|
||||
state = Authenticated(store: current.store, user: user);
|
||||
|
||||
_ref.invalidate(storeAccountProvider);
|
||||
final store = await _ref.read(storeAccountProvider.future);
|
||||
|
||||
final me = store.staff.where((s) => s.id == current.user.id);
|
||||
state = Authenticated(
|
||||
store: store,
|
||||
// Signed out if the active operator was just deactivated — carrying on
|
||||
// would keep stamping bills with an account the shop has revoked.
|
||||
user: me.isEmpty ? store.staff.first : me.first,
|
||||
);
|
||||
}
|
||||
|
||||
void signOut() => state = const Unauthenticated();
|
||||
@@ -99,8 +133,9 @@ class AuthController extends StateNotifier<AuthState> {
|
||||
}
|
||||
}
|
||||
|
||||
final authControllerProvider =
|
||||
StateNotifierProvider<AuthController, AuthState>((ref) => AuthController());
|
||||
final authControllerProvider = StateNotifierProvider<AuthController, AuthState>(
|
||||
AuthController.new,
|
||||
);
|
||||
|
||||
/// The signed-in store, or null before sign-in.
|
||||
final currentStoreProvider = Provider<StoreAccount?>((ref) {
|
||||
@@ -113,3 +148,9 @@ final currentUserProvider = Provider<StaffUser?>((ref) {
|
||||
final s = ref.watch(authControllerProvider);
|
||||
return s is Authenticated ? s.user : null;
|
||||
});
|
||||
|
||||
/// 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;
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ class PrinterSettings {
|
||||
this.printerName,
|
||||
this.autoPrint = false,
|
||||
this.openDrawer = true,
|
||||
this.drawerHost,
|
||||
this.drawerPort = 9100,
|
||||
});
|
||||
|
||||
/// Target passed to `directPrintPdf`. Null means "use the system default".
|
||||
@@ -31,7 +33,16 @@ class PrinterSettings {
|
||||
|
||||
final bool openDrawer;
|
||||
|
||||
/// IP address of the receipt printer the drawer is wired to.
|
||||
///
|
||||
/// Separate from [printerUrl] because that is an opaque platform handle for
|
||||
/// the PDF driver, which cannot carry raw ESC/POS bytes. The drawer kick
|
||||
/// needs a socket, so it needs an address.
|
||||
final String? drawerHost;
|
||||
final int drawerPort;
|
||||
|
||||
bool get hasPrinter => printerUrl != null;
|
||||
bool get hasDrawer => (drawerHost ?? '').isNotEmpty;
|
||||
|
||||
PrinterSettings copyWith({
|
||||
String? printerUrl,
|
||||
@@ -39,12 +50,16 @@ class PrinterSettings {
|
||||
bool clearPrinter = false,
|
||||
bool? autoPrint,
|
||||
bool? openDrawer,
|
||||
String? drawerHost,
|
||||
int? drawerPort,
|
||||
}) {
|
||||
return PrinterSettings(
|
||||
printerUrl: clearPrinter ? null : (printerUrl ?? this.printerUrl),
|
||||
printerName: clearPrinter ? null : (printerName ?? this.printerName),
|
||||
autoPrint: autoPrint ?? this.autoPrint,
|
||||
openDrawer: openDrawer ?? this.openDrawer,
|
||||
drawerHost: drawerHost ?? this.drawerHost,
|
||||
drawerPort: drawerPort ?? this.drawerPort,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -61,11 +76,26 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
|
||||
if (!store.isReady) return;
|
||||
|
||||
final dao = store.catalogue;
|
||||
|
||||
// Read every value first, then assign. Written inline the four awaits still
|
||||
// run before the assignment, so leaving Settings mid-read threw
|
||||
// "used after dispose" — which reaches the cashier as a red screen.
|
||||
final url = await dao.meta(MetaKeys.printerUrl);
|
||||
final name = await dao.meta(MetaKeys.printerName);
|
||||
final autoPrint = await dao.meta(MetaKeys.autoPrint);
|
||||
final openDrawer = await dao.meta(MetaKeys.openDrawer);
|
||||
final drawerHost = await dao.meta(MetaKeys.drawerHost);
|
||||
final drawerPort = await dao.meta(MetaKeys.drawerPort);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
state = PrinterSettings(
|
||||
printerUrl: await dao.meta(MetaKeys.printerUrl),
|
||||
printerName: await dao.meta(MetaKeys.printerName),
|
||||
autoPrint: (await dao.meta(MetaKeys.autoPrint)) == '1',
|
||||
openDrawer: (await dao.meta(MetaKeys.openDrawer)) != '0',
|
||||
printerUrl: url,
|
||||
printerName: name,
|
||||
autoPrint: autoPrint == '1',
|
||||
openDrawer: openDrawer != '0',
|
||||
drawerHost: drawerHost,
|
||||
drawerPort: int.tryParse(drawerPort ?? '') ?? 9100,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,12 +105,14 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
|
||||
if (printer == null) {
|
||||
await dao.setMeta(MetaKeys.printerUrl, '');
|
||||
await dao.setMeta(MetaKeys.printerName, '');
|
||||
if (!mounted) return;
|
||||
state = state.copyWith(clearPrinter: true, autoPrint: false);
|
||||
return;
|
||||
}
|
||||
|
||||
await dao.setMeta(MetaKeys.printerUrl, printer.url);
|
||||
await dao.setMeta(MetaKeys.printerName, printer.name);
|
||||
if (!mounted) return;
|
||||
state = state.copyWith(
|
||||
printerUrl: printer.url,
|
||||
printerName: printer.name,
|
||||
@@ -95,6 +127,7 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
|
||||
.read(localStoreProvider)
|
||||
.catalogue
|
||||
.setMeta(MetaKeys.autoPrint, value ? '1' : '0');
|
||||
if (!mounted) return;
|
||||
state = state.copyWith(autoPrint: value);
|
||||
}
|
||||
|
||||
@@ -103,8 +136,21 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
|
||||
.read(localStoreProvider)
|
||||
.catalogue
|
||||
.setMeta(MetaKeys.openDrawer, value ? '1' : '0');
|
||||
if (!mounted) return;
|
||||
state = state.copyWith(openDrawer: value);
|
||||
}
|
||||
|
||||
/// Points the drawer kick at a printer.
|
||||
///
|
||||
/// A blank host disables it — which is the honest state for a USB printer,
|
||||
/// since there is no raw path to one from Flutter.
|
||||
Future<void> setDrawerAddress(String host, int port) async {
|
||||
final dao = _ref.read(localStoreProvider).catalogue;
|
||||
await dao.setMeta(MetaKeys.drawerHost, host.trim());
|
||||
await dao.setMeta(MetaKeys.drawerPort, '$port');
|
||||
if (!mounted) return;
|
||||
state = state.copyWith(drawerHost: host.trim(), drawerPort: port);
|
||||
}
|
||||
}
|
||||
|
||||
final printerSettingsProvider =
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../core/widgets/status_pill.dart';
|
||||
import '../../../domain/entities/customer.dart';
|
||||
import '../../customer/widgets/customer_capture_sheet.dart';
|
||||
import '../widgets/module_widgets.dart';
|
||||
|
||||
/// Full customer book, independent of the six shown during billing.
|
||||
@@ -108,7 +109,13 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
|
||||
title: 'Customer book',
|
||||
subtitle: '${filtered.length} shown',
|
||||
action: FilledButton.icon(
|
||||
onPressed: () {},
|
||||
// Reuses the sheet the till already opens at checkout, so a
|
||||
// customer added here is validated the same way — including the
|
||||
// duplicate-mobile guard.
|
||||
onPressed: () async {
|
||||
await showCustomerCaptureSheet(context);
|
||||
ref.invalidate(allCustomersProvider);
|
||||
},
|
||||
icon: const Icon(Icons.person_add_alt_1_rounded, size: 17),
|
||||
label: const Text('Add customer'),
|
||||
style: FilledButton.styleFrom(
|
||||
|
||||
@@ -1,202 +1,304 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../domain/entities/promo.dart';
|
||||
import '../../../domain/entities/store_account.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
import '../../../core/widgets/empty_state.dart';
|
||||
import '../widgets/module_widgets.dart';
|
||||
import '../widgets/promo_editor_dialog.dart';
|
||||
|
||||
/// Discount rules and campaigns.
|
||||
class PromosView extends StatefulWidget {
|
||||
///
|
||||
/// This was a mockup: three hardcoded rows with a toggle that changed nothing,
|
||||
/// and no promo code anywhere in the domain or data layers. A cashier looking
|
||||
/// at it would reasonably conclude promotions were running. They were not.
|
||||
class PromosView extends ConsumerWidget {
|
||||
const PromosView({super.key});
|
||||
|
||||
@override
|
||||
State<PromosView> createState() => _PromosViewState();
|
||||
}
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final promosAsync = ref.watch(promosProvider);
|
||||
final isAdmin = ref.watch(currentUserProvider)?.role == StaffRole.admin;
|
||||
|
||||
class _PromosViewState extends State<PromosView> {
|
||||
final Set<String> _enabled = {'WEEKEND10', 'DAIRY5', 'FESTIVE'};
|
||||
|
||||
static const _campaigns = [
|
||||
(
|
||||
'WEEKEND10',
|
||||
'Weekend Saver',
|
||||
'10% off bills above ₹500',
|
||||
'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(
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: AppSpacing.lg,
|
||||
runSpacing: AppSpacing.lg,
|
||||
children: [
|
||||
StatTile(
|
||||
label: 'Active Campaigns',
|
||||
value: '${_enabled.length}',
|
||||
icon: Icons.campaign_rounded,
|
||||
caption: 'of ${_campaigns.length} configured',
|
||||
),
|
||||
const StatTile(
|
||||
label: 'Redemptions',
|
||||
value: '970',
|
||||
icon: Icons.confirmation_number_rounded,
|
||||
color: AppColors.info,
|
||||
caption: 'this month',
|
||||
),
|
||||
const StatTile(
|
||||
label: 'Discount Given',
|
||||
value: '₹48,240',
|
||||
icon: Icons.local_offer_rounded,
|
||||
color: AppColors.warning,
|
||||
caption: '2.6% of sales',
|
||||
),
|
||||
const StatTile(
|
||||
label: 'Incremental Sales',
|
||||
value: '₹2.14L',
|
||||
icon: Icons.trending_up_rounded,
|
||||
color: AppColors.success,
|
||||
delta: '+18%',
|
||||
caption: 'attributed',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
PanelCard(
|
||||
title: 'Campaigns',
|
||||
subtitle: 'Toggle a rule to apply it at the till immediately',
|
||||
action: FilledButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.add_rounded, size: 18),
|
||||
label: const Text('New campaign'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
minimumSize: const Size(0, 40),
|
||||
),
|
||||
promosAsync.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(AppSpacing.xxl),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
error: (e, _) => Text('Could not load campaigns: $e'),
|
||||
data: (promos) => Column(
|
||||
children: [
|
||||
for (final c in _campaigns)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: AppRadius.brMd,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
// Wrap prevents collision when the panel is narrow.
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.spaceBetween,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: AppSpacing.md,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 320,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: c.$6.withValues(alpha: 0.12),
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: Icon(Icons.sell_rounded,
|
||||
size: 18, color: c.$6,),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
c.$2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
c.$3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TagChip(c.$1, color: c.$6),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
TagChip(c.$4, color: AppColors.textSecondary),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(
|
||||
'${c.$5} used',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Switch(
|
||||
value: _enabled.contains(c.$1),
|
||||
onChanged: (v) => setState(() {
|
||||
if (v) {
|
||||
_enabled.add(c.$1);
|
||||
} else {
|
||||
_enabled.remove(c.$1);
|
||||
}
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_summary(promos),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_campaignList(context, ref, promos, isAdmin: isAdmin),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
children: [
|
||||
Expanded(
|
||||
child: StatTile(
|
||||
label: 'Running now',
|
||||
value: '$live',
|
||||
icon: Icons.play_circle_outline_rounded,
|
||||
color: AppColors.success,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: StatTile(
|
||||
label: 'Scheduled',
|
||||
value: '$scheduled',
|
||||
icon: Icons.schedule_rounded,
|
||||
color: AppColors.info,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: StatTile(
|
||||
label: 'Paused',
|
||||
value: '${promos.where((p) => !p.isActive).length}',
|
||||
icon: Icons.pause_circle_outline_rounded,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _campaignList(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
List<Promo> promos, {
|
||||
required bool isAdmin,
|
||||
}) {
|
||||
return PanelCard(
|
||||
title: 'Campaigns',
|
||||
subtitle: promos.isEmpty
|
||||
? 'Nothing running. Add a campaign and the till applies it '
|
||||
'automatically.'
|
||||
: 'Applied automatically at the till, in priority order',
|
||||
action: isAdmin
|
||||
? FilledButton.icon(
|
||||
onPressed: () => showPromoEditor(context),
|
||||
icon: const Icon(Icons.add_rounded, size: 18),
|
||||
label: const Text('New campaign'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
minimumSize: const Size(0, 40),
|
||||
),
|
||||
)
|
||||
: 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,
|
||||
children: [
|
||||
for (final promo in promos)
|
||||
_PromoRow(promo: promo, isAdmin: isAdmin),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PromoRow extends ConsumerWidget {
|
||||
const _PromoRow({required this.promo, required this.isAdmin});
|
||||
|
||||
final Promo promo;
|
||||
final bool isAdmin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final live = promo.isLiveAt(DateTime.now());
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: AppRadius.brMd,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
// Wrap prevents collision when the panel is narrow.
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.spaceBetween,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: AppSpacing.md,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 360,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: (live ? AppColors.success : AppColors.textTertiary)
|
||||
.withValues(alpha: 0.12),
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.sell_rounded,
|
||||
size: 18,
|
||||
color: live ? AppColors.success : AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
promo.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
Text(
|
||||
promo.summary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: AppSpacing.sm,
|
||||
children: [
|
||||
if (promo.stackable)
|
||||
const TagChip('Stacks', color: AppColors.info),
|
||||
// The distinction a shop actually needs: switched on, but out of
|
||||
// its date range or wrong day, is not the same as switched off.
|
||||
if (promo.isActive && !live)
|
||||
const TagChip('Not today', color: AppColors.warning),
|
||||
Text(
|
||||
_window(promo),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
if (isAdmin) ...[
|
||||
Switch(
|
||||
value: promo.isActive,
|
||||
onChanged: (v) async {
|
||||
await ref
|
||||
.read(localStoreProvider)
|
||||
.promos
|
||||
.setActive(promo.id, active: v);
|
||||
ref
|
||||
..invalidate(promosProvider)
|
||||
..invalidate(activePromosProvider);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Edit',
|
||||
icon: const Icon(Icons.edit_outlined, size: 17),
|
||||
onPressed: () => showPromoEditor(context, existing: promo),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Delete',
|
||||
icon: const Icon(Icons.delete_outline_rounded, size: 17),
|
||||
color: AppColors.danger,
|
||||
onPressed: () => _confirmDelete(context, ref),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _window(Promo promo) {
|
||||
final from = promo.validFrom;
|
||||
final to = promo.validTo;
|
||||
|
||||
if (from == null && to == null) {
|
||||
return promo.daysOfWeek.isEmpty ? 'Always' : _days(promo.daysOfWeek);
|
||||
}
|
||||
|
||||
final range = [
|
||||
if (from != null) 'from ${Formatters.date(from)}',
|
||||
if (to != null) 'to ${Formatters.date(to)}',
|
||||
].join(' ');
|
||||
|
||||
return promo.daysOfWeek.isEmpty
|
||||
? range
|
||||
: '$range · ${_days(promo.daysOfWeek)}';
|
||||
}
|
||||
|
||||
static String _days(Set<int> days) {
|
||||
const names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
final sorted = days.toList()..sort();
|
||||
return sorted.map((d) => names[d - 1]).join(', ');
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text('Delete "${promo.name}"?'),
|
||||
content: const Text(
|
||||
'Bills already rung keep the discount they were given — a bill '
|
||||
'stores the amount, not a link to the campaign. Only future sales '
|
||||
'are affected.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.danger),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
await ref.read(localStoreProvider).promos.delete(promo.id);
|
||||
ref
|
||||
..invalidate(promosProvider)
|
||||
..invalidate(activePromosProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,19 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../core/config/sync_config.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
import '../../../core/services/cash_drawer_service.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../data/local/order_dao.dart';
|
||||
import '../../../domain/entities/store_account.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
import '../providers/printer_settings.dart';
|
||||
import '../widgets/back_office_dialog.dart';
|
||||
import '../widgets/staff_dialogs.dart';
|
||||
import '../widgets/store_details_dialog.dart';
|
||||
import '../../sync/providers/sync_controller.dart';
|
||||
import '../widgets/module_widgets.dart';
|
||||
|
||||
@@ -21,15 +27,36 @@ class SettingsView extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
final _drawerHost = TextEditingController();
|
||||
final _drawerPort = TextEditingController(text: '9100');
|
||||
bool _testingDrawer = false;
|
||||
bool _loadedDrawerFields = false;
|
||||
|
||||
bool _scannerSound = true;
|
||||
bool _roundOff = true;
|
||||
bool _autoLoyalty = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_drawerHost.dispose();
|
||||
_drawerPort.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final store = ref.watch(currentStoreProvider);
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
// Seeded once, from whatever was persisted. Assigning on every build would
|
||||
// fight the cashier for the cursor while they type.
|
||||
final printer = ref.watch(printerSettingsProvider);
|
||||
if (!_loadedDrawerFields && printer.hasDrawer) {
|
||||
_loadedDrawerFields = true;
|
||||
_drawerHost.text = printer.drawerHost ?? '';
|
||||
_drawerPort.text = '${printer.drawerPort}';
|
||||
}
|
||||
|
||||
return ModulePage(
|
||||
children: [
|
||||
LayoutBuilder(
|
||||
@@ -79,7 +106,10 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
Widget _storeCard(StoreAccount? store) => PanelCard(
|
||||
title: 'Store details',
|
||||
subtitle: 'Printed on every invoice',
|
||||
action: TextButton(onPressed: () {}, child: const Text('Edit')),
|
||||
action: TextButton(
|
||||
onPressed: () => showStoreDetailsDialog(context),
|
||||
child: const Text('Edit'),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -236,7 +266,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
.read(receiptServiceProvider)
|
||||
.printTestPage(
|
||||
printerUrl: settings.printerUrl,);
|
||||
if (!context.mounted) return;
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(
|
||||
@@ -282,18 +312,102 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
),
|
||||
_toggle(
|
||||
'Open cash drawer on cash sales',
|
||||
'Needs a raw ESC/POS link — see the notes in ReceiptService',
|
||||
settings.hasDrawer
|
||||
? 'Kicks the drawer on ${settings.drawerHost} after a cash '
|
||||
'tender'
|
||||
: 'Enter the printer\'s IP address below to enable this',
|
||||
settings.openDrawer,
|
||||
controller.setOpenDrawer,
|
||||
settings.hasDrawer ? controller.setOpenDrawer : null,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_drawerAddressField(settings, controller),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The drawer needs a socket, not the print driver.
|
||||
///
|
||||
/// A PDF is rendered by the platform driver, which will not pass raw ESC/POS
|
||||
/// bytes through to the device — so the printer's own address is the only way
|
||||
/// to reach the drawer wired to its RJ11 port.
|
||||
Widget _drawerAddressField(
|
||||
PrinterSettings settings,
|
||||
PrinterSettingsController controller,
|
||||
) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextFormField(
|
||||
controller: _drawerHost,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Printer IP address',
|
||||
hintText: '192.168.1.50',
|
||||
helperText: 'Leave blank for a USB printer',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
SizedBox(
|
||||
width: 84,
|
||||
child: TextFormField(
|
||||
controller: _drawerPort,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Port', isDense: true),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.xs),
|
||||
child: OutlinedButton(
|
||||
onPressed: _testingDrawer
|
||||
? null
|
||||
: () => _saveAndTestDrawer(controller),
|
||||
child: _testingDrawer
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Test'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveAndTestDrawer(PrinterSettingsController controller) async {
|
||||
setState(() => _testingDrawer = true);
|
||||
|
||||
final port = int.tryParse(_drawerPort.text.trim()) ?? 9100;
|
||||
await controller.setDrawerAddress(_drawerHost.text, port);
|
||||
|
||||
final result = await ref.read(receiptServiceProvider).openCashDrawer(
|
||||
host: _drawerHost.text,
|
||||
port: port,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _testingDrawer = false);
|
||||
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(
|
||||
backgroundColor:
|
||||
result.isSuccess ? AppColors.success : AppColors.danger,
|
||||
content: Text(result.message),
|
||||
),);
|
||||
}
|
||||
|
||||
Widget _staffCard(StoreAccount? store, StaffUser? current) => PanelCard(
|
||||
title: 'Users & roles',
|
||||
action: TextButton(onPressed: () {}, child: const Text('Manage')),
|
||||
action: TextButton(
|
||||
onPressed: () => showStaffDialog(context),
|
||||
child: const Text('Manage'),
|
||||
),
|
||||
child: ResponsiveTable(
|
||||
stackBelow: 360,
|
||||
columns: const [
|
||||
@@ -305,10 +419,12 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
.map((s) => [
|
||||
Cell(s.name, bold: true),
|
||||
Cell(s.role.label, color: AppColors.textSecondary),
|
||||
s.id == current?.id
|
||||
? const TagChip('Signed in',
|
||||
color: AppColors.success,)
|
||||
: const SizedBox.shrink(),
|
||||
if (s.id == current?.id)
|
||||
const TagChip('Signed in', color: AppColors.success)
|
||||
else if (s.mustChangePin)
|
||||
const TagChip('Default PIN', color: AppColors.warning)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
],)
|
||||
.toList(),
|
||||
),
|
||||
@@ -318,11 +434,19 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
final ready = ref.watch(catalogueReadyProvider);
|
||||
final lastImport = ref.watch(lastImportAtProvider);
|
||||
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
|
||||
final config = ref.watch(syncConfigProvider);
|
||||
final terminal = ref.watch(terminalIdentityProvider);
|
||||
final sync = ref.watch(syncEngineStateProvider).value ??
|
||||
ref.watch(syncEngineProvider).state;
|
||||
|
||||
return PanelCard(
|
||||
title: 'Connectivity & sync',
|
||||
subtitle: 'This terminal only needs a connection to import the '
|
||||
'catalogue and to push the shift report.',
|
||||
subtitle: 'Bills are written to this terminal first and uploaded in the '
|
||||
'background. Nothing is ever held up waiting for the network.',
|
||||
action: TextButton(
|
||||
onPressed: () => showBackOfficeDialog(context),
|
||||
child: const Text('Configure'),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -331,7 +455,27 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
'Last import',
|
||||
lastImport == null ? 'Never' : Formatters.dateTime(lastImport),
|
||||
),
|
||||
_row('Unsynced bills', '$outstanding'),
|
||||
_row('Terminal', '${terminal.name} · ${terminal.code}'),
|
||||
_row('Route', _transportLabel(config)),
|
||||
_row('Waiting to upload', '$outstanding bill(s)'),
|
||||
_row(
|
||||
'Last upload',
|
||||
sync.lastSuccessAt == null
|
||||
? 'Never'
|
||||
: Formatters.dateTime(sync.lastSuccessAt!),
|
||||
),
|
||||
if (sync.isHalted)
|
||||
_row('Status', 'Halted — ${sync.lastError ?? 'refused'}')
|
||||
else if (sync.nextAttemptAt != null)
|
||||
_row(
|
||||
'Next attempt',
|
||||
'${Formatters.time(sync.nextAttemptAt!)} '
|
||||
'(attempt ${sync.consecutiveFailures + 1})',
|
||||
),
|
||||
_row(
|
||||
'Bills kept on device',
|
||||
'${OrderDao.retentionWindow.inDays} days after upload',
|
||||
),
|
||||
_toggle(
|
||||
'Simulate offline',
|
||||
'Forces import and sync to fail, so you can confirm nothing is '
|
||||
@@ -340,6 +484,9 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
ref.watch(simulateOfflineProvider),
|
||||
(v) {
|
||||
ref.read(simulateOfflineProvider.notifier).state = v;
|
||||
// The pill and the drain both read connectivity, so they have to
|
||||
// be told the switch moved.
|
||||
ref.read(connectivityServiceProvider).refresh();
|
||||
// Clear any stale failure banner left by the previous setting.
|
||||
ref.read(catalogueImportProvider.notifier).reset();
|
||||
ref.read(orderSyncProvider.notifier).reset();
|
||||
@@ -350,26 +497,44 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _aboutCard() => PanelCard(
|
||||
String _transportLabel(SyncConfig config) => switch (config.transport) {
|
||||
TransportKind.simulated =>
|
||||
'Simulated — no back office configured for this terminal',
|
||||
TransportKind.http => 'HTTP · ${config.httpBaseUrl}',
|
||||
TransportKind.mqtt =>
|
||||
'MQTT · ${config.brokerHost}:${config.brokerPort}'
|
||||
'${config.useTls ? ' (TLS)' : ''}',
|
||||
};
|
||||
|
||||
Widget _aboutCard() {
|
||||
final terminal = ref.watch(terminalIdentityProvider);
|
||||
|
||||
return PanelCard(
|
||||
title: 'About',
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_row('Application', '${AppConstants.appName} 1.0.0'),
|
||||
_row('Terminal', 'TERM-01'),
|
||||
_row('Data store', 'SQLite (on device)'),
|
||||
_row('Application',
|
||||
'${AppConstants.appName} ${AppConstants.appVersion}',),
|
||||
_row('Terminal', '${terminal.name} (${terminal.code})'),
|
||||
// The identifier support asks for. Stable for the life of the
|
||||
// device, and the only thing that ties this till to its history.
|
||||
_row('Device ID', terminal.deviceId, mono: true),
|
||||
_row('Store', terminal.storeId),
|
||||
_row('Data store', 'SQLite (on device, WAL)'),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.sync_rounded, size: 17),
|
||||
label: const Text('Check for updates'),
|
||||
onPressed: () => showBackOfficeDialog(context),
|
||||
icon: const Icon(Icons.settings_ethernet_rounded, size: 17),
|
||||
label: const Text('Back office connection'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(String label, String value, {bool mono = false}) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../core/utils/extensions.dart';
|
||||
import '../../../data/sync/sync_engine.dart';
|
||||
import '../../../domain/entities/transaction.dart';
|
||||
import '../../../domain/usecases/checkout_sale.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
import '../../pos/providers/cart_controller.dart';
|
||||
import '../../modules/providers/printer_settings.dart';
|
||||
import '../../pos/providers/catalog_providers.dart';
|
||||
import '../../sync/providers/sync_controller.dart';
|
||||
|
||||
@@ -190,7 +192,18 @@ class PaymentController extends StateNotifier<PaymentState> {
|
||||
// No auto-print: with no roll printer attached this silently failed and
|
||||
// looked like a bug. The receipt screen shows the bill on the terminal
|
||||
// and offers Print, WhatsApp and Share explicitly.
|
||||
unawaited(_ref.read(receiptServiceProvider).openCashDrawer());
|
||||
// Only for cash. A card-only sale that pops the drawer is a shrinkage
|
||||
// risk, and it is what a shop notices first.
|
||||
final printer = _ref.read(printerSettingsProvider);
|
||||
final tookCash = splits.any((p) => p.method == PaymentMethod.cash);
|
||||
if (printer.openDrawer && printer.hasDrawer && tookCash) {
|
||||
unawaited(
|
||||
_ref.read(receiptServiceProvider).openCashDrawer(
|
||||
host: printer.drawerHost,
|
||||
port: printer.drawerPort,
|
||||
),
|
||||
);
|
||||
}
|
||||
unawaited(_ref.read(soundServiceProvider).saleComplete());
|
||||
|
||||
// Stock changed, so the grid must refresh; the new order changes the
|
||||
@@ -199,6 +212,11 @@ class PaymentController extends StateNotifier<PaymentState> {
|
||||
_ref.invalidate(visibleProductsProvider);
|
||||
_ref.read(orderVersionProvider.notifier).state++;
|
||||
|
||||
// The bill is safely on disk; getting it to the back office is the
|
||||
// engine's problem now. Deliberately not awaited — the cashier must
|
||||
// reach the receipt screen at network speed of zero.
|
||||
_ref.read(syncEngineProvider).nudge(SyncTrigger.saleCommitted);
|
||||
|
||||
return result;
|
||||
} on CheckoutFailure catch (e) {
|
||||
state = state.copyWith(
|
||||
|
||||
@@ -9,9 +9,11 @@ import '../../../core/services/sound_service.dart';
|
||||
import '../../../domain/entities/cart.dart';
|
||||
import '../../../domain/entities/customer.dart';
|
||||
import '../../../domain/entities/product.dart';
|
||||
import '../../../domain/entities/promo.dart';
|
||||
import '../../../domain/entities/transaction.dart';
|
||||
import '../../../domain/repositories/product_repository.dart';
|
||||
import '../../../domain/repositories/transaction_repository.dart';
|
||||
import '../../../domain/services/promo_engine.dart';
|
||||
|
||||
/// Transient feedback for the scan toast — never a blocking dialog.
|
||||
enum ScanOutcome { added, incremented, notFound, outOfStock }
|
||||
@@ -43,9 +45,13 @@ class CartController extends StateNotifier<Cart> {
|
||||
required TransactionRepository transactions,
|
||||
required SoundService sound,
|
||||
required this.onFeedback,
|
||||
List<Promo> promos = const [],
|
||||
DateTime Function()? clock,
|
||||
}) : _products = products,
|
||||
_transactions = transactions,
|
||||
_sound = sound,
|
||||
_promos = promos,
|
||||
_now = clock ?? DateTime.now,
|
||||
super(Cart.empty);
|
||||
|
||||
final ProductRepository _products;
|
||||
@@ -53,8 +59,29 @@ class CartController extends StateNotifier<Cart> {
|
||||
final SoundService _sound;
|
||||
final void Function(ScanFeedback) onFeedback;
|
||||
|
||||
/// Campaigns live right now. Re-evaluated after every change to the bill,
|
||||
/// because whether one fires depends on what is in it.
|
||||
final List<Promo> _promos;
|
||||
final DateTime Function() _now;
|
||||
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// Applies the campaign rules to [next] and stores the result.
|
||||
///
|
||||
/// Every mutation goes through here rather than assigning `state` directly,
|
||||
/// so a promo cannot be left applied after the line that earned it is
|
||||
/// removed — which is how a shopper gets a discount for an item they put
|
||||
/// back.
|
||||
void _commit(Cart next) {
|
||||
state = next.copyWith(
|
||||
appliedPromos: PromoEngine.evaluate(
|
||||
cart: next,
|
||||
promos: _promos,
|
||||
at: _now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Snapshots for undo — capped so memory can't grow unbounded on a terminal
|
||||
/// that runs for days.
|
||||
final List<Cart> _undoStack = [];
|
||||
@@ -105,18 +132,18 @@ class CartController extends StateNotifier<Cart> {
|
||||
}
|
||||
|
||||
if (existing == null) {
|
||||
state = state.copyWith(lines: [
|
||||
_commit(state.copyWith(lines: [
|
||||
...state.lines,
|
||||
CartLine(
|
||||
product: product,
|
||||
quantity: quantity,
|
||||
addedAt: DateTime.now(),
|
||||
),
|
||||
],);
|
||||
],),);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
_commit(state.copyWith(
|
||||
lines: _replace(existing.copyWith(quantity: requested)),
|
||||
);
|
||||
),);
|
||||
}
|
||||
|
||||
_clampRedemption();
|
||||
@@ -171,7 +198,7 @@ class CartController extends StateNotifier<Cart> {
|
||||
}
|
||||
|
||||
_push();
|
||||
state = state.copyWith(lines: _replace(line.copyWith(quantity: capped)));
|
||||
_commit(state.copyWith(lines: _replace(line.copyWith(quantity: capped))));
|
||||
_clampRedemption();
|
||||
}
|
||||
|
||||
@@ -190,9 +217,9 @@ class CartController extends StateNotifier<Cart> {
|
||||
void removeLine(String productId) {
|
||||
if (!state.contains(productId)) return;
|
||||
_push();
|
||||
state = state.copyWith(
|
||||
_commit(state.copyWith(
|
||||
lines: state.lines.where((l) => l.product.id != productId).toList(),
|
||||
);
|
||||
),);
|
||||
_clampRedemption();
|
||||
}
|
||||
|
||||
@@ -200,14 +227,14 @@ class CartController extends StateNotifier<Cart> {
|
||||
final line = state.lineFor(productId);
|
||||
if (line == null) return;
|
||||
_push();
|
||||
state = state.copyWith(lines: _replace(line.copyWith(discount: discount)));
|
||||
_commit(state.copyWith(lines: _replace(line.copyWith(discount: discount))));
|
||||
_clampRedemption();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Bill level
|
||||
void applyBillDiscount(Discount discount) {
|
||||
_push();
|
||||
state = state.copyWith(billDiscount: discount);
|
||||
_commit(state.copyWith(billDiscount: discount));
|
||||
_clampRedemption();
|
||||
}
|
||||
|
||||
@@ -215,9 +242,11 @@ class CartController extends StateNotifier<Cart> {
|
||||
|
||||
void attachCustomer(Customer? customer) {
|
||||
_push();
|
||||
state = customer == null
|
||||
? state.copyWith(clearCustomer: true, pointsRedeemed: 0)
|
||||
: state.copyWith(customer: customer);
|
||||
_commit(
|
||||
customer == null
|
||||
? state.copyWith(clearCustomer: true, pointsRedeemed: 0)
|
||||
: state.copyWith(customer: customer),
|
||||
);
|
||||
_clampRedemption();
|
||||
}
|
||||
|
||||
@@ -275,7 +304,9 @@ class CartController extends StateNotifier<Cart> {
|
||||
Future<void> resume(ParkedBill bill) async {
|
||||
await _transactions.removeParked(bill.id);
|
||||
_undoStack.clear();
|
||||
state = bill.cart;
|
||||
// Re-evaluated rather than restored: a campaign that has since ended must
|
||||
// not be honoured just because the bill was parked while it was running.
|
||||
_commit(bill.cart);
|
||||
}
|
||||
|
||||
List<CartLine> _replace(CartLine updated) => [
|
||||
@@ -293,6 +324,10 @@ final cartControllerProvider =
|
||||
products: ref.watch(productRepositoryProvider),
|
||||
transactions: ref.watch(transactionRepositoryProvider),
|
||||
sound: ref.watch(soundServiceProvider),
|
||||
// Watched, so editing a campaign in Settings takes effect at the till
|
||||
// without a restart. An empty list until they load is correct — no promo
|
||||
// is safer than a stale one.
|
||||
promos: ref.watch(activePromosProvider).value ?? const [],
|
||||
onFeedback: (feedback) =>
|
||||
ref.read(scanFeedbackProvider.notifier).state = feedback,
|
||||
);
|
||||
|
||||
@@ -6,11 +6,13 @@ import '../../../core/services/barcode_service.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_layout.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
import '../../modules/screens/customers_view.dart';
|
||||
import '../../modules/screens/events_view.dart';
|
||||
import '../../modules/screens/product_import_view.dart';
|
||||
import '../../modules/screens/promos_view.dart';
|
||||
import '../../modules/screens/settings_view.dart';
|
||||
import '../../modules/widgets/staff_dialogs.dart';
|
||||
import '../../sync/providers/sync_controller.dart';
|
||||
import '../providers/cart_controller.dart';
|
||||
import '../providers/catalog_providers.dart';
|
||||
@@ -45,10 +47,18 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
|
||||
final FocusNode _searchFocus = FocusNode();
|
||||
late final BarcodeService _barcode;
|
||||
|
||||
/// Guards against re-opening the PIN dialog on every rebuild.
|
||||
bool _promptedForPin = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Anyone still on a PIN this build shipped with is asked to choose their
|
||||
// own before ringing a sale. Deferred to the first frame because it opens
|
||||
// a dialog, which needs a Navigator that exists.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _maybePromptForPin());
|
||||
|
||||
// The scanner behaves like a keyboard, so listen globally rather than
|
||||
// depending on any one field holding focus. A scan from another module
|
||||
// jumps back to billing, which is what a cashier expects.
|
||||
@@ -65,6 +75,14 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
|
||||
)..attach();
|
||||
}
|
||||
|
||||
Future<void> _maybePromptForPin() async {
|
||||
if (_promptedForPin || !mounted) return;
|
||||
if (!ref.read(mustChangePinProvider)) return;
|
||||
|
||||
_promptedForPin = true;
|
||||
await showForcedPinChange(context);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_barcode.dispose();
|
||||
|
||||
@@ -242,6 +242,17 @@ class _Summary extends ConsumerWidget {
|
||||
valueColor: AppColors.success,
|
||||
),
|
||||
|
||||
// Named individually rather than lumped into one "Promotions" line:
|
||||
// a shopper who came in for a specific offer needs to see it applied,
|
||||
// and a cashier being asked "did the weekend deal come off?" needs to
|
||||
// answer without opening a report.
|
||||
for (final applied in cart.appliedPromos)
|
||||
_Row(
|
||||
label: applied.promo.name,
|
||||
value: '-${Formatters.money(applied.amount)}',
|
||||
valueColor: AppColors.success,
|
||||
),
|
||||
|
||||
_Row(
|
||||
label: 'GST',
|
||||
value: Formatters.money(cart.taxAmount),
|
||||
|
||||
@@ -154,16 +154,19 @@ class _Breadcrumb extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _LivePill extends StatefulWidget {
|
||||
/// What the pill is saying, in the order it takes precedence.
|
||||
enum _Liveness { offlineSim, halted, syncing, queued, live }
|
||||
|
||||
class _LivePill extends ConsumerStatefulWidget {
|
||||
const _LivePill({required this.offline});
|
||||
|
||||
final bool offline;
|
||||
|
||||
@override
|
||||
State<_LivePill> createState() => _LivePillState();
|
||||
ConsumerState<_LivePill> createState() => _LivePillState();
|
||||
}
|
||||
|
||||
class _LivePillState extends State<_LivePill>
|
||||
class _LivePillState extends ConsumerState<_LivePill>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _c = AnimationController(
|
||||
vsync: this,
|
||||
@@ -178,16 +181,62 @@ class _LivePillState extends State<_LivePill>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final offline = widget.offline;
|
||||
final tone = offline ? AppColors.warning : AppColors.success;
|
||||
final surface =
|
||||
offline ? AppColors.warningSurface : AppColors.successSurface;
|
||||
// The engine may not have emitted yet on a cold start, so fall back to its
|
||||
// current value rather than showing nothing.
|
||||
final sync = ref.watch(syncEngineStateProvider).value ??
|
||||
ref.watch(syncEngineProvider).state;
|
||||
|
||||
final liveness = switch (0) {
|
||||
_ when widget.offline => _Liveness.offlineSim,
|
||||
_ when sync.isHalted => _Liveness.halted,
|
||||
_ when sync.isSyncing => _Liveness.syncing,
|
||||
// Bills waiting is normal for a few seconds after a sale; it is only
|
||||
// worth flagging once they are visibly piling up.
|
||||
_ when sync.pending > 0 => _Liveness.queued,
|
||||
_ => _Liveness.live,
|
||||
};
|
||||
|
||||
final (label, tone, surface, message) = switch (liveness) {
|
||||
_Liveness.offlineSim => (
|
||||
'OFFLINE (SIM)',
|
||||
AppColors.warning,
|
||||
AppColors.warningSurface,
|
||||
'Simulate offline is ON in Settings — imports and syncs are being '
|
||||
'failed deliberately.',
|
||||
),
|
||||
_Liveness.halted => (
|
||||
'SYNC HALTED',
|
||||
AppColors.danger,
|
||||
AppColors.dangerSurface,
|
||||
'Uploading stopped because retrying will not help: '
|
||||
'${sync.lastError ?? 'the back office refused the batch'}. '
|
||||
'Every bill is still safe on this terminal. Press Sync to try '
|
||||
'again once it is sorted.',
|
||||
),
|
||||
_Liveness.syncing => (
|
||||
'SYNCING',
|
||||
AppColors.primary,
|
||||
AppColors.primarySurface,
|
||||
'Uploading bills to the back office.',
|
||||
),
|
||||
_Liveness.queued => (
|
||||
'${sync.pending} QUEUED',
|
||||
AppColors.warning,
|
||||
AppColors.warningSurface,
|
||||
'${sync.pending} bill(s) are stored on this terminal and waiting to '
|
||||
'upload. They are safe; nothing is lost while the line is down.',
|
||||
),
|
||||
_Liveness.live => (
|
||||
'LIVE',
|
||||
AppColors.success,
|
||||
AppColors.successSurface,
|
||||
'Terminal is operating normally and everything rung has been '
|
||||
'uploaded.',
|
||||
),
|
||||
};
|
||||
|
||||
return Tooltip(
|
||||
message: offline
|
||||
? 'Simulate offline is ON in Settings — imports and syncs are being '
|
||||
'failed deliberately.'
|
||||
: 'Terminal is operating normally.',
|
||||
message: message,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
@@ -210,7 +259,7 @@ class _LivePillState extends State<_LivePill>
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs + 2),
|
||||
Text(
|
||||
offline ? 'OFFLINE (SIM)' : 'LIVE',
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: tone,
|
||||
fontSize: 10.5,
|
||||
|
||||
@@ -249,7 +249,7 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
||||
final sent = await ref
|
||||
.read(receiptServiceProvider)
|
||||
.sendToWhatsApp(txn);
|
||||
if (!context.mounted || sent) return;
|
||||
if (!mounted || sent) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(const SnackBar(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
import '../../../data/remote/mqtt_order_transport.dart';
|
||||
import '../../../data/sync/presence_reporter.dart';
|
||||
import '../../../domain/entities/shift_report.dart';
|
||||
import '../../../domain/entities/sync_event.dart';
|
||||
import '../../../domain/repositories/sync_repository.dart';
|
||||
@@ -167,6 +170,12 @@ class OrderSyncController extends StateNotifier<OrderSyncState> {
|
||||
bool get isRunning => state is SyncRunning;
|
||||
|
||||
/// Uploads every bill at sync_status = 0 and flips the accepted ones to 1.
|
||||
///
|
||||
/// Goes through the engine rather than straight to the repository, so a
|
||||
/// cashier pressing sync while a background drain is already mid-flight
|
||||
/// joins it instead of starting a second pass over the same rows. It also
|
||||
/// clears a halt: pressing the button is how you retry after the back office
|
||||
/// has been fixed.
|
||||
Future<SyncOutcome> run() async {
|
||||
if (isRunning) {
|
||||
return const SyncOutcome(attempted: 0, uploaded: 0);
|
||||
@@ -174,7 +183,7 @@ class OrderSyncController extends StateNotifier<OrderSyncState> {
|
||||
|
||||
state = const SyncRunning(0, 'Starting…');
|
||||
|
||||
final outcome = await _ref.read(syncRepositoryProvider).syncOrders(
|
||||
final outcome = await _ref.read(syncEngineProvider).syncNow(
|
||||
onProgress: (progress, stage) {
|
||||
if (mounted) state = SyncRunning(progress, stage);
|
||||
},
|
||||
@@ -192,3 +201,55 @@ final orderSyncProvider =
|
||||
StateNotifierProvider<OrderSyncController, OrderSyncState>(
|
||||
(ref) => OrderSyncController(ref),
|
||||
);
|
||||
|
||||
// ------------------------------------------------------- Background drain
|
||||
/// Brings the queue-and-drain machinery up, once, when the shell mounts.
|
||||
///
|
||||
/// Deliberately not gated on sign-in: a terminal that boots holding yesterday's
|
||||
/// bills should be emptying its queue before anyone reaches the till.
|
||||
///
|
||||
/// Overridden to a no-op in widget tests, which have no network stack and
|
||||
/// cannot drive real disk I/O on a fake clock.
|
||||
final syncBootstrapProvider = FutureProvider<void>((ref) async {
|
||||
// Restore the route this terminal was pointed at. Without this the settings
|
||||
// are written on Save and then silently ignored on the next launch, which
|
||||
// reads exactly like they never saved.
|
||||
final store = ref.read(localStoreProvider);
|
||||
if (store.isReady) {
|
||||
ref.read(syncConfigProvider.notifier).state =
|
||||
await store.syncConfig.load(ref.read(syncConfigProvider));
|
||||
}
|
||||
|
||||
await ref.read(connectivityServiceProvider).start();
|
||||
|
||||
final engine = ref.read(syncEngineProvider);
|
||||
|
||||
// A background drain moves bills out of the pending set, so the tallies and
|
||||
// shift totals on screen are stale the moment one finishes.
|
||||
var wasSyncing = false;
|
||||
final subscription = engine.states.listen((state) {
|
||||
if (wasSyncing && !state.isSyncing) {
|
||||
ref.read(orderVersionProvider.notifier).state++;
|
||||
}
|
||||
wasSyncing = state.isSyncing;
|
||||
});
|
||||
ref.onDispose(subscription.cancel);
|
||||
|
||||
await engine.start();
|
||||
|
||||
// Fleet presence only exists on a transport that can carry it.
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
#include <printing/printing_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
@@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
|
||||
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) printing_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin");
|
||||
printing_plugin_register_with_registrar(printing_registrar);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_linux
|
||||
flutter_secure_storage_linux
|
||||
printing
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
@@ -6,12 +6,16 @@ import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import audioplayers_darwin
|
||||
import connectivity_plus
|
||||
import flutter_secure_storage_darwin
|
||||
import printing
|
||||
import sqflite_darwin
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
|
||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
|
||||
116
pubspec.lock
116
pubspec.lock
@@ -137,14 +137,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
crypto:
|
||||
connectivity_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: connectivity_plus
|
||||
sha256: "762c99f890ca8bf87f7337236f99edd42793843bc6c3631da294a76653a54bd0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.3.1"
|
||||
connectivity_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: connectivity_plus_platform_interface
|
||||
sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
crypto:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.13"
|
||||
equatable:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -153,6 +177,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
event_bus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: event_bus
|
||||
sha256: "1a55e97923769c286d295240048fc180e7b0768902c3c2e869fe059aafa15304"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -169,6 +201,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
ffi_leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi_leak_tracker
|
||||
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.2"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -214,6 +254,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.3.1"
|
||||
flutter_secure_storage_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_darwin
|
||||
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.2"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.2"
|
||||
flutter_shaders:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -265,7 +353,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
http:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
@@ -384,6 +472,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
mqtt_client:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mqtt_client
|
||||
sha256: "41c8edd3bc8efc80c1c8ebfb40081c24d12d13085faca96b9280a624eca2d893"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.11.11"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -392,6 +488,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.19.2"
|
||||
nm:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: nm
|
||||
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -789,6 +893,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -39,6 +39,11 @@ dependencies:
|
||||
audioplayers: ^6.0.0
|
||||
pdf: ^3.11.0
|
||||
printing: ^5.13.0
|
||||
mqtt_client: ^10.11.11
|
||||
connectivity_plus: ^7.3.1
|
||||
http: ^1.6.0
|
||||
crypto: ^3.0.7
|
||||
flutter_secure_storage: ^10.3.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
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),
|
||||
],
|
||||
cashierName: 'Suriya',
|
||||
terminalId: 'T0TEST',
|
||||
);
|
||||
|
||||
final txn = result.transaction;
|
||||
@@ -83,6 +84,7 @@ void main() {
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: 124, tendered: 124),
|
||||
],
|
||||
cashierName: 'Suriya',
|
||||
terminalId: 'T0TEST',
|
||||
);
|
||||
|
||||
final milk = await products.findByBarcode('8901234500011');
|
||||
@@ -98,6 +100,7 @@ void main() {
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: 124, tendered: 124),
|
||||
],
|
||||
cashierName: 'Suriya',
|
||||
terminalId: 'T0TEST',
|
||||
);
|
||||
|
||||
final history = await transactions.history();
|
||||
@@ -119,6 +122,7 @@ void main() {
|
||||
),
|
||||
],
|
||||
cashierName: 'Suriya',
|
||||
terminalId: 'T0TEST',
|
||||
);
|
||||
|
||||
expect(result.transaction.isSplit, isTrue);
|
||||
@@ -140,6 +144,7 @@ void main() {
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: 122, tendered: 200),
|
||||
],
|
||||
cashierName: 'Suriya',
|
||||
terminalId: 'T0TEST',
|
||||
);
|
||||
|
||||
final updated = result.updatedCustomer!;
|
||||
@@ -158,6 +163,7 @@ void main() {
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: 0),
|
||||
],
|
||||
cashierName: 'Suriya',
|
||||
terminalId: 'T0TEST',
|
||||
),
|
||||
throwsA(isA<CheckoutFailure>()),
|
||||
);
|
||||
@@ -166,7 +172,12 @@ void main() {
|
||||
test('refuses a bill with no tender', () async {
|
||||
final cart = await milkCart();
|
||||
expect(
|
||||
() => checkout(cart: cart, payments: const [], cashierName: 'Suriya'),
|
||||
() => checkout(
|
||||
cart: cart,
|
||||
payments: const [],
|
||||
cashierName: 'Suriya',
|
||||
terminalId: 'T0TEST',
|
||||
),
|
||||
throwsA(isA<CheckoutFailure>()),
|
||||
);
|
||||
});
|
||||
@@ -180,6 +191,7 @@ void main() {
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: 100, tendered: 100),
|
||||
],
|
||||
cashierName: 'Suriya',
|
||||
terminalId: 'T0TEST',
|
||||
),
|
||||
throwsA(isA<CheckoutFailure>()),
|
||||
);
|
||||
@@ -198,6 +210,7 @@ void main() {
|
||||
),
|
||||
],
|
||||
cashierName: 'Suriya',
|
||||
terminalId: 'T0TEST',
|
||||
),
|
||||
throwsA(isA<CheckoutFailure>()),
|
||||
);
|
||||
@@ -206,7 +219,12 @@ void main() {
|
||||
test('leaves stock untouched when validation fails', () async {
|
||||
final cart = await milkCart();
|
||||
try {
|
||||
await checkout(cart: cart, payments: const [], cashierName: 'Suriya');
|
||||
await checkout(
|
||||
cart: cart,
|
||||
payments: const [],
|
||||
cashierName: 'Suriya',
|
||||
terminalId: 'T0TEST',
|
||||
);
|
||||
} on CheckoutFailure {
|
||||
// expected
|
||||
}
|
||||
|
||||
171
test/unit/fleet_identity_test.dart
Normal file
171
test/unit/fleet_identity_test.dart
Normal file
@@ -0,0 +1,171 @@
|
||||
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),
|
||||
'pos.store-01.TA1B2.order',
|
||||
);
|
||||
expect(
|
||||
SyncConfig.asNatsSubject(config.statusTopic),
|
||||
'pos.store-01.TA1B2.status',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -139,7 +139,7 @@ void main() {
|
||||
await AppDatabase.instance.open(overridePath: dbPath);
|
||||
final db = AppDatabase.instance.db;
|
||||
|
||||
expect(await db.getVersion(), 4);
|
||||
expect(await db.getVersion(), 7);
|
||||
|
||||
final rows = await db.query('day_archive');
|
||||
expect(rows, hasLength(1));
|
||||
@@ -148,6 +148,17 @@ void main() {
|
||||
expect(row['business_date'], '2026-07-30');
|
||||
expect(row['cashier_name'], '',
|
||||
reason: 'rows from before per-cashier attribution get an empty name',);
|
||||
|
||||
// v5 adds staff. An upgraded terminal must come up with the table present
|
||||
// but empty — seeding is the store's job on first open, not the migration's,
|
||||
// so an existing shop is never handed accounts it did not create.
|
||||
final staff = await db.query('staff');
|
||||
expect(staff, isEmpty);
|
||||
|
||||
// v6 adds campaigns. Same rule: the table exists, and an upgraded shop is
|
||||
// not handed promotions it never created.
|
||||
final promos = await db.query('promos');
|
||||
expect(promos, isEmpty);
|
||||
expect(row['bill_count'], 12);
|
||||
expect(row['gross_sales'], 8450.0);
|
||||
expect(row['tax_collected'], 620.5);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nearle_pos/data/datasources/local_store.dart';
|
||||
import 'package:nearle_pos/data/datasources/remote_catalogue_source.dart';
|
||||
import 'package:nearle_pos/data/remote/simulated_catalogue_source.dart';
|
||||
import 'package:nearle_pos/data/datasources/seed_data.dart';
|
||||
import 'package:nearle_pos/data/remote/simulated_order_transport.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';
|
||||
@@ -81,6 +82,7 @@ void main() {
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: 529, tendered: 529),
|
||||
],
|
||||
cashierName: 'Divya',
|
||||
terminalId: 'T0TEST',
|
||||
);
|
||||
|
||||
final stored = (await transactions.history()).single;
|
||||
@@ -112,6 +114,7 @@ void main() {
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: 589, tendered: 600),
|
||||
],
|
||||
cashierName: 'Divya',
|
||||
terminalId: 'T0TEST',
|
||||
);
|
||||
|
||||
final stored = (await transactions.history()).single;
|
||||
@@ -134,6 +137,7 @@ void main() {
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: 529, tendered: 529),
|
||||
],
|
||||
cashierName: 'Divya',
|
||||
terminalId: 'T0TEST',
|
||||
);
|
||||
|
||||
final report = ShiftReport.fromTransactions(
|
||||
@@ -192,6 +196,7 @@ void main() {
|
||||
),
|
||||
],
|
||||
cashierName: 'Divya',
|
||||
terminalId: 'T0TEST',
|
||||
),
|
||||
throwsA(isA<CheckoutFailure>()),
|
||||
);
|
||||
@@ -209,6 +214,7 @@ void main() {
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: 248, tendered: 250),
|
||||
],
|
||||
cashierName: 'Divya',
|
||||
terminalId: 'T0TEST',
|
||||
);
|
||||
expect((await products.findByBarcode('8901234500011'))!.stock,
|
||||
opening - 4,);
|
||||
@@ -247,6 +253,7 @@ void main() {
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: 124, tendered: 124),
|
||||
],
|
||||
cashierName: 'Divya',
|
||||
terminalId: 'T0TEST',
|
||||
),
|
||||
throwsA(isA<CheckoutFailure>()),
|
||||
);
|
||||
@@ -269,8 +276,8 @@ void main() {
|
||||
test('the archived day totals match what was charged', () async {
|
||||
final sync = SyncRepositoryImpl(
|
||||
store,
|
||||
RemoteCatalogueSource(isOffline: () => false),
|
||||
RemoteOrderSink(isOffline: () => false),
|
||||
SimulatedCatalogueSource(isOffline: () => false),
|
||||
SimulatedOrderTransport(isOffline: () => false),
|
||||
);
|
||||
|
||||
final milk = (await products.findByBarcode('8901234500011'))!;
|
||||
@@ -287,6 +294,7 @@ void main() {
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: 529, tendered: 529),
|
||||
],
|
||||
cashierName: 'Divya',
|
||||
terminalId: 'T0TEST',
|
||||
);
|
||||
|
||||
final outcome = await sync.syncOrders();
|
||||
@@ -313,8 +321,8 @@ void main() {
|
||||
() async {
|
||||
final sync = SyncRepositoryImpl(
|
||||
store,
|
||||
RemoteCatalogueSource(isOffline: () => false),
|
||||
RemoteOrderSink(isOffline: () => false),
|
||||
SimulatedCatalogueSource(isOffline: () => false),
|
||||
SimulatedOrderTransport(isOffline: () => false),
|
||||
);
|
||||
|
||||
final milk = (await products.findByBarcode('8901234500011'))!;
|
||||
@@ -324,6 +332,7 @@ void main() {
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: 62, tendered: 62),
|
||||
],
|
||||
cashierName: 'Divya',
|
||||
terminalId: 'T0TEST',
|
||||
);
|
||||
await sync.syncOrders();
|
||||
|
||||
@@ -342,8 +351,8 @@ void main() {
|
||||
test('a failed import is recorded with its error', () async {
|
||||
final sync = SyncRepositoryImpl(
|
||||
store,
|
||||
RemoteCatalogueSource(isOffline: () => true),
|
||||
RemoteOrderSink(isOffline: () => true),
|
||||
SimulatedCatalogueSource(isOffline: () => true),
|
||||
SimulatedOrderTransport(isOffline: () => true),
|
||||
);
|
||||
|
||||
await sync.importCatalogue();
|
||||
@@ -364,14 +373,15 @@ void main() {
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: due, tendered: due),
|
||||
],
|
||||
cashierName: cashier,
|
||||
terminalId: 'T0TEST',
|
||||
);
|
||||
}
|
||||
|
||||
test('a cashier settles their own till, not the terminal', () async {
|
||||
final sync = SyncRepositoryImpl(
|
||||
store,
|
||||
RemoteCatalogueSource(isOffline: () => false),
|
||||
RemoteOrderSink(isOffline: () => false),
|
||||
SimulatedCatalogueSource(isOffline: () => false),
|
||||
SimulatedOrderTransport(isOffline: () => false),
|
||||
);
|
||||
|
||||
await sell('Divya', 2); // 124
|
||||
@@ -396,8 +406,8 @@ void main() {
|
||||
test('scoping holds after the bills are uploaded and deleted', () async {
|
||||
final sync = SyncRepositoryImpl(
|
||||
store,
|
||||
RemoteCatalogueSource(isOffline: () => false),
|
||||
RemoteOrderSink(isOffline: () => false),
|
||||
SimulatedCatalogueSource(isOffline: () => false),
|
||||
SimulatedOrderTransport(isOffline: () => false),
|
||||
);
|
||||
|
||||
await sell('Divya', 2);
|
||||
|
||||
449
test/unit/promo_engine_test.dart
Normal file
449
test/unit/promo_engine_test.dart
Normal file
@@ -0,0 +1,449 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nearle_pos/domain/entities/cart.dart';
|
||||
import 'package:nearle_pos/domain/entities/product.dart';
|
||||
import 'package:nearle_pos/domain/entities/promo.dart';
|
||||
import 'package:nearle_pos/domain/services/promo_engine.dart';
|
||||
|
||||
/// Campaigns give money away automatically, so the rules that decide how much
|
||||
/// are the ones worth pinning down hardest.
|
||||
void main() {
|
||||
Product product({
|
||||
String id = 'p1',
|
||||
ProductCategory category = ProductCategory.beverages,
|
||||
double price = 100,
|
||||
double gstRate = 0.18,
|
||||
}) =>
|
||||
Product(
|
||||
id: id,
|
||||
name: 'Item $id',
|
||||
barcode: 'bc-$id',
|
||||
sku: 'sku-$id',
|
||||
category: category,
|
||||
price: price,
|
||||
stock: 100,
|
||||
gstRate: gstRate,
|
||||
);
|
||||
|
||||
Cart cartOf(List<({Product product, double qty})> items) => Cart(
|
||||
lines: [
|
||||
for (final i in items)
|
||||
CartLine(product: i.product, quantity: i.qty),
|
||||
],
|
||||
);
|
||||
|
||||
final monday = DateTime(2026, 8, 3, 11);
|
||||
|
||||
group('amount', () {
|
||||
test('a percentage off the bill', () {
|
||||
final cart = cartOf([(product: product(), qty: 3)]); // 300
|
||||
const promo = Promo(
|
||||
id: 'a',
|
||||
name: '10% off',
|
||||
type: PromoType.percentOffBill,
|
||||
value: 10,
|
||||
);
|
||||
|
||||
expect(PromoEngine.amountFor(promo: promo, cart: cart), 30);
|
||||
});
|
||||
|
||||
test('a flat amount off the bill', () {
|
||||
final cart = cartOf([(product: product(), qty: 3)]);
|
||||
const promo = Promo(
|
||||
id: 'a',
|
||||
name: '50 off',
|
||||
type: PromoType.flatOffBill,
|
||||
value: 50,
|
||||
);
|
||||
|
||||
expect(PromoEngine.amountFor(promo: promo, cart: cart), 50);
|
||||
});
|
||||
|
||||
test('a category promo touches only that category', () {
|
||||
final cart = cartOf([
|
||||
(product: product(category: ProductCategory.beverages), qty: 2), // 200
|
||||
(product: product(id: 'p2', category: ProductCategory.snacks), qty: 3),
|
||||
]);
|
||||
const promo = Promo(
|
||||
id: 'a',
|
||||
name: '20% off drinks',
|
||||
type: PromoType.percentOffCategory,
|
||||
value: 20,
|
||||
targetId: 'beverages',
|
||||
);
|
||||
|
||||
expect(PromoEngine.amountFor(promo: promo, cart: cart), 40);
|
||||
});
|
||||
|
||||
test('a product promo touches only that product', () {
|
||||
final cart = cartOf([
|
||||
(product: product(), qty: 2),
|
||||
(product: product(id: 'p2'), qty: 4),
|
||||
]);
|
||||
const promo = Promo(
|
||||
id: 'a',
|
||||
name: '50% off p2',
|
||||
type: PromoType.percentOffProduct,
|
||||
value: 50,
|
||||
targetId: 'p2',
|
||||
);
|
||||
|
||||
expect(PromoEngine.amountFor(promo: promo, cart: cart), 200);
|
||||
});
|
||||
|
||||
test('a promo whose target is not in the bill is worth nothing', () {
|
||||
final cart = cartOf([(product: product(), qty: 2)]);
|
||||
const promo = Promo(
|
||||
id: 'a',
|
||||
name: '20% off Dairy',
|
||||
type: PromoType.percentOffCategory,
|
||||
value: 20,
|
||||
targetId: 'dairy',
|
||||
);
|
||||
|
||||
expect(PromoEngine.amountFor(promo: promo, cart: cart), 0);
|
||||
});
|
||||
|
||||
test('a cap stops a large trolley giving away more than was costed', () {
|
||||
final cart = cartOf([(product: product(), qty: 50)]); // 5000
|
||||
const promo = Promo(
|
||||
id: 'a',
|
||||
name: '20% off, max 200',
|
||||
type: PromoType.percentOffBill,
|
||||
value: 20,
|
||||
maxDiscount: 200,
|
||||
);
|
||||
|
||||
expect(PromoEngine.amountFor(promo: promo, cart: cart), 200);
|
||||
});
|
||||
});
|
||||
|
||||
group('buy X get Y', () {
|
||||
const promo = Promo(
|
||||
id: 'bxgy',
|
||||
name: 'Buy 2 get 1',
|
||||
type: PromoType.buyXGetY,
|
||||
targetId: 'p1',
|
||||
buyQuantity: 2,
|
||||
freeQuantity: 1,
|
||||
);
|
||||
|
||||
test('gives nothing until a whole group is in the bill', () {
|
||||
for (final qty in [1.0, 2.0]) {
|
||||
final cart = cartOf([(product: product(), qty: qty)]);
|
||||
expect(PromoEngine.amountFor(promo: promo, cart: cart), 0,
|
||||
reason: '$qty items should not earn a free one',);
|
||||
}
|
||||
});
|
||||
|
||||
test('gives one free per completed group, and no more', () {
|
||||
// Three earns one. Five still earns one — the fifth has not paid for a
|
||||
// second group.
|
||||
expect(
|
||||
PromoEngine.amountFor(
|
||||
promo: promo,
|
||||
cart: cartOf([(product: product(), qty: 3)]),
|
||||
),
|
||||
100,
|
||||
);
|
||||
expect(
|
||||
PromoEngine.amountFor(
|
||||
promo: promo,
|
||||
cart: cartOf([(product: product(), qty: 5)]),
|
||||
),
|
||||
100,
|
||||
);
|
||||
expect(
|
||||
PromoEngine.amountFor(
|
||||
promo: promo,
|
||||
cart: cartOf([(product: product(), qty: 6)]),
|
||||
),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
test('prices the free unit at what is actually being charged', () {
|
||||
// A line already carrying a manual discount must not refund more than it
|
||||
// took in the first place.
|
||||
final cart = Cart(
|
||||
lines: [
|
||||
CartLine(
|
||||
product: product(),
|
||||
quantity: 3,
|
||||
discount: const Discount(type: DiscountType.percentage, value: 50),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(PromoEngine.amountFor(promo: promo, cart: cart), 50);
|
||||
});
|
||||
|
||||
test('a malformed buy-X-get-Y is worth nothing rather than everything', () {
|
||||
final cart = cartOf([(product: product(), qty: 10)]);
|
||||
const broken = Promo(
|
||||
id: 'b',
|
||||
name: 'Buy 0 get 0',
|
||||
type: PromoType.buyXGetY,
|
||||
targetId: 'p1',
|
||||
);
|
||||
|
||||
expect(PromoEngine.amountFor(promo: broken, cart: cart), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('eligibility', () {
|
||||
final cart = Cart(
|
||||
lines: [CartLine(product: product(), quantity: 3)],
|
||||
); // 300
|
||||
|
||||
List<AppliedPromo> run(List<Promo> promos, {DateTime? at}) =>
|
||||
PromoEngine.evaluate(cart: cart, promos: promos, at: at ?? monday);
|
||||
|
||||
test('an inactive campaign never fires', () {
|
||||
expect(
|
||||
run([
|
||||
const Promo(
|
||||
id: 'a',
|
||||
name: 'Paused',
|
||||
type: PromoType.percentOffBill,
|
||||
value: 10,
|
||||
isActive: false,
|
||||
),
|
||||
]),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('a campaign outside its dates never fires', () {
|
||||
const promo = Promo(
|
||||
id: 'a',
|
||||
name: 'August',
|
||||
type: PromoType.percentOffBill,
|
||||
value: 10,
|
||||
);
|
||||
|
||||
final august = promo.copyWith(
|
||||
validFrom: DateTime(2026, 8),
|
||||
validTo: DateTime(2026, 8, 31),
|
||||
);
|
||||
|
||||
expect(run([august], at: DateTime(2026, 7, 31, 23)), isEmpty);
|
||||
expect(run([august], at: DateTime(2026, 8, 15)), hasLength(1));
|
||||
|
||||
// Inclusive of the closing day: a campaign "to the 31st" runs all of it.
|
||||
expect(run([august], at: DateTime(2026, 8, 31, 22)), hasLength(1));
|
||||
expect(run([august], at: DateTime(2026, 9, 1)), isEmpty);
|
||||
});
|
||||
|
||||
test('a weekday-restricted campaign fires only on those days', () {
|
||||
const weekend = Promo(
|
||||
id: 'a',
|
||||
name: 'Weekends',
|
||||
type: PromoType.percentOffBill,
|
||||
value: 10,
|
||||
daysOfWeek: {6, 7},
|
||||
);
|
||||
|
||||
expect(run([weekend], at: DateTime(2026, 8, 3)), isEmpty); // Monday
|
||||
expect(run([weekend], at: DateTime(2026, 8, 8)), hasLength(1)); // Sat
|
||||
expect(run([weekend], at: DateTime(2026, 8, 9)), hasLength(1)); // Sun
|
||||
});
|
||||
|
||||
test('a minimum bill value is enforced', () {
|
||||
const promo = Promo(
|
||||
id: 'a',
|
||||
name: 'Over 500',
|
||||
type: PromoType.flatOffBill,
|
||||
value: 50,
|
||||
minBillValue: 500,
|
||||
);
|
||||
|
||||
expect(run([promo]), isEmpty, reason: 'the bill is only 300');
|
||||
});
|
||||
});
|
||||
|
||||
group('stacking', () {
|
||||
final cart = Cart(
|
||||
lines: [CartLine(product: product(), quantity: 10)],
|
||||
); // 1000
|
||||
|
||||
List<AppliedPromo> run(List<Promo> promos) =>
|
||||
PromoEngine.evaluate(cart: cart, promos: promos, at: monday);
|
||||
|
||||
test('only the best exclusive campaign applies', () {
|
||||
// Two stacking percentages compound into a discount nobody costed, and
|
||||
// the shop finds out at the end of the month.
|
||||
final applied = run(const [
|
||||
Promo(id: 'a', name: '5%', type: PromoType.percentOffBill, value: 5),
|
||||
Promo(id: 'b', name: '15%', type: PromoType.percentOffBill, value: 15),
|
||||
Promo(id: 'c', name: '10%', type: PromoType.percentOffBill, value: 10),
|
||||
]);
|
||||
|
||||
expect(applied, hasLength(1));
|
||||
expect(applied.single.promo.id, 'b');
|
||||
expect(applied.single.amount, 150);
|
||||
});
|
||||
|
||||
test('the best means best for the shopper, not highest percentage', () {
|
||||
final applied = run(const [
|
||||
Promo(id: 'pct', name: '5%', type: PromoType.percentOffBill, value: 5),
|
||||
Promo(
|
||||
id: 'flat',
|
||||
name: '200 off',
|
||||
type: PromoType.flatOffBill,
|
||||
value: 200,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(applied.single.promo.id, 'flat');
|
||||
expect(applied.single.amount, 200);
|
||||
});
|
||||
|
||||
test('stackable campaigns all apply, alongside one exclusive', () {
|
||||
final applied = run(const [
|
||||
Promo(
|
||||
id: 's1',
|
||||
name: 'Stack 50',
|
||||
type: PromoType.flatOffBill,
|
||||
value: 50,
|
||||
stackable: true,
|
||||
priority: 10,
|
||||
),
|
||||
Promo(
|
||||
id: 's2',
|
||||
name: 'Stack 30',
|
||||
type: PromoType.flatOffBill,
|
||||
value: 30,
|
||||
stackable: true,
|
||||
priority: 20,
|
||||
),
|
||||
Promo(id: 'e1', name: '10%', type: PromoType.percentOffBill, value: 10),
|
||||
Promo(id: 'e2', name: '5%', type: PromoType.percentOffBill, value: 5),
|
||||
]);
|
||||
|
||||
expect(applied.map((a) => a.promo.id), ['s1', 's2', 'e1']);
|
||||
expect(applied.fold<double>(0, (s, a) => s + a.amount), 180);
|
||||
});
|
||||
|
||||
test('priority breaks a tie between equal exclusive campaigns', () {
|
||||
final applied = run(const [
|
||||
Promo(
|
||||
id: 'low',
|
||||
name: 'A',
|
||||
type: PromoType.flatOffBill,
|
||||
value: 100,
|
||||
priority: 50,
|
||||
),
|
||||
Promo(
|
||||
id: 'high',
|
||||
name: 'B',
|
||||
type: PromoType.flatOffBill,
|
||||
value: 100,
|
||||
priority: 10,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(applied.single.promo.id, 'high');
|
||||
});
|
||||
|
||||
test('no combination can drive the bill below zero', () {
|
||||
// The one outcome that must be impossible: a sale that becomes a payout.
|
||||
final applied = run(const [
|
||||
Promo(
|
||||
id: 'a',
|
||||
name: 'Huge',
|
||||
type: PromoType.flatOffBill,
|
||||
value: 900,
|
||||
stackable: true,
|
||||
),
|
||||
Promo(
|
||||
id: 'b',
|
||||
name: 'Also huge',
|
||||
type: PromoType.flatOffBill,
|
||||
value: 900,
|
||||
stackable: true,
|
||||
),
|
||||
]);
|
||||
|
||||
final total = applied.fold<double>(0, (s, a) => s + a.amount);
|
||||
expect(total, lessThanOrEqualTo(1000));
|
||||
expect(total, 1000);
|
||||
});
|
||||
});
|
||||
|
||||
group('on the bill', () {
|
||||
test('a promo reduces the total and is included in savings', () {
|
||||
final base = Cart(lines: [CartLine(product: product(), quantity: 10)]);
|
||||
expect(base.grandTotal, 1000);
|
||||
|
||||
const promo = Promo(
|
||||
id: 'a',
|
||||
name: '10% off',
|
||||
type: PromoType.percentOffBill,
|
||||
value: 10,
|
||||
);
|
||||
|
||||
final withPromo = base.copyWith(
|
||||
appliedPromos: [const AppliedPromo(promo: promo, amount: 100)],
|
||||
);
|
||||
|
||||
expect(withPromo.promoDiscountAmount, 100);
|
||||
expect(withPromo.billDiscountTotal, 100);
|
||||
expect(withPromo.grandTotal, 900);
|
||||
expect(withPromo.totalSavings, 100);
|
||||
});
|
||||
|
||||
test('GST is recomputed against the reduced total, not the original', () {
|
||||
// Prices are GST-inclusive, so a discount reduces the tax collected. Left
|
||||
// unapportioned the shop would remit tax on money it never took.
|
||||
final base = Cart(lines: [CartLine(product: product(), quantity: 10)]);
|
||||
const promo = Promo(
|
||||
id: 'a',
|
||||
name: '10% off',
|
||||
type: PromoType.percentOffBill,
|
||||
value: 10,
|
||||
);
|
||||
|
||||
final withPromo = base.copyWith(
|
||||
appliedPromos: [const AppliedPromo(promo: promo, amount: 100)],
|
||||
);
|
||||
|
||||
expect(withPromo.taxAmount, lessThan(base.taxAmount));
|
||||
expect(withPromo.taxAmount, closeTo(base.taxAmount * 0.9, 0.02));
|
||||
expect(
|
||||
withPromo.taxableAmount + withPromo.taxAmount,
|
||||
closeTo(withPromo.netAmount, 0.02),
|
||||
);
|
||||
});
|
||||
|
||||
test('a promo and a tier discount both apply, without going negative', () {
|
||||
final base = Cart(lines: [CartLine(product: product(), quantity: 10)]);
|
||||
const promo = Promo(
|
||||
id: 'a',
|
||||
name: 'Everything free',
|
||||
type: PromoType.flatOffBill,
|
||||
value: 5000,
|
||||
);
|
||||
|
||||
final withPromo = base.copyWith(
|
||||
appliedPromos: [const AppliedPromo(promo: promo, amount: 5000)],
|
||||
);
|
||||
|
||||
expect(withPromo.billDiscountTotal, 1000);
|
||||
expect(withPromo.grandTotal, 0);
|
||||
expect(withPromo.netAmount, greaterThanOrEqualTo(0));
|
||||
});
|
||||
|
||||
test('an empty cart earns nothing', () {
|
||||
expect(
|
||||
PromoEngine.evaluate(
|
||||
cart: Cart.empty,
|
||||
promos: const [
|
||||
Promo(id: 'a', name: 'x', type: PromoType.flatOffBill, value: 50),
|
||||
],
|
||||
at: monday,
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
290
test/unit/promo_persistence_test.dart
Normal file
290
test/unit/promo_persistence_test.dart
Normal file
@@ -0,0 +1,290 @@
|
||||
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/promo_dao.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/promo.dart';
|
||||
import 'package:nearle_pos/domain/entities/transaction.dart';
|
||||
import 'package:nearle_pos/domain/services/promo_engine.dart';
|
||||
import 'package:nearle_pos/domain/usecases/checkout_sale.dart';
|
||||
|
||||
void main() {
|
||||
late LocalStore store;
|
||||
late PromoDao promos;
|
||||
|
||||
setUpAll(() {
|
||||
LocalStore.registerSeed(
|
||||
products: SeedData.products,
|
||||
customers: SeedData.customers,
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
store = LocalStore.instance;
|
||||
await store.reset(withCatalogue: true);
|
||||
promos = store.promos;
|
||||
});
|
||||
|
||||
const weekendSaver = Promo(
|
||||
id: '',
|
||||
name: 'Weekend Saver',
|
||||
type: PromoType.percentOffBill,
|
||||
value: 10,
|
||||
minBillValue: 500,
|
||||
maxDiscount: 200,
|
||||
daysOfWeek: {6, 7},
|
||||
);
|
||||
|
||||
group('storage', () {
|
||||
test('a campaign round-trips with every field intact', () async {
|
||||
final saved = await promos.save(weekendSaver.copyWith(
|
||||
validFrom: DateTime(2026, 8),
|
||||
validTo: DateTime(2026, 8, 31),
|
||||
),);
|
||||
|
||||
final read = await promos.findById(saved.id);
|
||||
|
||||
expect(read!.name, 'Weekend Saver');
|
||||
expect(read.type, PromoType.percentOffBill);
|
||||
expect(read.value, 10);
|
||||
expect(read.minBillValue, 500);
|
||||
expect(read.maxDiscount, 200);
|
||||
expect(read.daysOfWeek, {6, 7});
|
||||
expect(read.validFrom, DateTime(2026, 8));
|
||||
expect(read.stackable, isFalse);
|
||||
expect(read.isActive, isTrue);
|
||||
});
|
||||
|
||||
test('editing keeps the id, so nothing points at a stale row', () async {
|
||||
final saved = await promos.save(weekendSaver);
|
||||
final edited = await promos.save(saved.copyWith(value: 15));
|
||||
|
||||
expect(edited.id, saved.id);
|
||||
expect(await promos.all(), hasLength(1));
|
||||
expect(edited.value, 15);
|
||||
});
|
||||
|
||||
test('pausing removes it from what the till applies', () async {
|
||||
final saved = await promos.save(weekendSaver);
|
||||
|
||||
await promos.setActive(saved.id, active: false);
|
||||
|
||||
expect(await promos.all(), hasLength(1));
|
||||
expect(await promos.all(activeOnly: true), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('validation', () {
|
||||
test('a campaign must give something away', () async {
|
||||
await expectLater(
|
||||
promos.save(const Promo(
|
||||
id: '',
|
||||
name: 'Nothing',
|
||||
type: PromoType.percentOffBill,
|
||||
),),
|
||||
throwsA(isA<PromoException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('a percentage over 100 is refused', () async {
|
||||
// Over 100% is a refund with extra steps.
|
||||
await expectLater(
|
||||
promos.save(const Promo(
|
||||
id: '',
|
||||
name: 'Too much',
|
||||
type: PromoType.percentOffBill,
|
||||
value: 150,
|
||||
),),
|
||||
throwsA(isA<PromoException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('a targeted campaign without a target is refused', () async {
|
||||
// It would save happily and then silently never fire, which is worse
|
||||
// than an error.
|
||||
await expectLater(
|
||||
promos.save(const Promo(
|
||||
id: '',
|
||||
name: 'Category promo',
|
||||
type: PromoType.percentOffCategory,
|
||||
value: 10,
|
||||
),),
|
||||
throwsA(isA<PromoException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('an end date before the start is refused', () async {
|
||||
await expectLater(
|
||||
promos.save(weekendSaver.copyWith(
|
||||
validFrom: DateTime(2026, 9),
|
||||
validTo: DateTime(2026, 8),
|
||||
),),
|
||||
throwsA(isA<PromoException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('a buy-X-get-Y with no quantities is refused', () async {
|
||||
await expectLater(
|
||||
promos.save(const Promo(
|
||||
id: '',
|
||||
name: 'Broken BOGO',
|
||||
type: PromoType.buyXGetY,
|
||||
targetId: 'p1',
|
||||
),),
|
||||
throwsA(isA<PromoException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('on a completed sale', () {
|
||||
test('the discount given is recorded and read back', () async {
|
||||
// A bill stores the amount, not a link to the campaign, so a promo that
|
||||
// is later edited or deleted cannot change what a past sale shows.
|
||||
final products = ProductRepositoryImpl(store);
|
||||
final transactions = TransactionRepositoryImpl(store);
|
||||
final checkout = CheckoutSale(
|
||||
productRepository: products,
|
||||
customerRepository: CustomerRepositoryImpl(store),
|
||||
transactionRepository: transactions,
|
||||
);
|
||||
|
||||
final milk = (await products.findByBarcode('8901234500011'))!;
|
||||
final saved = await promos.save(const Promo(
|
||||
id: '',
|
||||
name: 'Ten off everything',
|
||||
type: PromoType.percentOffBill,
|
||||
value: 10,
|
||||
),);
|
||||
|
||||
var cart = Cart(lines: [CartLine(product: milk, quantity: 10)]);
|
||||
cart = cart.copyWith(
|
||||
appliedPromos: PromoEngine.evaluate(
|
||||
cart: cart,
|
||||
promos: [saved],
|
||||
at: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
expect(cart.appliedPromos, hasLength(1));
|
||||
expect(cart.promoDiscountAmount, 62);
|
||||
final due = cart.grandTotal;
|
||||
|
||||
await checkout(
|
||||
cart: cart,
|
||||
payments: [
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: due, tendered: due),
|
||||
],
|
||||
cashierName: 'Divya',
|
||||
terminalId: 'T0TEST',
|
||||
);
|
||||
|
||||
final stored = (await transactions.history()).single;
|
||||
|
||||
expect(stored.total, due,
|
||||
reason: 'the figure charged must not move in storage',);
|
||||
expect(stored.cart.appliedPromos, hasLength(1));
|
||||
expect(stored.cart.appliedPromos.single.promo.name, 'Ten off everything');
|
||||
expect(stored.cart.appliedPromos.single.amount, 62);
|
||||
});
|
||||
|
||||
test('a promo and a manual discount are not double-counted on read-back',
|
||||
() async {
|
||||
// bill_discount on the row already contains the promo. Restoring both at
|
||||
// full value would discount the bill twice on the way back in — the exact
|
||||
// shape of the bug that used to overstate synced totals.
|
||||
final products = ProductRepositoryImpl(store);
|
||||
final transactions = TransactionRepositoryImpl(store);
|
||||
final checkout = CheckoutSale(
|
||||
productRepository: products,
|
||||
customerRepository: CustomerRepositoryImpl(store),
|
||||
transactionRepository: transactions,
|
||||
);
|
||||
|
||||
final milk = (await products.findByBarcode('8901234500011'))!;
|
||||
final saved = await promos.save(const Promo(
|
||||
id: '',
|
||||
name: 'Fifty off',
|
||||
type: PromoType.flatOffBill,
|
||||
value: 50,
|
||||
),);
|
||||
|
||||
var cart = Cart(
|
||||
lines: [CartLine(product: milk, quantity: 10)],
|
||||
billDiscount: const Discount(type: DiscountType.flat, value: 30),
|
||||
);
|
||||
cart = cart.copyWith(
|
||||
appliedPromos: PromoEngine.evaluate(
|
||||
cart: cart,
|
||||
promos: [saved],
|
||||
at: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
expect(cart.billDiscountTotal, 80, reason: '50 promo + 30 manual');
|
||||
final due = cart.grandTotal;
|
||||
|
||||
await checkout(
|
||||
cart: cart,
|
||||
payments: [
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: due, tendered: due),
|
||||
],
|
||||
cashierName: 'Divya',
|
||||
terminalId: 'T0TEST',
|
||||
);
|
||||
|
||||
final stored = (await transactions.history()).single;
|
||||
|
||||
expect(stored.cart.billDiscountTotal, 80);
|
||||
expect(stored.cart.promoDiscountAmount, 50);
|
||||
expect(stored.cart.manualBillDiscountAmount, 30);
|
||||
expect(stored.total, due);
|
||||
});
|
||||
|
||||
test('deleting a campaign does not change a bill already rung', () async {
|
||||
final products = ProductRepositoryImpl(store);
|
||||
final transactions = TransactionRepositoryImpl(store);
|
||||
final checkout = CheckoutSale(
|
||||
productRepository: products,
|
||||
customerRepository: CustomerRepositoryImpl(store),
|
||||
transactionRepository: transactions,
|
||||
);
|
||||
|
||||
final milk = (await products.findByBarcode('8901234500011'))!;
|
||||
final saved = await promos.save(const Promo(
|
||||
id: '',
|
||||
name: 'Doomed campaign',
|
||||
type: PromoType.flatOffBill,
|
||||
value: 100,
|
||||
),);
|
||||
|
||||
var cart = Cart(lines: [CartLine(product: milk, quantity: 10)]);
|
||||
cart = cart.copyWith(
|
||||
appliedPromos: PromoEngine.evaluate(
|
||||
cart: cart,
|
||||
promos: [saved],
|
||||
at: DateTime.now(),
|
||||
),
|
||||
);
|
||||
final due = cart.grandTotal;
|
||||
|
||||
await checkout(
|
||||
cart: cart,
|
||||
payments: [
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: due, tendered: due),
|
||||
],
|
||||
cashierName: 'Divya',
|
||||
terminalId: 'T0TEST',
|
||||
);
|
||||
|
||||
await promos.delete(saved.id);
|
||||
|
||||
final stored = (await transactions.history()).single;
|
||||
expect(stored.total, due);
|
||||
expect(stored.cart.appliedPromos.single.promo.name, 'Doomed campaign');
|
||||
expect(stored.cart.appliedPromos.single.amount, 100);
|
||||
});
|
||||
});
|
||||
}
|
||||
357
test/unit/retention_test.dart
Normal file
357
test/unit/retention_test.dart
Normal file
@@ -0,0 +1,357 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nearle_pos/data/datasources/local_store.dart';
|
||||
import 'package:nearle_pos/data/remote/simulated_catalogue_source.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/order_dao.dart';
|
||||
import 'package:nearle_pos/data/remote/order_transport.dart';
|
||||
import 'package:nearle_pos/data/remote/simulated_order_transport.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/transaction.dart';
|
||||
import 'package:nearle_pos/domain/usecases/checkout_sale.dart';
|
||||
|
||||
/// A transport whose answer each call is dictated by the test.
|
||||
class _ScriptedTransport implements OrderTransport {
|
||||
_ScriptedTransport(this.answer);
|
||||
|
||||
/// Given the ids in a batch, returns what the back office says about them.
|
||||
PushReceipt Function(List<String> ids) answer;
|
||||
|
||||
int batches = 0;
|
||||
|
||||
@override
|
||||
String get label => 'Scripted';
|
||||
|
||||
@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 {
|
||||
batches++;
|
||||
return answer(orders.map((o) => o['id']! as String).toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
/// Bills the back office has taken delivery of stay on the terminal for a
|
||||
/// week, so a batch the server later loses can still be re-sent in full.
|
||||
///
|
||||
/// The risk this buys is double counting: an accepted bill is now in two
|
||||
/// places at once — its own row, and the archived day totals. Most of what
|
||||
/// follows is about that.
|
||||
void main() {
|
||||
late LocalStore store;
|
||||
late ProductRepositoryImpl products;
|
||||
late CustomerRepositoryImpl customers;
|
||||
late TransactionRepositoryImpl transactions;
|
||||
late CheckoutSale checkout;
|
||||
|
||||
setUpAll(() {
|
||||
LocalStore.registerSeed(
|
||||
products: SeedData.products,
|
||||
customers: SeedData.customers,
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
store = LocalStore.instance;
|
||||
await store.reset(withCatalogue: true);
|
||||
|
||||
products = ProductRepositoryImpl(store);
|
||||
customers = CustomerRepositoryImpl(store);
|
||||
transactions = TransactionRepositoryImpl(store);
|
||||
checkout = CheckoutSale(
|
||||
productRepository: products,
|
||||
customerRepository: customers,
|
||||
transactionRepository: transactions,
|
||||
);
|
||||
});
|
||||
|
||||
SyncRepositoryImpl syncWith(OrderTransport transport) => SyncRepositoryImpl(
|
||||
store,
|
||||
SimulatedCatalogueSource(isOffline: () => false),
|
||||
transport,
|
||||
);
|
||||
|
||||
/// Rings one bill for [quantity] litres of milk at 62.00 each.
|
||||
Future<double> ringSale({
|
||||
double quantity = 2,
|
||||
String cashier = 'Divya',
|
||||
}) async {
|
||||
final milk = (await products.findByBarcode('8901234500011'))!;
|
||||
final cart = Cart(lines: [CartLine(product: milk, quantity: quantity)]);
|
||||
final due = cart.grandTotal;
|
||||
|
||||
await checkout(
|
||||
cart: cart,
|
||||
payments: [
|
||||
PaymentSplit(
|
||||
method: PaymentMethod.cash,
|
||||
amount: due,
|
||||
tendered: due,
|
||||
),
|
||||
],
|
||||
cashierName: cashier,
|
||||
terminalId: 'T0TEST',
|
||||
);
|
||||
return due;
|
||||
}
|
||||
|
||||
group('accepted bills are kept, not deleted', () {
|
||||
test('a synced bill is still on the terminal and still re-sendable',
|
||||
() async {
|
||||
await ringSale();
|
||||
final sync = syncWith(SimulatedOrderTransport(isOffline: () => false));
|
||||
|
||||
expect(await sync.unsyncedCount(), 1);
|
||||
await sync.syncOrders();
|
||||
expect(await sync.unsyncedCount(), 0);
|
||||
|
||||
// The row survives, carrying its line items, so the full bill can go up
|
||||
// again if the back office loses it.
|
||||
final rows = await sync.orderSyncRows();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.single.isSynced, isTrue);
|
||||
expect(rows.single.syncedAt, isNotNull);
|
||||
|
||||
final stored = await store.orders.recent();
|
||||
expect(stored, hasLength(1));
|
||||
expect(stored.single.cart.lines, isNotEmpty,
|
||||
reason: 'a kept bill with no lines could not be re-sent',);
|
||||
});
|
||||
|
||||
test("today's takings are not counted twice while the bill is retained",
|
||||
() async {
|
||||
// The bug this exists to catch: an accepted bill is in the archive *and*
|
||||
// still in the orders table. Summing both would inflate the day.
|
||||
final due = await ringSale(quantity: 3);
|
||||
final sync = syncWith(SimulatedOrderTransport(isOffline: () => false));
|
||||
|
||||
final before = await sync.todayReport(
|
||||
terminalId: 'TERM-01',
|
||||
cashierName: 'Divya',
|
||||
);
|
||||
expect(before.grossSales, closeTo(due, 0.01));
|
||||
|
||||
await sync.syncOrders();
|
||||
|
||||
final after = await sync.todayReport(
|
||||
terminalId: 'TERM-01',
|
||||
cashierName: 'Divya',
|
||||
);
|
||||
expect(after.grossSales, closeTo(due, 0.01),
|
||||
reason: 'syncing must not change what the shop took',);
|
||||
expect(after.billCount, 1);
|
||||
});
|
||||
|
||||
test('a second sync does not send an already-accepted bill again',
|
||||
() async {
|
||||
await ringSale();
|
||||
final transport = _ScriptedTransport((ids) => PushReceipt(accepted: ids));
|
||||
final sync = syncWith(transport);
|
||||
|
||||
await sync.syncOrders();
|
||||
expect(transport.batches, 1);
|
||||
|
||||
final outcome = await sync.syncOrders();
|
||||
expect(outcome.hadNothingToDo, isTrue);
|
||||
expect(transport.batches, 1,
|
||||
reason: 'a retained bill must not be re-uploaded',);
|
||||
});
|
||||
});
|
||||
|
||||
group('purging', () {
|
||||
test('a bill past its window goes, and its archived totals stay', () async {
|
||||
final due = await ringSale(quantity: 4);
|
||||
final sync = syncWith(SimulatedOrderTransport(isOffline: () => false));
|
||||
await sync.syncOrders();
|
||||
|
||||
// Backdate the acceptance past the retention window.
|
||||
await AppDatabase.instance.db.rawUpdate(
|
||||
'UPDATE orders SET synced_at = ?',
|
||||
[
|
||||
DateTime.now()
|
||||
.subtract(OrderDao.retentionWindow + const Duration(days: 1))
|
||||
.millisecondsSinceEpoch,
|
||||
],
|
||||
);
|
||||
|
||||
expect(await sync.purgeExpired(), 1);
|
||||
expect(await store.orders.recent(), isEmpty);
|
||||
|
||||
// What the shop was paid is unchanged — only the re-sendable copy went.
|
||||
final report = await sync.todayReport(
|
||||
terminalId: 'TERM-01',
|
||||
cashierName: 'Divya',
|
||||
);
|
||||
expect(report.grossSales, closeTo(due, 0.01));
|
||||
expect(report.billCount, 1);
|
||||
});
|
||||
|
||||
test('a bill inside its window is left alone', () async {
|
||||
await ringSale();
|
||||
final sync = syncWith(SimulatedOrderTransport(isOffline: () => false));
|
||||
await sync.syncOrders();
|
||||
|
||||
expect(await sync.purgeExpired(), 0);
|
||||
expect(await store.orders.recent(), hasLength(1));
|
||||
});
|
||||
|
||||
test('an unsynced bill is never purged, however old', () async {
|
||||
// The one thing that must never happen: a bill the back office has not
|
||||
// taken delivery of being deleted from the only place it exists.
|
||||
await ringSale();
|
||||
await AppDatabase.instance.db.rawUpdate(
|
||||
'UPDATE orders SET created_at = ?, synced_at = ?',
|
||||
[0, 0],
|
||||
);
|
||||
|
||||
final sync = syncWith(SimulatedOrderTransport(isOffline: () => false));
|
||||
expect(await sync.purgeExpired(), 0);
|
||||
expect(await sync.unsyncedCount(), 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('partial acceptance', () {
|
||||
test('a bill the back office stayed silent about stays pending', () async {
|
||||
// Silence is not acceptance.
|
||||
await ringSale(quantity: 1);
|
||||
await ringSale(quantity: 2);
|
||||
|
||||
final transport = _ScriptedTransport(
|
||||
(ids) => PushReceipt(accepted: [ids.first]),
|
||||
);
|
||||
final sync = syncWith(transport);
|
||||
|
||||
final outcome = await sync.syncOrders();
|
||||
expect(outcome.uploaded, 1);
|
||||
expect(outcome.rejected, 1);
|
||||
expect(await sync.unsyncedCount(), 1,
|
||||
reason: 'the unconfirmed bill must still be owed',);
|
||||
});
|
||||
|
||||
test('a refusal stops the drain instead of looping on the same rows',
|
||||
() async {
|
||||
await ringSale();
|
||||
final transport = _ScriptedTransport(
|
||||
(ids) => PushReceipt(
|
||||
accepted: const [],
|
||||
rejected: {for (final id in ids) id: 'duplicate invoice'},
|
||||
),
|
||||
);
|
||||
final sync = syncWith(transport);
|
||||
|
||||
final outcome = await sync.syncOrders();
|
||||
|
||||
expect(outcome.uploaded, 0);
|
||||
expect(outcome.isSuccess, isFalse);
|
||||
expect(outcome.isRetryable, isFalse,
|
||||
reason: 'the same bytes will be refused again',);
|
||||
expect(transport.batches, 1,
|
||||
reason: 'the refused page must not be fetched and sent forever',);
|
||||
expect(outcome.error, contains('duplicate invoice'));
|
||||
});
|
||||
|
||||
test('the refusal reason is recorded against the bill for a person to read',
|
||||
() async {
|
||||
await ringSale();
|
||||
final sync = syncWith(_ScriptedTransport(
|
||||
(ids) => PushReceipt(
|
||||
accepted: const [],
|
||||
rejected: {for (final id in ids) id: 'unknown product code'},
|
||||
),
|
||||
),);
|
||||
|
||||
await sync.syncOrders();
|
||||
|
||||
final row = (await sync.orderSyncRows()).single;
|
||||
expect(row.isSynced, isFalse);
|
||||
expect(row.error, 'unknown product code');
|
||||
expect(row.attempts, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('at-least-once delivery', () {
|
||||
test('a batch accepted twice is banked once', () async {
|
||||
// MQTT will re-deliver, and a lost ack means the terminal sends again.
|
||||
// The second acceptance must not double the archived takings.
|
||||
final due = await ringSale(quantity: 5);
|
||||
final transport = _ScriptedTransport((ids) => PushReceipt(accepted: ids));
|
||||
final sync = syncWith(transport);
|
||||
|
||||
await sync.syncOrders();
|
||||
// A duplicate ack for bills already marked synced — the drain finds
|
||||
// nothing pending and does nothing.
|
||||
await sync.syncOrders();
|
||||
|
||||
final report = await sync.todayReport(
|
||||
terminalId: 'TERM-01',
|
||||
cashierName: 'Divya',
|
||||
);
|
||||
expect(report.grossSales, closeTo(due, 0.01));
|
||||
expect(report.billCount, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('transport failure', () {
|
||||
test('an unreachable back office leaves every bill exactly where it was',
|
||||
() async {
|
||||
final due = await ringSale(quantity: 6);
|
||||
final sync = syncWith(SimulatedOrderTransport(isOffline: () => true));
|
||||
|
||||
final outcome = await sync.syncOrders();
|
||||
|
||||
expect(outcome.isSuccess, isFalse);
|
||||
expect(outcome.uploaded, 0);
|
||||
expect(await sync.unsyncedCount(), 1);
|
||||
|
||||
final report = await sync.todayReport(
|
||||
terminalId: 'TERM-01',
|
||||
cashierName: 'Divya',
|
||||
);
|
||||
expect(report.grossSales, closeTo(due, 0.01),
|
||||
reason: 'a failed upload must not change the shift total',);
|
||||
});
|
||||
|
||||
test('a batch is bounded so a backlog cannot exceed a broker message',
|
||||
() async {
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await ringSale(quantity: 1);
|
||||
}
|
||||
|
||||
final sizes = <int>[];
|
||||
final transport = _ScriptedTransport((ids) {
|
||||
sizes.add(ids.length);
|
||||
return PushReceipt(accepted: ids);
|
||||
});
|
||||
|
||||
final sync = SyncRepositoryImpl(
|
||||
store,
|
||||
SimulatedCatalogueSource(isOffline: () => false),
|
||||
transport,
|
||||
batchSize: 2,
|
||||
);
|
||||
|
||||
final outcome = await sync.syncOrders();
|
||||
|
||||
expect(outcome.uploaded, 5);
|
||||
expect(sizes, [2, 2, 1], reason: 'the backlog must drain in pages');
|
||||
expect(await sync.unsyncedCount(), 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
242
test/unit/staff_auth_test.dart
Normal file
242
test/unit/staff_auth_test.dart
Normal file
@@ -0,0 +1,242 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nearle_pos/core/security/pin_hasher.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/staff_dao.dart';
|
||||
import 'package:nearle_pos/data/repositories/store_repository_impl.dart';
|
||||
import 'package:nearle_pos/domain/entities/store_account.dart';
|
||||
|
||||
/// Staff credentials used to be three `StaffUser` constants with plaintext
|
||||
/// PINs, which meant every shipped build carried every till's credentials —
|
||||
/// readable by anyone who unzipped the APK.
|
||||
void main() {
|
||||
late LocalStore store;
|
||||
late StaffDao staff;
|
||||
|
||||
setUpAll(() {
|
||||
LocalStore.registerSeed(
|
||||
products: SeedData.products,
|
||||
customers: SeedData.customers,
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
store = LocalStore.instance;
|
||||
await store.reset(withCatalogue: true);
|
||||
staff = store.staff;
|
||||
});
|
||||
|
||||
group('hashing', () {
|
||||
test('the same PIN under two salts produces two different hashes', () {
|
||||
// Otherwise a stolen database would show at a glance which staff share a
|
||||
// PIN, and one cracked hash would open several accounts.
|
||||
final saltA = PinHasher.newSalt();
|
||||
final saltB = PinHasher.newSalt();
|
||||
|
||||
expect(saltA, isNot(saltB));
|
||||
expect(PinHasher.hash('4821', saltA), isNot(PinHasher.hash('4821', saltB)));
|
||||
});
|
||||
|
||||
test('a correct PIN verifies and a wrong one does not', () {
|
||||
final salt = PinHasher.newSalt();
|
||||
final hash = PinHasher.hash('4821', salt);
|
||||
|
||||
expect(PinHasher.verify('4821', salt: salt, hash: hash), isTrue);
|
||||
expect(PinHasher.verify('4822', salt: salt, hash: hash), isFalse);
|
||||
expect(PinHasher.verify('', salt: salt, hash: hash), isFalse);
|
||||
});
|
||||
|
||||
test('the hash is not the PIN in any recoverable form', () {
|
||||
final salt = PinHasher.newSalt();
|
||||
final hash = PinHasher.hash('4821', salt);
|
||||
expect(hash, isNot(contains('4821')));
|
||||
expect(hash.length, greaterThan(20));
|
||||
});
|
||||
});
|
||||
|
||||
group('storage', () {
|
||||
test('no PIN is ever written to the database', () async {
|
||||
// The whole point. Reading every column of every row must not turn up a
|
||||
// usable credential.
|
||||
final rows = await AppDatabase.instance.db.query(Tables.staff);
|
||||
expect(rows, isNotEmpty);
|
||||
|
||||
for (final row in rows) {
|
||||
for (final entry in row.entries) {
|
||||
for (final account in StaffDao.seedAccounts) {
|
||||
expect(
|
||||
'${entry.value}',
|
||||
isNot(account.pin),
|
||||
reason: '${entry.key} holds a plaintext PIN',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the entity carries no PIN field at all', () {
|
||||
// Belt and braces: if StaffUser held one, it would be in memory, in every
|
||||
// widget holding a user, and back in the shipped binary the moment
|
||||
// someone declared an account as a constant again.
|
||||
const user = StaffUser(id: 'x', name: 'Y', role: StaffRole.cashier);
|
||||
expect(user.props.contains('4821'), isFalse);
|
||||
expect(user.toString(), isNot(contains('pin')));
|
||||
});
|
||||
|
||||
test('seeded accounts are all flagged to change their PIN', () async {
|
||||
final all = await staff.all();
|
||||
expect(all, hasLength(StaffDao.seedAccounts.length));
|
||||
expect(all.every((s) => s.mustChangePin), isTrue,
|
||||
reason: 'a default PIN must not quietly become the permanent one',);
|
||||
});
|
||||
|
||||
test('seeding twice does not duplicate or reset anything', () async {
|
||||
final before = await staff.all();
|
||||
await staff.setPin(before.first.id, '8317');
|
||||
|
||||
await staff.seedIfEmpty();
|
||||
|
||||
final after = await staff.all();
|
||||
expect(after, hasLength(before.length));
|
||||
expect(await staff.authenticate('8317'), isNotNull,
|
||||
reason: 'an upgrade must not reset a PIN someone chose',);
|
||||
});
|
||||
});
|
||||
|
||||
group('authentication', () {
|
||||
test('a seeded PIN signs the right person in', () async {
|
||||
final user = await staff.authenticate('5093');
|
||||
expect(user, isNotNull);
|
||||
expect(user!.name, 'Divya');
|
||||
expect(user.role, StaffRole.manager);
|
||||
});
|
||||
|
||||
test('a wrong PIN returns nobody', () async {
|
||||
expect(await staff.authenticate('9999'), isNull);
|
||||
expect(await staff.authenticate(''), isNull);
|
||||
});
|
||||
|
||||
test('a deactivated account cannot sign in', () async {
|
||||
final rahul = (await staff.all()).firstWhere((s) => s.name == 'Rahul');
|
||||
await staff.deactivate(rahul.id);
|
||||
|
||||
expect(await staff.authenticate('6274'), isNull);
|
||||
expect(await staff.all(), isNot(contains(rahul)));
|
||||
});
|
||||
});
|
||||
|
||||
group('rules', () {
|
||||
test('a PIN a queue could read off your hand is refused', () async {
|
||||
for (final weak in ['0000', '1111', '1234', '4321']) {
|
||||
await expectLater(
|
||||
staff.create(name: 'X', role: StaffRole.cashier, pin: weak),
|
||||
throwsA(isA<StaffException>()),
|
||||
reason: '$weak was accepted',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a PIN shorter than four digits, or not digits, is refused', () async {
|
||||
await expectLater(
|
||||
staff.create(name: 'X', role: StaffRole.cashier, pin: '821'),
|
||||
throwsA(isA<StaffException>()),
|
||||
);
|
||||
await expectLater(
|
||||
staff.create(name: 'X', role: StaffRole.cashier, pin: 'abcd'),
|
||||
throwsA(isA<StaffException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('two people cannot share a PIN', () async {
|
||||
// The till identifies a cashier by PIN alone, so a shared one would
|
||||
// attribute bills to whichever row happened to be checked first.
|
||||
await expectLater(
|
||||
staff.create(name: 'Impostor', role: StaffRole.cashier, pin: '5093'),
|
||||
throwsA(isA<StaffException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('the last admin cannot be demoted or deactivated', () async {
|
||||
// A till with no admin cannot be administered — including to make someone
|
||||
// an admin again. Recovering means editing the database by hand.
|
||||
final all = await staff.all();
|
||||
final admin = all.firstWhere((s) => s.role == StaffRole.admin);
|
||||
|
||||
await expectLater(
|
||||
staff.deactivate(admin.id),
|
||||
throwsA(isA<StaffException>()),
|
||||
);
|
||||
await expectLater(
|
||||
staff.updateDetails(id: admin.id, role: StaffRole.cashier),
|
||||
throwsA(isA<StaffException>()),
|
||||
);
|
||||
|
||||
// With a second admin in place, both become legal.
|
||||
final divya = all.firstWhere((s) => s.name == 'Divya');
|
||||
await staff.updateDetails(id: divya.id, role: StaffRole.admin);
|
||||
await staff.deactivate(admin.id);
|
||||
|
||||
expect((await staff.all()).any((s) => s.role == StaffRole.admin), isTrue);
|
||||
});
|
||||
|
||||
test('choosing your own PIN clears the change flag', () async {
|
||||
final user = (await staff.all()).first;
|
||||
expect(user.mustChangePin, isTrue);
|
||||
|
||||
await staff.setPin(user.id, '8317');
|
||||
|
||||
final after = await staff.findById(user.id);
|
||||
expect(after!.mustChangePin, isFalse);
|
||||
expect(await staff.authenticate('8317'), isNotNull);
|
||||
expect(await staff.authenticate('4821'), isNull,
|
||||
reason: 'the old PIN must stop working',);
|
||||
});
|
||||
|
||||
test('an admin reset re-arms the change flag', () async {
|
||||
final user = (await staff.all()).last;
|
||||
await staff.setPin(user.id, '7168', mustChangePin: true);
|
||||
|
||||
expect((await staff.findById(user.id))!.mustChangePin, isTrue);
|
||||
});
|
||||
|
||||
test('deactivating keeps the row, so old bills still name a real person',
|
||||
() async {
|
||||
final rahul = (await staff.all()).firstWhere((s) => s.name == 'Rahul');
|
||||
await staff.deactivate(rahul.id);
|
||||
|
||||
expect(await staff.findById(rahul.id), isNotNull);
|
||||
expect((await staff.all(includeInactive: true)).length, 3);
|
||||
});
|
||||
});
|
||||
|
||||
group('store details', () {
|
||||
test('edits persist and are read back, not overwritten by the constants',
|
||||
() async {
|
||||
final repo = StoreRepositoryImpl(store);
|
||||
|
||||
await repo.save(
|
||||
name: 'Nearle Daily — Anna Nagar',
|
||||
address: '12 2nd Ave, Chennai 600040',
|
||||
gstin: '33AABCU9603R1ZM',
|
||||
phone: '9840012345',
|
||||
);
|
||||
|
||||
final loaded = await repo.load(email: 'a@b.c');
|
||||
expect(loaded.name, 'Nearle Daily — Anna Nagar');
|
||||
expect(loaded.gstin, '33AABCU9603R1ZM');
|
||||
expect(loaded.phone, '9840012345');
|
||||
});
|
||||
|
||||
test('a malformed GSTIN is caught before it reaches an invoice', () {
|
||||
// These print on every bill as a legal requirement, so a typo is a
|
||||
// compliance problem across a few hundred invoices before anyone notices.
|
||||
expect(GstinValidator.validate('33AABCU9603R1ZM'), isNull);
|
||||
expect(GstinValidator.validate(''), isNotNull);
|
||||
expect(GstinValidator.validate('33AABCU9603R1Z'), isNotNull);
|
||||
expect(GstinValidator.validate('99AABCU9603R1ZM'), isNotNull);
|
||||
expect(GstinValidator.validate('33aabcu9603r1zm'), isNull,
|
||||
reason: 'lowercase is normalised, not rejected',);
|
||||
});
|
||||
});
|
||||
}
|
||||
436
test/unit/sync_engine_test.dart
Normal file
436
test/unit/sync_engine_test.dart
Normal file
@@ -0,0 +1,436 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nearle_pos/data/remote/order_transport.dart';
|
||||
import 'package:nearle_pos/data/sync/sync_engine.dart';
|
||||
import 'package:nearle_pos/domain/entities/shift_report.dart';
|
||||
import 'package:nearle_pos/domain/entities/sync_event.dart';
|
||||
import 'package:nearle_pos/domain/entities/transaction.dart';
|
||||
import 'package:nearle_pos/domain/repositories/sync_repository.dart';
|
||||
|
||||
/// A repository whose every answer the test dictates.
|
||||
///
|
||||
/// The engine is a scheduler; what it schedules is irrelevant here. Driving it
|
||||
/// with a stub is what makes "did it retry, and when" answerable without a
|
||||
/// database or a network.
|
||||
class _StubRepository implements SyncRepository {
|
||||
_StubRepository();
|
||||
|
||||
/// Answers handed out in order; the last one repeats once exhausted.
|
||||
final List<SyncOutcome> scripted = [];
|
||||
int calls = 0;
|
||||
int pending = 0;
|
||||
|
||||
/// Completed by the test to hold a drain open, so overlapping triggers can
|
||||
/// be observed.
|
||||
Completer<void>? gate;
|
||||
|
||||
@override
|
||||
Future<SyncOutcome> syncOrders({
|
||||
void Function(double progress, String stage)? onProgress,
|
||||
}) async {
|
||||
calls++;
|
||||
if (gate != null) await gate!.future;
|
||||
if (scripted.isEmpty) return const SyncOutcome(attempted: 0, uploaded: 0);
|
||||
return scripted[(calls - 1).clamp(0, scripted.length - 1)];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> unsyncedCount() async => pending;
|
||||
|
||||
@override
|
||||
Future<int> purgeExpired() async => 0;
|
||||
|
||||
@override
|
||||
bool get hasCatalogue => true;
|
||||
|
||||
@override
|
||||
DateTime? get lastImportAt => null;
|
||||
|
||||
@override
|
||||
String? get catalogueRevision => null;
|
||||
|
||||
@override
|
||||
List<SyncEvent> get events => const [];
|
||||
|
||||
@override
|
||||
Future<SyncEvent> importCatalogue({
|
||||
void Function(double progress, String stage)? onProgress,
|
||||
}) =>
|
||||
throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<List<SaleTransaction>> unsyncedOrders() async => const [];
|
||||
|
||||
@override
|
||||
Future<List<OrderSyncRow>> orderSyncRows({int limit = 200}) async => const [];
|
||||
|
||||
@override
|
||||
Future<ShiftReport> todayReport({
|
||||
required String terminalId,
|
||||
required String cashierName,
|
||||
bool scopeToCashier = false,
|
||||
}) =>
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Captures what the engine asked to be scheduled instead of really waiting.
|
||||
class _FakeScheduler {
|
||||
final List<Duration> delays = [];
|
||||
final List<void Function()> callbacks = [];
|
||||
|
||||
Timer schedule(Duration d, void Function() cb) {
|
||||
delays.add(d);
|
||||
callbacks.add(cb);
|
||||
return Timer(Duration.zero, () {})..cancel();
|
||||
}
|
||||
|
||||
/// Runs the most recently scheduled callback — the backoff retry.
|
||||
void fireLast() => callbacks.last();
|
||||
}
|
||||
|
||||
void main() {
|
||||
late _StubRepository repo;
|
||||
late _FakeScheduler scheduler;
|
||||
|
||||
SyncEngine build({
|
||||
Stream<bool>? connectivity,
|
||||
Stream<DownlinkMessage>? downlink,
|
||||
Random? random,
|
||||
}) =>
|
||||
SyncEngine(
|
||||
repository: repo,
|
||||
connectivity: connectivity,
|
||||
downlink: downlink,
|
||||
// Fixed seed so the jitter band is assertable rather than flaky.
|
||||
random: random ?? Random(7),
|
||||
scheduleTimer: scheduler.schedule,
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
repo = _StubRepository();
|
||||
scheduler = _FakeScheduler();
|
||||
});
|
||||
|
||||
group('single flight', () {
|
||||
test('overlapping triggers do not start a second pass over the same bills',
|
||||
() async {
|
||||
// Without this guarantee a busy till firing a trigger per sale would have
|
||||
// several drains reading the same pending rows at once, and every bill
|
||||
// would go up two or three times.
|
||||
repo.gate = Completer<void>();
|
||||
final engine = build();
|
||||
|
||||
engine
|
||||
..nudge(SyncTrigger.saleCommitted)
|
||||
..nudge(SyncTrigger.saleCommitted)
|
||||
..nudge(SyncTrigger.saleCommitted);
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(repo.calls, 1, reason: 'three triggers must yield one drain');
|
||||
|
||||
repo.gate!.complete();
|
||||
repo.gate = null;
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
test('a trigger arriving mid-drain is honoured once that drain finishes',
|
||||
() async {
|
||||
// Bills committed during a pass were not in the set it read. Dropping
|
||||
// the trigger would leave them waiting for the next poll.
|
||||
repo
|
||||
..gate = Completer<void>()
|
||||
..pending = 3;
|
||||
final engine = build();
|
||||
|
||||
engine.nudge(SyncTrigger.startup);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(repo.calls, 1);
|
||||
|
||||
engine.nudge(SyncTrigger.saleCommitted);
|
||||
repo.gate!.complete();
|
||||
repo.gate = null;
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(repo.calls, 2, reason: 'the queued trigger must be replayed');
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
test('a queued trigger with nothing left owing does not run a second pass',
|
||||
() async {
|
||||
// The counterpart to the test above. The drain that just finished emptied
|
||||
// the queue, so replaying the trigger would send nothing and only churn
|
||||
// the connection.
|
||||
repo
|
||||
..gate = Completer<void>()
|
||||
..pending = 0;
|
||||
final engine = build();
|
||||
|
||||
engine.nudge(SyncTrigger.startup);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
engine.nudge(SyncTrigger.saleCommitted);
|
||||
repo.gate!.complete();
|
||||
repo.gate = null;
|
||||
await _settle();
|
||||
|
||||
expect(repo.calls, 1);
|
||||
await engine.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('backoff', () {
|
||||
test('doubles per failure and stops at the ceiling', () {
|
||||
final engine = SyncEngine(
|
||||
repository: repo,
|
||||
baseBackoff: const Duration(seconds: 2),
|
||||
maxBackoff: const Duration(minutes: 5),
|
||||
// No jitter, so the shape of the curve is what is being asserted.
|
||||
random: _ZeroJitter(),
|
||||
scheduleTimer: scheduler.schedule,
|
||||
);
|
||||
|
||||
// 0.8 is the bottom of the jitter band, which _ZeroJitter pins.
|
||||
expect(engine.backoffFor(1).inMilliseconds, 1600); // 2s
|
||||
expect(engine.backoffFor(2).inMilliseconds, 3200); // 4s
|
||||
expect(engine.backoffFor(3).inMilliseconds, 6400); // 8s
|
||||
expect(engine.backoffFor(9).inSeconds, 240); // 512s → capped
|
||||
expect(engine.backoffFor(30).inSeconds, 240); // still capped
|
||||
});
|
||||
|
||||
test('jitter keeps every delay inside ±20% of the nominal wait', () {
|
||||
// A shop's terminals all fail at the same instant when the line drops.
|
||||
// Without jitter they would retry in lockstep and keep colliding.
|
||||
final engine = build(random: Random(1));
|
||||
|
||||
final seen = <int>{};
|
||||
for (var i = 0; i < 200; i++) {
|
||||
final ms = engine.backoffFor(4).inMilliseconds;
|
||||
expect(ms, greaterThanOrEqualTo((16000 * 0.8).round()));
|
||||
expect(ms, lessThanOrEqualTo((16000 * 1.2).round()));
|
||||
seen.add(ms);
|
||||
}
|
||||
expect(seen.length, greaterThan(50), reason: 'delays must actually vary');
|
||||
});
|
||||
|
||||
test('a failed drain schedules a retry and a success clears it', () async {
|
||||
repo.scripted.addAll([
|
||||
const SyncOutcome(attempted: 2, uploaded: 0, error: 'line dropped'),
|
||||
const SyncOutcome(attempted: 2, uploaded: 2),
|
||||
]);
|
||||
|
||||
final engine = build();
|
||||
engine.nudge(SyncTrigger.saleCommitted);
|
||||
await _settle();
|
||||
|
||||
expect(engine.state.consecutiveFailures, 1);
|
||||
expect(engine.state.nextAttemptAt, isNotNull);
|
||||
expect(scheduler.delays, hasLength(1));
|
||||
|
||||
scheduler.fireLast();
|
||||
await _settle();
|
||||
|
||||
expect(engine.state.consecutiveFailures, 0);
|
||||
expect(engine.state.lastError, isNull);
|
||||
expect(engine.state.nextAttemptAt, isNull);
|
||||
expect(engine.state.lastSuccessAt, isNotNull);
|
||||
|
||||
await engine.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('halting', () {
|
||||
test('a refused batch halts instead of retrying the same bytes forever',
|
||||
() async {
|
||||
// A rejection is a decision, not a fault. Re-sending gets the same
|
||||
// answer, and a loop would bury the one message a person needs to see.
|
||||
repo.scripted.add(const SyncOutcome(
|
||||
attempted: 1,
|
||||
uploaded: 0,
|
||||
rejected: 1,
|
||||
error: 'duplicate invoice number',
|
||||
isRetryable: false,
|
||||
),);
|
||||
|
||||
final engine = build();
|
||||
engine.nudge(SyncTrigger.saleCommitted);
|
||||
await _settle();
|
||||
|
||||
expect(engine.state.isHalted, isTrue);
|
||||
expect(scheduler.delays, isEmpty, reason: 'no retry may be scheduled');
|
||||
|
||||
// Further background triggers are ignored while halted.
|
||||
engine
|
||||
..nudge(SyncTrigger.periodic)
|
||||
..nudge(SyncTrigger.saleCommitted);
|
||||
await _settle();
|
||||
expect(repo.calls, 1);
|
||||
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
test('pressing sync clears a halt and tries again', () async {
|
||||
// The button is how a cashier retries once the back office is fixed.
|
||||
repo.scripted.add(const SyncOutcome(
|
||||
attempted: 1,
|
||||
uploaded: 0,
|
||||
error: 'bad credential',
|
||||
isRetryable: false,
|
||||
),);
|
||||
|
||||
final engine = build();
|
||||
engine.nudge(SyncTrigger.saleCommitted);
|
||||
await _settle();
|
||||
expect(engine.state.isHalted, isTrue);
|
||||
|
||||
repo.scripted
|
||||
..clear()
|
||||
..add(const SyncOutcome(attempted: 1, uploaded: 1));
|
||||
repo.calls = 0;
|
||||
|
||||
await engine.syncNow();
|
||||
expect(repo.calls, 1);
|
||||
expect(engine.state.isHalted, isFalse);
|
||||
|
||||
await engine.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('triggers', () {
|
||||
test('the network coming back starts a drain; going away does not',
|
||||
() async {
|
||||
final connectivity = StreamController<bool>();
|
||||
final engine = build(connectivity: connectivity.stream);
|
||||
await engine.start();
|
||||
|
||||
final atStart = repo.calls;
|
||||
|
||||
connectivity.add(false);
|
||||
await _settle();
|
||||
expect(repo.calls, atStart,
|
||||
reason: 'an attempt certain to fail is not worth making',);
|
||||
expect(engine.state.online, isFalse);
|
||||
|
||||
connectivity.add(true);
|
||||
await _settle();
|
||||
expect(repo.calls, atStart + 1);
|
||||
|
||||
await connectivity.close();
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
test('background triggers are ignored while offline, manual is not',
|
||||
() async {
|
||||
final connectivity = StreamController<bool>();
|
||||
final engine = build(connectivity: connectivity.stream);
|
||||
await engine.start();
|
||||
|
||||
connectivity.add(false);
|
||||
await _settle();
|
||||
final atStart = repo.calls;
|
||||
|
||||
engine.nudge(SyncTrigger.saleCommitted);
|
||||
await _settle();
|
||||
expect(repo.calls, atStart);
|
||||
|
||||
// The cashier may know something the engine does not, and being told why
|
||||
// it failed beats a button that does nothing.
|
||||
await engine.syncNow();
|
||||
expect(repo.calls, atStart + 1);
|
||||
|
||||
await connectivity.close();
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
test('head office can pull a shift up over the downlink', () async {
|
||||
final downlink = StreamController<DownlinkMessage>();
|
||||
// Bills owed, otherwise a request to sync correctly does nothing.
|
||||
repo.pending = 2;
|
||||
final engine = build(downlink: downlink.stream);
|
||||
await engine.start();
|
||||
// Let the startup drain finish, so this measures the downlink and not a
|
||||
// race with it.
|
||||
await _settle();
|
||||
|
||||
final atStart = repo.calls;
|
||||
downlink.add(const DownlinkMessage(kind: DownlinkKind.syncRequested));
|
||||
await _settle();
|
||||
|
||||
expect(repo.calls, atStart + 1);
|
||||
|
||||
await downlink.close();
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
test('an unrecognised downlink message is ignored, not acted on', () async {
|
||||
final downlink = StreamController<DownlinkMessage>();
|
||||
repo.pending = 2;
|
||||
final engine = build(downlink: downlink.stream);
|
||||
await engine.start();
|
||||
await _settle();
|
||||
|
||||
final atStart = repo.calls;
|
||||
downlink.add(const DownlinkMessage(kind: DownlinkKind.unknown));
|
||||
await _settle();
|
||||
|
||||
expect(repo.calls, atStart);
|
||||
|
||||
await downlink.close();
|
||||
await engine.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('a repository that throws is treated as a retryable failure, not a crash',
|
||||
() async {
|
||||
// Anything escaping the repository is a defect. The engine must still empty
|
||||
// its queue once the defect is fixed, rather than dying on the first sale.
|
||||
final engine = SyncEngine(
|
||||
repository: _ThrowingRepository(),
|
||||
random: Random(3),
|
||||
scheduleTimer: scheduler.schedule,
|
||||
);
|
||||
|
||||
engine.nudge(SyncTrigger.saleCommitted);
|
||||
await _settle();
|
||||
|
||||
expect(engine.state.consecutiveFailures, 1);
|
||||
expect(engine.state.isHalted, isFalse);
|
||||
expect(scheduler.delays, hasLength(1));
|
||||
|
||||
await engine.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
/// Pins the jitter multiplier at its lower bound so the backoff curve itself
|
||||
/// can be asserted.
|
||||
class _ZeroJitter implements Random {
|
||||
@override
|
||||
double nextDouble() => 0;
|
||||
|
||||
@override
|
||||
bool nextBool() => false;
|
||||
|
||||
@override
|
||||
int nextInt(int max) => 0;
|
||||
}
|
||||
|
||||
class _ThrowingRepository extends _StubRepository {
|
||||
@override
|
||||
Future<SyncOutcome> syncOrders({
|
||||
void Function(double progress, String stage)? onProgress,
|
||||
}) async =>
|
||||
throw StateError('boom');
|
||||
}
|
||||
|
||||
/// Lets queued microtasks run. The engine never really waits, so a handful of
|
||||
/// turns is enough for everything it schedules to settle.
|
||||
Future<void> _settle() async {
|
||||
for (var i = 0; i < 6; i++) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
}
|
||||
276
test/unit/transport_test.dart
Normal file
276
test/unit/transport_test.dart
Normal file
@@ -0,0 +1,276 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:nearle_pos/core/config/sync_config.dart';
|
||||
import 'package:nearle_pos/data/remote/http_order_transport.dart';
|
||||
import 'package:nearle_pos/data/remote/mqtt_order_transport.dart';
|
||||
import 'package:nearle_pos/data/remote/order_transport.dart';
|
||||
|
||||
/// Two bills, enough to tell "all accepted" from "some accepted".
|
||||
final _orders = [
|
||||
{'id': 'order-a', 'invoice_number': 'INV-1', 'total': 100.0},
|
||||
{'id': 'order-b', 'invoice_number': 'INV-2', 'total': 250.0},
|
||||
];
|
||||
|
||||
void main() {
|
||||
group('MQTT ack correlation', () {
|
||||
late MqttOrderTransport transport;
|
||||
const config = SyncConfig(
|
||||
transport: TransportKind.mqtt,
|
||||
storeId: 'store-9',
|
||||
terminalId: 'TERM-04',
|
||||
brokerHost: 'broker.invalid',
|
||||
);
|
||||
|
||||
setUp(() => transport = MqttOrderTransport(config: config));
|
||||
tearDown(() => transport.dispose());
|
||||
|
||||
test('topics are namespaced per store and per terminal', () {
|
||||
// Two stores sharing one broker must never see each other's bills.
|
||||
expect(config.orderTopic, 'pos/store-9/TERM-04/order');
|
||||
expect(config.ackTopic, 'pos/store-9/TERM-04/ack');
|
||||
expect(config.statusTopic, 'pos/store-9/TERM-04/status');
|
||||
expect(config.catalogueTopic, 'pos/store-9/catalogue');
|
||||
});
|
||||
|
||||
test('an ack naming only some ids accepts only those', () async {
|
||||
// The heart of it. A partial ack must not be read as "the batch went up":
|
||||
// order-b stays pending and is sent again.
|
||||
final receipt = _receiptFor(transport, {
|
||||
'accepted': ['order-a'],
|
||||
'rejected': {'order-b': 'unknown product'},
|
||||
});
|
||||
|
||||
final result = await receipt;
|
||||
expect(result.accepted, ['order-a']);
|
||||
expect(result.rejected, {'order-b': 'unknown product'});
|
||||
});
|
||||
|
||||
test('an ack for a different batch does not release this one', () async {
|
||||
final pending = transport.pushOrders(_orders).timeout(
|
||||
const Duration(milliseconds: 300),
|
||||
onTimeout: () => throw TimeoutException('not released'),
|
||||
);
|
||||
|
||||
// Give the publish a turn to register its correlation id, then answer
|
||||
// with someone else's.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
transport.handleInbound(
|
||||
config.ackTopic,
|
||||
jsonEncode({'batch_id': 'a-different-batch', 'accepted': ['order-a']}),
|
||||
);
|
||||
|
||||
await expectLater(pending, throwsA(isA<Exception>()));
|
||||
});
|
||||
|
||||
test('a malformed ack is discarded rather than taken down the connection',
|
||||
() {
|
||||
// The next message may be a perfectly good ack releasing a day's bills.
|
||||
expect(
|
||||
() => transport.handleInbound(config.ackTopic, 'not json at all'),
|
||||
returnsNormally,
|
||||
);
|
||||
expect(
|
||||
() => transport.handleInbound(config.ackTopic, '{"no":"batch id"}'),
|
||||
returnsNormally,
|
||||
);
|
||||
});
|
||||
|
||||
test('an ack with no accepted list releases nothing', () async {
|
||||
// Silence is not acceptance. A back office that answers `{}` must not
|
||||
// cause a single bill to be marked synced.
|
||||
final result = await _receiptFor(transport, {'accepted': <String>[]});
|
||||
expect(result.accepted, isEmpty);
|
||||
});
|
||||
|
||||
test('a bare list of rejected ids is tolerated', () async {
|
||||
final result = await _receiptFor(transport, {
|
||||
'accepted': ['order-a'],
|
||||
'rejected': ['order-b'],
|
||||
});
|
||||
expect(result.rejected.keys, ['order-b']);
|
||||
});
|
||||
|
||||
test('catalogue pushes arrive on the downlink', () async {
|
||||
final received = <DownlinkMessage>[];
|
||||
final sub = transport.downlink.listen(received.add);
|
||||
|
||||
transport
|
||||
..handleInbound(config.catalogueTopic, jsonEncode({'revision': 'r9'}))
|
||||
..handleInbound(config.commandTopic, jsonEncode({'command': 'sync'}))
|
||||
..handleInbound(
|
||||
config.commandTopic,
|
||||
jsonEncode({'command': 'self-destruct'}),
|
||||
);
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await sub.cancel();
|
||||
|
||||
expect(
|
||||
received.map((m) => m.kind),
|
||||
[
|
||||
DownlinkKind.catalogueChanged,
|
||||
DownlinkKind.syncRequested,
|
||||
// A newer server talking to an older terminal stays visible instead
|
||||
// of being silently dropped.
|
||||
DownlinkKind.unknown,
|
||||
],
|
||||
);
|
||||
expect(received.first.payload['revision'], 'r9');
|
||||
});
|
||||
});
|
||||
|
||||
group('HTTP transport', () {
|
||||
const SyncConfig config = SyncConfig(
|
||||
transport: TransportKind.http,
|
||||
httpBaseUrl: 'https://back.office.test',
|
||||
apiKey: 'k',
|
||||
);
|
||||
|
||||
test('accepts only the ids the endpoint names', () async {
|
||||
final transport = HttpOrderTransport(
|
||||
config: config,
|
||||
client: _FakeClient((_) => http.Response(
|
||||
jsonEncode({
|
||||
'accepted': ['order-a'],
|
||||
'rejected': {'order-b': 'stale price list'},
|
||||
}),
|
||||
200,
|
||||
),),
|
||||
);
|
||||
|
||||
final receipt = await transport.pushOrders(_orders);
|
||||
expect(receipt.accepted, ['order-a']);
|
||||
expect(receipt.rejected['order-b'], 'stale price list');
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('a bare 200 with no body marks nothing synced', () async {
|
||||
// Guessing here would retire a day's takings on an empty response.
|
||||
final transport = HttpOrderTransport(
|
||||
config: config,
|
||||
client: _FakeClient((_) => http.Response('', 200)),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
transport.pushOrders(_orders),
|
||||
throwsA(isA<TransportException>()),
|
||||
);
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('a 200 that names nothing accepts nothing, without throwing',
|
||||
() async {
|
||||
final transport = HttpOrderTransport(
|
||||
config: config,
|
||||
client: _FakeClient((_) => http.Response('{"accepted":[]}', 200)),
|
||||
);
|
||||
|
||||
final receipt = await transport.pushOrders(_orders);
|
||||
expect(receipt.accepted, isEmpty);
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('a bad credential is not retryable, so the engine can halt', () async {
|
||||
// Hammering the endpoint would only bury the one message a person needs.
|
||||
final transport = HttpOrderTransport(
|
||||
config: config,
|
||||
client: _FakeClient((_) => http.Response('nope', 401)),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
transport.pushOrders(_orders),
|
||||
throwsA(isA<TransportException>()
|
||||
.having((e) => e.retryable, 'retryable', isFalse),),
|
||||
);
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('a server error is retryable', () async {
|
||||
final transport = HttpOrderTransport(
|
||||
config: config,
|
||||
client: _FakeClient((_) => http.Response('boom', 503)),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
transport.pushOrders(_orders),
|
||||
throwsA(isA<TransportException>()
|
||||
.having((e) => e.retryable, 'retryable', isTrue),),
|
||||
);
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('a retry of the same bills carries the same idempotency key',
|
||||
() async {
|
||||
// So the endpoint can collapse a duplicate batch server-side rather than
|
||||
// relying on every order id being checked one at a time.
|
||||
final keys = <String>[];
|
||||
final transport = HttpOrderTransport(
|
||||
config: config,
|
||||
client: _FakeClient((request) {
|
||||
keys.add(request.headers['idempotency-key'] ?? '');
|
||||
return http.Response('{"accepted":["order-a","order-b"]}', 200);
|
||||
}),
|
||||
);
|
||||
|
||||
await transport.pushOrders(_orders);
|
||||
await transport.pushOrders(_orders);
|
||||
expect(keys.first, keys.last);
|
||||
expect(keys.first, isNotEmpty);
|
||||
|
||||
await transport.pushOrders([_orders.first]);
|
||||
expect(keys.last, isNot(keys.first));
|
||||
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('an unconfigured endpoint fails fast rather than retrying', () async {
|
||||
final transport = HttpOrderTransport(
|
||||
config: const SyncConfig(transport: TransportKind.http),
|
||||
client: _FakeClient((_) => http.Response('', 200)),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
transport.pushOrders(_orders),
|
||||
throwsA(isA<TransportException>()
|
||||
.having((e) => e.retryable, 'retryable', isFalse),),
|
||||
);
|
||||
await transport.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Waits on a known batch id, then feeds it [body] as if the back office had
|
||||
/// answered — exercising the real parse and correlation path with no broker.
|
||||
Future<PushReceipt> _receiptFor(
|
||||
MqttOrderTransport transport,
|
||||
Map<String, Object?> body,
|
||||
) {
|
||||
const batchId = 'test-batch';
|
||||
final receipt = transport.awaitAck(batchId);
|
||||
|
||||
transport.handleInbound(
|
||||
transport.config.ackTopic,
|
||||
jsonEncode({...body, 'batch_id': batchId}),
|
||||
);
|
||||
|
||||
return receipt;
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
198
test/widget/admin_dialogs_test.dart
Normal file
198
test/widget/admin_dialogs_test.dart
Normal file
@@ -0,0 +1,198 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:nearle_pos/app/providers.dart';
|
||||
import 'package:nearle_pos/data/datasources/local_store.dart';
|
||||
import 'package:nearle_pos/data/datasources/seed_data.dart';
|
||||
import 'package:nearle_pos/domain/entities/store_account.dart';
|
||||
import 'package:nearle_pos/presentation/auth/providers/auth_controller.dart';
|
||||
import 'package:nearle_pos/presentation/modules/widgets/staff_dialogs.dart';
|
||||
import 'package:nearle_pos/presentation/modules/widgets/store_details_dialog.dart';
|
||||
|
||||
/// The two admin screens that change what a till is and who may use it.
|
||||
///
|
||||
/// Both guard on role, and both refuse input that would break something a shop
|
||||
/// cannot recover from on its own — a locked-out account, or a GSTIN that is
|
||||
/// wrong on every invoice printed after it.
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
GoogleFonts.config.allowRuntimeFetching = false;
|
||||
LocalStore.registerSeed(
|
||||
products: SeedData.products,
|
||||
customers: SeedData.customers,
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
await LocalStore.instance.reset(withCatalogue: true);
|
||||
});
|
||||
|
||||
const admin = StaffUser(id: 'u1', name: 'Suriya', role: StaffRole.admin);
|
||||
const cashier = StaffUser(id: 'u3', name: 'Rahul', role: StaffRole.cashier);
|
||||
|
||||
StoreAccount storeWith(List<StaffUser> staff) => StoreAccount(
|
||||
id: 'store-001',
|
||||
name: 'Nearle Daily',
|
||||
email: 'admin@nearle.in',
|
||||
address: '1 Test Street',
|
||||
gstin: '33AABCU9603R1ZM',
|
||||
phone: '9840000000',
|
||||
staff: staff,
|
||||
);
|
||||
|
||||
/// Mounts a dialog with [who] signed in.
|
||||
Future<void> open(
|
||||
WidgetTester tester, {
|
||||
required StaffUser who,
|
||||
required Future<void> Function(BuildContext) show,
|
||||
List<StaffUser> staff = const [admin, cashier],
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
storeAccountProvider.overrideWith((ref) async => storeWith(staff)),
|
||||
authControllerProvider.overrideWith(
|
||||
(ref) => _StubAuth(storeWith(staff), who),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: TextButton(
|
||||
onPressed: () => show(context),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
group('staff', () {
|
||||
testWidgets('a cashier is refused', (tester) async {
|
||||
// Anyone who can edit staff can make themselves an admin, so the check
|
||||
// has to be at the door rather than on each action.
|
||||
await open(tester, who: cashier, show: showStaffDialog);
|
||||
|
||||
expect(find.textContaining('Only an admin'), findsOneWidget);
|
||||
expect(find.text('Add staff member'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('an admin can manage staff', (tester) async {
|
||||
await open(tester, who: admin, show: showStaffDialog);
|
||||
|
||||
expect(find.text('Add staff member'), findsOneWidget);
|
||||
expect(find.text('Suriya'), findsOneWidget);
|
||||
expect(find.text('Rahul'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a mistyped confirmation is caught before it locks an account',
|
||||
(tester) async {
|
||||
// There is no email to reset a PIN with. A typo nobody can verify means
|
||||
// the account is simply gone until an admin resets it.
|
||||
await open(tester, who: admin, show: showStaffDialog);
|
||||
|
||||
await tester.tap(find.text('Add staff member'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextFormField).first, 'Meena');
|
||||
await tester.enterText(find.byType(TextFormField).at(1), '7391');
|
||||
await tester.enterText(find.byType(TextFormField).at(2), '7392');
|
||||
|
||||
await tester.tap(find.text('Add'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('The two PINs do not match'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a too-short PIN is refused', (tester) async {
|
||||
await open(tester, who: admin, show: showStaffDialog);
|
||||
await tester.tap(find.text('Add staff member'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextFormField).first, 'Meena');
|
||||
await tester.enterText(find.byType(TextFormField).at(1), '73');
|
||||
await tester.enterText(find.byType(TextFormField).at(2), '73');
|
||||
|
||||
await tester.tap(find.text('Add'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('At least four digits'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an account still on a shipped PIN is flagged', (tester) async {
|
||||
await open(
|
||||
tester,
|
||||
who: admin,
|
||||
show: showStaffDialog,
|
||||
staff: const [
|
||||
admin,
|
||||
StaffUser(
|
||||
id: 'u9',
|
||||
name: 'Newbie',
|
||||
role: StaffRole.cashier,
|
||||
mustChangePin: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('store details', () {
|
||||
testWidgets('a cashier cannot change what invoices claim', (tester) async {
|
||||
await open(tester, who: cashier, show: showStoreDetailsDialog);
|
||||
expect(find.textContaining('Only an admin'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a malformed GSTIN is refused', (tester) async {
|
||||
// It prints on every invoice as a legal requirement, so a typo is a
|
||||
// compliance problem across hundreds of bills before anyone notices.
|
||||
await open(tester, who: admin, show: showStoreDetailsDialog);
|
||||
|
||||
await tester.enterText(find.byType(TextFormField).at(2), '99AABCU9603R1ZM');
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('The first two digits are not a valid state code.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('an empty seller name is refused', (tester) async {
|
||||
await open(tester, who: admin, show: showStoreDetailsDialog);
|
||||
|
||||
await tester.enterText(find.byType(TextFormField).first, '');
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('An invoice must name the seller'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Holds a fixed session so a test can choose who is signed in.
|
||||
class _StubAuth extends AuthController {
|
||||
_StubAuth(StoreAccount store, StaffUser user) : super(_throwingRef) {
|
||||
state = Authenticated(store: store, user: user);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> refreshStore() async {}
|
||||
}
|
||||
|
||||
/// The stub never reaches the real container, so a Ref is never used.
|
||||
final Ref _throwingRef = _UnusedRef();
|
||||
|
||||
class _UnusedRef implements Ref {
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) =>
|
||||
throw UnsupportedError('The stubbed AuthController does not read providers.');
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:nearle_pos/app/app.dart';
|
||||
import 'package:nearle_pos/data/datasources/local_store.dart';
|
||||
import 'package:nearle_pos/data/datasources/seed_data.dart';
|
||||
import 'package:nearle_pos/app/providers.dart';
|
||||
import 'package:nearle_pos/domain/entities/shift_report.dart';
|
||||
import 'package:nearle_pos/domain/entities/store_account.dart';
|
||||
import 'package:nearle_pos/presentation/auth/providers/auth_controller.dart';
|
||||
import 'package:nearle_pos/presentation/pos/providers/cart_controller.dart';
|
||||
import 'package:nearle_pos/presentation/pos/screens/pos_dashboard_screen.dart';
|
||||
@@ -30,6 +32,18 @@ void main() {
|
||||
await LocalStore.instance.reset(withCatalogue: true);
|
||||
});
|
||||
|
||||
const testStore = StoreAccount(
|
||||
id: 'store-001',
|
||||
name: 'Nearle Daily',
|
||||
email: DemoCredentials.email,
|
||||
address: '1 Test Street',
|
||||
gstin: '33AABCU9603R1ZM',
|
||||
phone: '9840000000',
|
||||
staff: [
|
||||
StaffUser(id: 'u1', name: 'Suriya', role: StaffRole.admin),
|
||||
],
|
||||
);
|
||||
|
||||
ShiftReport blankReport() => ShiftReport.blank(
|
||||
businessDate: DateTime(2026, 7, 31),
|
||||
terminalId: 'TERM-01',
|
||||
@@ -40,12 +54,25 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
// The background drain would open a broker connection and hit the
|
||||
// disk on a clock this test controls. Neither is what these tests
|
||||
// measure, and a half-driven timer would leak into the next one.
|
||||
syncBootstrapProvider.overrideWith((ref) async {}),
|
||||
|
||||
// Sign-in now reads staff and store details from SQLite. Real disk
|
||||
// I/O cannot complete inside a fixed number of pumps on a fake
|
||||
// clock, so the sign-in would hang and every later assertion would
|
||||
// fail on a screen that never arrived.
|
||||
storeAccountProvider.overrideWith((ref) async => testStore),
|
||||
|
||||
// Catalogue reads come from the in-memory cache and resolve on the
|
||||
// spot, but these four go to SQLite. Real disk I/O cannot be driven
|
||||
// by the fake clock a widget test runs on: sqflite's own lock-warning
|
||||
// timer is left pending and trips the binding's leak check. Stubbed
|
||||
// so this test measures rendering, which is what it is for.
|
||||
unsyncedCountProvider.overrideWith((ref) async => 0),
|
||||
promosProvider.overrideWith((ref) async => []),
|
||||
activePromosProvider.overrideWith((ref) async => []),
|
||||
parkedBillsProvider.overrideWith((ref) async => []),
|
||||
orderSyncRowsProvider.overrideWith((ref) async => []),
|
||||
todayReportProvider.overrideWith((ref) async => blankReport()),
|
||||
@@ -127,4 +154,40 @@ void main() {
|
||||
expect(tester.takeException(), isNull, reason: 'opening "$label" threw');
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('the back office connection dialog opens and validates',
|
||||
(tester) async {
|
||||
// The only way a shop can point a till at a broker. Until it existed a
|
||||
// store was wired up by editing a provider and rebuilding.
|
||||
tester.view.physicalSize = const Size(1800, 1200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await bootApp(tester);
|
||||
await signIn(tester);
|
||||
|
||||
await tester.tap(find.text('Settings').first);
|
||||
await settle(tester);
|
||||
|
||||
await tester.tap(find.text('Configure').first);
|
||||
await settle(tester);
|
||||
|
||||
// "Back office connection" is both the dialog title and the About card's
|
||||
// button, so match the dialog itself.
|
||||
expect(find.byType(AlertDialog), findsOneWidget);
|
||||
expect(find.text('MQTT'), findsOneWidget);
|
||||
|
||||
// Switching to MQTT and saving with no host must be refused, not silently
|
||||
// accepted — a terminal pointed at nothing looks identical to one that is
|
||||
// simply offline.
|
||||
await tester.tap(find.text('MQTT'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('Save'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('A broker host is required'), findsOneWidget);
|
||||
expect(find.byType(AlertDialog), findsOneWidget,
|
||||
reason: 'the dialog must stay open on a validation failure',);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,12 +7,18 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <audioplayers_windows/audioplayers_windows_plugin.h>
|
||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <printing/printing_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
AudioplayersWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
|
||||
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
PrintingPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PrintingPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_windows
|
||||
connectivity_plus
|
||||
flutter_secure_storage_windows
|
||||
printing
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user