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:
Suriya
2026-08-01 15:42:55 +05:30
75 changed files with 9921 additions and 507 deletions

251
docs/sync-contract.md Normal file
View 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.

View File

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

View File

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

View 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,
);
}

View File

@@ -3,6 +3,10 @@ class AppConstants {
const AppConstants._();
static const String appName = 'Nearle POS';
/// Reported in every presence record. Across a fleet this is how you find
/// the twelve tills still on last month's build when one of them misbehaves.
static const String appVersion = '1.1.0';
static const String storeName = 'Nearle Daily';
static const String storeAddress = '12 Gandhipuram Main Rd, Coimbatore 641012';
static const String storeGstin = '33ABCDE1234F1Z5';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -59,9 +59,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';
}
}

View File

@@ -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);
}

View File

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

View File

@@ -13,7 +13,7 @@ class AppDatabase {
static final AppDatabase instance = AppDatabase._();
static const String _fileName = 'nearle_pos.db';
static const int _version = 4;
static const int _version = 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';

View File

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

View File

@@ -5,15 +5,20 @@ import 'package:sqflite/sqflite.dart';
import '../../domain/entities/cart.dart';
import '../../domain/entities/customer.dart';
import '../../domain/entities/product.dart';
import '../../domain/entities/promo.dart';
import '../../domain/entities/transaction.dart';
import 'app_database.dart';
import 'catalogue_dao.dart';
/// Persists bills.
///
/// Every completed sale lands here with `sync_status = 0`. The end-of-day
/// upload selects those rows, sends them, and flips the accepted ones to 1.
/// Nothing is ever deleted as part of syncing.
/// Every completed sale lands here with `sync_status = 0`, which makes this
/// table the terminal's outbox: the drain engine selects those rows, sends
/// them, and flips the ones the back office confirmed to 1.
///
/// Confirmed rows are kept for [retentionWindow] so a batch the server later
/// loses can be re-sent in full, then retired by [purgeSyncedBefore]. Syncing
/// itself never deletes anything.
class OrderDao {
const OrderDao(this._db);
@@ -22,6 +27,9 @@ class OrderDao {
static const int pending = 0;
static const int synced = 1;
/// How long an accepted bill stays re-sendable on the terminal.
static const Duration retentionWindow = Duration(days: 7);
static String businessDateOf(DateTime dt) =>
'${dt.year.toString().padLeft(4, '0')}-'
'${dt.month.toString().padLeft(2, '0')}-'
@@ -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,

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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);
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,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();
}

View 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();
}
}

View 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();
}
}

View 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();
}

View File

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

View File

@@ -0,0 +1,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();
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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();
}
}

View File

@@ -4,6 +4,7 @@ import '../../core/constants/app_constants.dart';
import '../../core/utils/extensions.dart';
import 'customer.dart';
import 'product.dart';
import 'promo.dart';
/// How a discount value should be interpreted.
enum DiscountType { none, percentage, flat }
@@ -100,6 +101,7 @@ class Cart extends Equatable {
this.billDiscount = Discount.none,
this.pointsRedeemed = 0,
this.note,
this.appliedPromos = const [],
});
final List<CartLine> lines;
@@ -108,6 +110,14 @@ class Cart extends Equatable {
final int pointsRedeemed;
final String? note;
/// Campaigns that fired on this bill.
///
/// Resolved by `PromoEngine` and handed in, rather than computed here: which
/// campaigns exist is policy that changes weekly, and Cart owns arithmetic
/// that must never be wrong. Stored as amounts so a bill read back years
/// later shows what was actually given, not what today's rules would give.
final List<AppliedPromo> appliedPromos;
static const Cart empty = Cart();
bool get isEmpty => lines.isEmpty;
@@ -143,9 +153,17 @@ class Cart extends Equatable {
double get manualBillDiscountAmount => billDiscount.amountOn(subtotal);
/// What the automatic campaigns took off.
double get promoDiscountAmount =>
appliedPromos.fold(0.0, (sum, p) => sum + p.amount).asMoney;
/// All bill-level reductions combined.
///
/// Clamped to the subtotal so no combination of tier, campaign and manual
/// discount can drive a bill below zero and turn a sale into a payout.
double get billDiscountTotal =>
(membershipDiscountAmount + manualBillDiscountAmount)
(membershipDiscountAmount + manualBillDiscountAmount +
promoDiscountAmount)
.clamp(0, subtotal)
.toDouble()
.asMoney;
@@ -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];
}

View 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];
}

View File

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

View File

@@ -2,18 +2,30 @@ import '../entities/shift_report.dart';
import '../entities/sync_event.dart';
import '../entities/transaction.dart';
/// Result of one end-of-day upload.
/// Result of one upload pass.
class SyncOutcome {
const SyncOutcome({
required this.attempted,
required this.uploaded,
this.rejected = 0,
this.error,
this.isRetryable = true,
});
final int attempted;
final int uploaded;
/// Bills the back office looked at and refused. These stay on the terminal
/// but sending them again unchanged will fail again, so they need a person.
final int rejected;
final String? error;
/// Whether trying again could plausibly work. False for a bad credential or
/// an unconfigured endpoint — the drain engine halts rather than retrying
/// something that cannot succeed.
final bool isRetryable;
bool get isSuccess => error == null;
bool get hadNothingToDo => attempted == 0;
int get remaining => attempted - uploaded;
@@ -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;

View 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;
}
}

View File

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

View File

@@ -1,6 +1,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;
});

View File

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

View File

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

View File

@@ -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',
'SatSun',
412,
AppColors.primary,
),
(
'DAIRY5',
'Dairy Days',
'5% off all dairy products',
'Ends 31 Aug',
286,
AppColors.info,
),
(
'FESTIVE',
'Festive Bonus',
'Double loyalty points',
'Ends 15 Sep',
178,
AppColors.tierGold,
),
(
'NEWCUST',
'First Purchase',
'₹50 off the first bill',
'Always on',
94,
AppColors.success,
),
];
@override
Widget build(BuildContext context) {
return ModulePage(
children: [
Wrap(
spacing: AppSpacing.lg,
runSpacing: AppSpacing.lg,
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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,9 +9,11 @@ import '../../../core/services/sound_service.dart';
import '../../../domain/entities/cart.dart';
import '../../../domain/entities/customer.dart';
import '../../../domain/entities/product.dart';
import '../../../domain/entities/promo.dart';
import '../../../domain/entities/transaction.dart';
import '../../../domain/repositories/product_repository.dart';
import '../../../domain/repositories/transaction_repository.dart';
import '../../../domain/services/promo_engine.dart';
/// Transient feedback for the scan toast — never a blocking dialog.
enum ScanOutcome { added, incremented, notFound, outOfStock }
@@ -43,9 +45,13 @@ class CartController extends StateNotifier<Cart> {
required TransactionRepository transactions,
required SoundService sound,
required this.onFeedback,
List<Promo> promos = const [],
DateTime Function()? clock,
}) : _products = products,
_transactions = transactions,
_sound = sound,
_promos = promos,
_now = clock ?? DateTime.now,
super(Cart.empty);
final ProductRepository _products;
@@ -53,8 +59,29 @@ class CartController extends StateNotifier<Cart> {
final SoundService _sound;
final void Function(ScanFeedback) onFeedback;
/// Campaigns live right now. Re-evaluated after every change to the bill,
/// because whether one fires depends on what is in it.
final List<Promo> _promos;
final DateTime Function() _now;
static const _uuid = Uuid();
/// Applies the campaign rules to [next] and stores the result.
///
/// Every mutation goes through here rather than assigning `state` directly,
/// so a promo cannot be left applied after the line that earned it is
/// removed — which is how a shopper gets a discount for an item they put
/// back.
void _commit(Cart next) {
state = next.copyWith(
appliedPromos: PromoEngine.evaluate(
cart: next,
promos: _promos,
at: _now(),
),
);
}
/// Snapshots for undo — capped so memory can't grow unbounded on a terminal
/// that runs for days.
final List<Cart> _undoStack = [];
@@ -105,18 +132,18 @@ class CartController extends StateNotifier<Cart> {
}
if (existing == null) {
state = state.copyWith(lines: [
_commit(state.copyWith(lines: [
...state.lines,
CartLine(
product: product,
quantity: quantity,
addedAt: DateTime.now(),
),
],);
],),);
} else {
state = state.copyWith(
_commit(state.copyWith(
lines: _replace(existing.copyWith(quantity: requested)),
);
),);
}
_clampRedemption();
@@ -171,7 +198,7 @@ class CartController extends StateNotifier<Cart> {
}
_push();
state = state.copyWith(lines: _replace(line.copyWith(quantity: capped)));
_commit(state.copyWith(lines: _replace(line.copyWith(quantity: capped))));
_clampRedemption();
}
@@ -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,
);

View File

@@ -6,11 +6,13 @@ import '../../../core/services/barcode_service.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_layout.dart';
import '../../auth/providers/auth_controller.dart';
import '../../modules/screens/customers_view.dart';
import '../../modules/screens/events_view.dart';
import '../../modules/screens/product_import_view.dart';
import '../../modules/screens/promos_view.dart';
import '../../modules/screens/settings_view.dart';
import '../../modules/widgets/staff_dialogs.dart';
import '../../sync/providers/sync_controller.dart';
import '../providers/cart_controller.dart';
import '../providers/catalog_providers.dart';
@@ -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();

View File

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

View File

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

View File

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

View File

@@ -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();
}
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,6 +39,11 @@ dependencies:
audioplayers: ^6.0.0
pdf: ^3.11.0
printing: ^5.13.0
mqtt_client: ^10.11.11
connectivity_plus: ^7.3.1
http: ^1.6.0
crypto: ^3.0.7
flutter_secure_storage: ^10.3.1
dev_dependencies:
flutter_test:

View File

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

View File

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

View File

@@ -0,0 +1,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));
});
});
}

View File

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

View File

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

View File

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

View 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,
);
});
});
}

View 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);
});
});
}

View 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);
});
});
}

View 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',);
});
});
}

View 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);
}
}

View 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,
);
}
}

View 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.');
}

View File

@@ -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);
});
}

View File

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

View File

@@ -4,6 +4,8 @@
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows
connectivity_plus
flutter_secure_storage_windows
printing
url_launcher_windows
)