diff --git a/docs/sync-contract.md b/docs/sync-contract.md new file mode 100644 index 0000000..e2dd3bc --- /dev/null +++ b/docs/sync-contract.md @@ -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. diff --git a/lib/app/app.dart b/lib/app/app.dart index 932201e..4c7eb3a 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -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, diff --git a/lib/app/providers.dart b/lib/app/providers.dart index 503d6d0..b176c77 100644 --- a/lib/app/providers.dart +++ b/lib/app/providers.dart @@ -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((ref) => LocalStore.instance); @@ -36,28 +50,130 @@ final transactionRepositoryProvider = Provider( /// purpose, which reads as a real network fault. final simulateOfflineProvider = StateProvider((ref) => false); -/// Simulated back-office endpoints. Held as singletons so the offline toggle -/// in Settings affects every call. -final remoteCatalogueProvider = Provider( - (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((ref) { + final config = ref.watch(syncConfigProvider); -final remoteOrderSinkProvider = Provider( - (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((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((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((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( (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((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((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( + (ref) => StoreRepositoryImpl(ref.watch(localStoreProvider)), +); + +/// The outlet, refreshed whenever staff or details change. +final storeAccountProvider = FutureProvider( + (ref) => ref.watch(storeRepositoryProvider).load( + email: DemoCredentials.email, + ), +); + +/// Campaigns stored on this terminal. +final promosProvider = FutureProvider>( + (ref) => ref.watch(localStoreProvider).promos.all(), +); + +/// Only the campaigns the till should be applying right now. +final activePromosProvider = FutureProvider>( + (ref) => ref.watch(localStoreProvider).promos.all(activeOnly: true), +); + // ------------------------------------------------------------- Use cases final checkoutSaleProvider = Provider( (ref) => CheckoutSale( @@ -87,13 +203,31 @@ class CashierSession { final String terminalId; } -final cashierSessionProvider = StateProvider( - (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((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((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. diff --git a/lib/core/config/sync_config.dart b/lib/core/config/sync_config.dart new file mode 100644 index 0000000..e1f69d8 --- /dev/null +++ b/lib/core/config/sync_config.dart @@ -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, + ); +} diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index b0c3d51..2b0594c 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -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'; diff --git a/lib/core/security/pin_hasher.dart b/lib/core/security/pin_hasher.dart new file mode 100644 index 0000000..1baed3e --- /dev/null +++ b/lib/core/security/pin_hasher.dart @@ -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 password, + List 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 = [ + ...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)); + } +} diff --git a/lib/core/services/cash_drawer_service.dart b/lib/core/services/cash_drawer_service.dart new file mode 100644 index 0000000..b39433f --- /dev/null +++ b/lib/core/services/cash_drawer_service.dart @@ -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 Function(String host, int port, {Duration? timeout})? connect, + }) : _connect = connect ?? _defaultConnect; + + final Future Function(String host, int port, {Duration? timeout}) + _connect; + + static Future _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 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 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; +} diff --git a/lib/core/services/connectivity_service.dart b/lib/core/services/connectivity_service.dart new file mode 100644 index 0000000..712f294 --- /dev/null +++ b/lib/core/services/connectivity_service.dart @@ -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.broadcast(); + StreamSubscription>? _subscription; + + bool _hasInterface = true; + bool _started = false; + + /// Fires only when the answer changes, so a subscriber can treat every event + /// as an edge. + Stream get onlineChanges => _controller.stream; + + bool get isOnline => _hasInterface && !_isSimulatedOffline(); + + Future 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 results) => + results.any((r) => r != ConnectivityResult.none); + + Future dispose() async { + await _subscription?.cancel(); + await _controller.close(); + } +} diff --git a/lib/core/services/receipt_service.dart b/lib/core/services/receipt_service.dart index 12496fd..5ae017c 100644 --- a/lib/core/services/receipt_service.dart +++ b/lib/core/services/receipt_service.dart @@ -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 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 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 openCashDrawer({ + String? host, + int port = 9100, + }) => + CashDrawerService().open(host: host, port: port); } /// One GST rate's slice of a bill. diff --git a/lib/core/services/sound_service.dart b/lib/core/services/sound_service.dart index 1f4fdcf..aa3d058 100644 --- a/lib/core/services/sound_service.dart +++ b/lib/core/services/sound_service.dart @@ -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 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) { diff --git a/lib/core/utils/formatters.dart b/lib/core/utils/formatters.dart index 055e1ce..c10efdf 100644 --- a/lib/core/utils/formatters.dart +++ b/lib/core/utils/formatters.dart @@ -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'; } } diff --git a/lib/data/datasources/local_store.dart b/lib/data/datasources/local_store.dart index 130f192..99572bb 100644 --- a/lib/data/datasources/local_store.dart +++ b/lib/data/datasources/local_store.dart @@ -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 _products = {}; final Map _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 customers, required String revision, required DateTime at, + bool isDelta = false, + List 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); } diff --git a/lib/data/datasources/remote_catalogue_source.dart b/lib/data/datasources/remote_catalogue_source.dart deleted file mode 100644 index 54a1b44..0000000 --- a/lib/data/datasources/remote_catalogue_source.dart +++ /dev/null @@ -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 products; - final List 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 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.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> pushOrders(List> orders) async { - await Future.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(); - } -} diff --git a/lib/data/local/app_database.dart b/lib/data/local/app_database.dart index d2c0082..e86d035 100644 --- a/lib/data/local/app_database.dart +++ b/lib/data/local/app_database.dart @@ -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 _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 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'; diff --git a/lib/data/local/catalogue_dao.dart b/lib/data/local/catalogue_dao.dart index f74282d..ed131d1 100644 --- a/lib/data/local/catalogue_dao.dart +++ b/lib/data/local/catalogue_dao.dart @@ -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 applyCatalogueDelta({ + required List products, + required List customers, + List 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 decrementStock(Map 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> allMeta() async { + final rows = await _db.query(Tables.meta); + return { + for (final row in rows) + row['key']! as String: (row['value'] as String?) ?? '', + }; + } + Future setMeta(String key, String value) async { await _db.insert( Tables.meta, diff --git a/lib/data/local/order_dao.dart b/lib/data/local/order_dao.dart index d9643a2..e9e761c 100644 --- a/lib/data/local/order_dao.dart +++ b/lib/data/local/order_dao.dart @@ -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> 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> 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 archiveAndDelete(List 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 archiveAccepted(List 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 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 _promosFromRow(String? json) { + if (json == null || json.isEmpty) return const []; + + try { + return (jsonDecode(json) as List) + .cast>() + .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> _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(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, diff --git a/lib/data/local/promo_dao.dart b/lib/data/local/promo_dao.dart new file mode 100644 index 0000000..cf73893 --- /dev/null +++ b/lib/data/local/promo_dao.dart @@ -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> 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 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 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 _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 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 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 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, + ); + } +} diff --git a/lib/data/local/staff_dao.dart b/lib/data/local/staff_dao.dart new file mode 100644 index 0000000..414d9d6 --- /dev/null +++ b/lib/data/local/staff_dao.dart @@ -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 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> 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 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 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 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 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 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 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 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 _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 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, + ); +} diff --git a/lib/data/local/sync_config_store.dart b/lib/data/local/sync_config_store.dart new file mode 100644 index 0000000..df90dfa --- /dev/null +++ b/lib/data/local/sync_config_store.dart @@ -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 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 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 _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 clearCredentials() async { + for (final key in [_kUsername, _kPassword, _kApiKey]) { + await _writeSecret(key, null); + } + } +} diff --git a/lib/data/local/terminal_identity.dart b/lib/data/local/terminal_identity.dart new file mode 100644 index 0000000..681bbfe --- /dev/null +++ b/lib/data/local/terminal_identity.dart @@ -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 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 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); + } + } +} diff --git a/lib/data/remote/catalogue_source.dart b/lib/data/remote/catalogue_source.dart new file mode 100644 index 0000000..7e372ae --- /dev/null +++ b/lib/data/remote/catalogue_source.dart @@ -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 products; + final List 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 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 fetch({ + String? since, + void Function(double progress, String stage)? onProgress, + }); + + void dispose() {} +} diff --git a/lib/data/remote/catalogue_wire.dart b/lib/data/remote/catalogue_wire.dart new file mode 100644 index 0000000..c5ebca6 --- /dev/null +++ b/lib/data/remote/catalogue_wire.dart @@ -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 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 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 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 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 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 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; + } +} diff --git a/lib/data/remote/http_catalogue_source.dart b/lib/data/remote/http_catalogue_source.dart new file mode 100644 index 0000000..4618934 --- /dev/null +++ b/lib/data/remote/http_catalogue_source.dart @@ -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 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 = []; + final customers = []; + final retired = []; + + 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? ?? const []) + .whereType(), + ); + + 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> _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; + } 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 _decodeList( + Object? raw, + T Function(Map) decode, + ) { + if (raw is! List) return const []; + return raw + .whereType>() + .map(decode) + .toList(growable: false); + } + + static String _trim(String body) => + body.length <= 200 ? body : '${body.substring(0, 200)}…'; + + @override + void dispose() => _client.close(); +} diff --git a/lib/data/remote/http_order_transport.dart b/lib/data/remote/http_order_transport.dart new file mode 100644 index 0000000..cbc1943 --- /dev/null +++ b/lib/data/remote/http_order_transport.dart @@ -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": ["", ...], "rejected": { "": "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.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 get downlink => const Stream.empty(); + + @override + Stream get connectionState => _connection.stream; + + @override + Future connect() async {} + + @override + Future pushOrders(List> 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 body; + try { + body = jsonDecode(response.body) as Map; + } 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? ?? const []) + .whereType() + .toList(); + + final rejected = {}; + 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> 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 dispose() async { + _client.close(); + await _connection.close(); + } +} diff --git a/lib/data/remote/mqtt_order_transport.dart b/lib/data/remote/mqtt_order_transport.dart new file mode 100644 index 0000000..4be5f9d --- /dev/null +++ b/lib/data/remote/mqtt_order_transport.dart @@ -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 = >{}; + + final _downlink = StreamController.broadcast(); + final _connection = StreamController.broadcast(); + + /// Guards against two drains racing to open the same connection. + Future? _connecting; + + StreamSubscription>>? _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 get downlink => _downlink.stream; + + @override + Stream get connectionState => _connection.stream; + + // ------------------------------------------------------------- Connection + @override + Future connect() { + if (isConnected) return Future.value(); + return _connecting ??= _doConnect().whenComplete(() => _connecting = null); + } + + Future _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> 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 pushOrders(List> orders) async { + if (orders.isEmpty) return const PushReceipt(accepted: []); + + await connect(); + + final batchId = _uuid.v4(); + final completer = Completer(); + _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 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 awaitAck(String batchId) { + final completer = Completer(); + _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 body; + try { + body = jsonDecode(payload) as Map; + } 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 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? ?? const []) + .whereType() + .toList(); + + final rejected = {}; + 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()) { + 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 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(); + } +} diff --git a/lib/data/remote/order_transport.dart b/lib/data/remote/order_transport.dart new file mode 100644 index 0000000..87a1566 --- /dev/null +++ b/lib/data/remote/order_transport.dart @@ -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 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 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 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 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 pushOrders(List> orders); + + /// Cloud-initiated messages. Empty for transports that cannot receive. + Stream 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 get connectionState; + + bool get isConnected; + + Future dispose(); +} diff --git a/lib/data/remote/simulated_catalogue_source.dart b/lib/data/remote/simulated_catalogue_source.dart new file mode 100644 index 0000000..7262dae --- /dev/null +++ b/lib/data/remote/simulated_catalogue_source.dart @@ -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 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.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() {} +} diff --git a/lib/data/remote/simulated_order_transport.dart b/lib/data/remote/simulated_order_transport.dart new file mode 100644 index 0000000..612db40 --- /dev/null +++ b/lib/data/remote/simulated_order_transport.dart @@ -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.broadcast(); + final _connection = StreamController.broadcast(); + + @override + String get label => 'Simulated'; + + @override + bool get isConnected => !isOffline(); + + @override + Stream get downlink => _downlink.stream; + + @override + Stream get connectionState => _connection.stream; + + @override + Future connect() async {} + + @override + Future pushOrders(List> orders) async { + await Future.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 dispose() async { + await _downlink.close(); + await _connection.close(); + } +} diff --git a/lib/data/repositories/store_repository_impl.dart b/lib/data/repositories/store_repository_impl.dart new file mode 100644 index 0000000..59e0fbf --- /dev/null +++ b/lib/data/repositories/store_repository_impl.dart @@ -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 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 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; + } +} diff --git a/lib/data/repositories/sync_repository_impl.dart b/lib/data/repositories/sync_repository_impl.dart index 3a2d312..8259f7f 100644 --- a/lib/data/repositories/sync_repository_impl.dart +++ b/lib/data/repositories/sync_repository_impl.dart @@ -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 = []; // `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(0, (s, o) => s + o.total); + final pendingValue = batch.fold(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 purgeExpired() => _store.orders.purgeSyncedBefore( + DateTime.now().subtract(OrderDao.retentionWindow), + ); + /// The JSON body sent per order. Map _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, diff --git a/lib/data/sync/presence_reporter.dart b/lib/data/sync/presence_reporter.dart new file mode 100644 index 0000000..503a2fc --- /dev/null +++ b/lib/data/sync/presence_reporter.dart @@ -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 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 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 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 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 dispose() async { + _stopped = true; + _timer?.cancel(); + } +} diff --git a/lib/data/sync/sync_engine.dart b/lib/data/sync/sync_engine.dart new file mode 100644 index 0000000..2fddf3d --- /dev/null +++ b/lib/data/sync/sync_engine.dart @@ -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? connectivity, + Stream? downlink, + Future 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? _connectivity; + final Stream? _downlink; + final Future 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.broadcast(); + + SyncEngineState _state = const SyncEngineState(); + SyncEngineState get state => _state; + Stream 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? _connectivitySub; + StreamSubscription? _downlinkSub; + bool _stopped = false; + + // ------------------------------------------------------------------ Life + Future 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.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 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 _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 _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 dispose() async { + _stopped = true; + _backoffTimer?.cancel(); + _pollTimer?.cancel(); + await _connectivitySub?.cancel(); + await _downlinkSub?.cancel(); + await _states.close(); + } +} diff --git a/lib/domain/entities/cart.dart b/lib/domain/entities/cart.dart index 59d0a35..09626e2 100644 --- a/lib/domain/entities/cart.dart +++ b/lib/domain/entities/cart.dart @@ -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 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 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? 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 get props => - [lines, customer, billDiscount, pointsRedeemed, note]; + [lines, customer, billDiscount, pointsRedeemed, note, appliedPromos]; } diff --git a/lib/domain/entities/promo.dart b/lib/domain/entities/promo.dart new file mode 100644 index 0000000..7787e4b --- /dev/null +++ b/lib/domain/entities/promo.dart @@ -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 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? 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 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 get props => [promo.id, amount]; +} diff --git a/lib/domain/entities/store_account.dart b/lib/domain/entities/store_account.dart index 4554017..8ae7655 100644 --- a/lib/domain/entities/store_account.dart +++ b/lib/domain/entities/store_account.dart @@ -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 get props => [id, name, role]; + List get props => [id, name, role, mustChangePin, isActive]; } /// The registered outlet this terminal belongs to. @@ -58,6 +83,26 @@ class StoreAccount extends Equatable { final List staff; final String plan; + StoreAccount copyWith({ + String? name, + String? email, + String? address, + String? gstin, + String? phone, + List? 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 get props => [id, email]; + List get props => [id, name, email, address, gstin, phone, plan]; } diff --git a/lib/domain/repositories/sync_repository.dart b/lib/domain/repositories/sync_repository.dart index e5872b0..72fb782 100644 --- a/lib/domain/repositories/sync_repository.dart +++ b/lib/domain/repositories/sync_repository.dart @@ -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 syncOrders({ void Function(double progress, String stage)? onProgress, }); + /// Retires confirmed bills past their retention window. Archived totals are + /// untouched. + Future purgeExpired(); + Future> orderSyncRows({int limit = 200}); List get events; diff --git a/lib/domain/services/promo_engine.dart b/lib/domain/services/promo_engine.dart new file mode 100644 index 0000000..e50abd4 --- /dev/null +++ b/lib/domain/services/promo_engine.dart @@ -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 evaluate({ + required Cart cart, + required List promos, + required DateTime at, + }) { + if (cart.isEmpty || promos.isEmpty) return const []; + + final subtotal = cart.subtotal; + + final eligible = []; + 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 = [ + ...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 _capped(List applied, double ceiling) { + final result = []; + 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; + } +} diff --git a/lib/domain/usecases/checkout_sale.dart b/lib/domain/usecases/checkout_sale.dart index 63a696c..5826274 100644 --- a/lib/domain/usecases/checkout_sale.dart +++ b/lib/domain/usecases/checkout_sale.dart @@ -51,7 +51,8 @@ class CheckoutSale { required Cart cart, required List 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, diff --git a/lib/presentation/auth/providers/auth_controller.dart b/lib/presentation/auth/providers/auth_controller.dart index 70a6321..95a1256 100644 --- a/lib/presentation/auth/providers/auth_controller.dart +++ b/lib/presentation/auth/providers/auth_controller.dart @@ -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 { - AuthController() : super(const Unauthenticated()); + AuthController(this._ref) : super(const Unauthenticated()); + + final Ref _ref; Future signIn({ required String email, @@ -81,15 +71,59 @@ class AuthController extends StateNotifier { 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 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 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 { } } -final authControllerProvider = - StateNotifierProvider((ref) => AuthController()); +final authControllerProvider = StateNotifierProvider( + AuthController.new, +); /// The signed-in store, or null before sign-in. final currentStoreProvider = Provider((ref) { @@ -113,3 +148,9 @@ final currentUserProvider = Provider((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((ref) { + final user = ref.watch(currentUserProvider); + return user?.mustChangePin ?? false; +}); diff --git a/lib/presentation/modules/providers/printer_settings.dart b/lib/presentation/modules/providers/printer_settings.dart index 7eff66a..39f3d89 100644 --- a/lib/presentation/modules/providers/printer_settings.dart +++ b/lib/presentation/modules/providers/printer_settings.dart @@ -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 { 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 { 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 { .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 { .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 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 = diff --git a/lib/presentation/modules/screens/customers_view.dart b/lib/presentation/modules/screens/customers_view.dart index e489a62..c18fed8 100644 --- a/lib/presentation/modules/screens/customers_view.dart +++ b/lib/presentation/modules/screens/customers_view.dart @@ -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 { 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( diff --git a/lib/presentation/modules/screens/promos_view.dart b/lib/presentation/modules/screens/promos_view.dart index 07c2e55..38df98a 100644 --- a/lib/presentation/modules/screens/promos_view.dart +++ b/lib/presentation/modules/screens/promos_view.dart @@ -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 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 { - final Set _enabled = {'WEEKEND10', 'DAIRY5', 'FESTIVE'}; - - static const _campaigns = [ - ( - 'WEEKEND10', - 'Weekend Saver', - '10% off bills above ₹500', - 'Sat–Sun', - 412, - AppColors.primary, - ), - ( - 'DAIRY5', - 'Dairy Days', - '5% off all dairy products', - 'Ends 31 Aug', - 286, - AppColors.info, - ), - ( - 'FESTIVE', - 'Festive Bonus', - 'Double loyalty points', - 'Ends 15 Sep', - 178, - AppColors.tierGold, - ), - ( - 'NEWCUST', - 'First Purchase', - '₹50 off the first bill', - 'Always on', - 94, - AppColors.success, - ), - ]; - - @override - Widget build(BuildContext context) { return ModulePage( children: [ - Wrap( - spacing: AppSpacing.lg, - runSpacing: AppSpacing.lg, - children: [ - StatTile( - label: 'Active Campaigns', - value: '${_enabled.length}', - icon: Icons.campaign_rounded, - caption: 'of ${_campaigns.length} configured', - ), - const StatTile( - label: 'Redemptions', - value: '970', - icon: Icons.confirmation_number_rounded, - color: AppColors.info, - caption: 'this month', - ), - const StatTile( - label: 'Discount Given', - value: '₹48,240', - icon: Icons.local_offer_rounded, - color: AppColors.warning, - caption: '2.6% of sales', - ), - const StatTile( - label: 'Incremental Sales', - value: '₹2.14L', - icon: Icons.trending_up_rounded, - color: AppColors.success, - delta: '+18%', - caption: 'attributed', - ), - ], - ), - const SizedBox(height: AppSpacing.lg), - - PanelCard( - title: 'Campaigns', - subtitle: 'Toggle a rule to apply it at the till immediately', - action: FilledButton.icon( - onPressed: () {}, - icon: const Icon(Icons.add_rounded, size: 18), - label: const Text('New campaign'), - style: FilledButton.styleFrom( - backgroundColor: AppColors.primary, - minimumSize: const Size(0, 40), - ), + promosAsync.when( + loading: () => const Padding( + padding: EdgeInsets.all(AppSpacing.xxl), + child: Center(child: CircularProgressIndicator()), ), - child: Column( - mainAxisSize: MainAxisSize.min, + error: (e, _) => Text('Could not load campaigns: $e'), + data: (promos) => Column( children: [ - for (final c in _campaigns) - Container( - margin: const EdgeInsets.only(bottom: AppSpacing.sm), - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppColors.surfaceAlt, - borderRadius: AppRadius.brMd, - border: Border.all(color: AppColors.border), - ), - // Wrap prevents collision when the panel is narrow. - child: Wrap( - alignment: WrapAlignment.spaceBetween, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: AppSpacing.md, - runSpacing: AppSpacing.sm, - children: [ - SizedBox( - width: 320, - child: Row( - children: [ - Container( - width: 38, - height: 38, - decoration: BoxDecoration( - color: c.$6.withValues(alpha: 0.12), - borderRadius: AppRadius.brSm, - ), - child: Icon(Icons.sell_rounded, - size: 18, color: c.$6,), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - c.$2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - Text( - c.$3, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 12.5, - color: AppColors.textSecondary, - ), - ), - ], - ), - ), - ], - ), - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - TagChip(c.$1, color: c.$6), - const SizedBox(width: AppSpacing.sm), - TagChip(c.$4, color: AppColors.textSecondary), - const SizedBox(width: AppSpacing.sm), - Text( - '${c.$5} used', - style: const TextStyle( - fontSize: 12, - color: AppColors.textTertiary, - ), - ), - const SizedBox(width: AppSpacing.sm), - Switch( - value: _enabled.contains(c.$1), - onChanged: (v) => setState(() { - if (v) { - _enabled.add(c.$1); - } else { - _enabled.remove(c.$1); - } - }), - ), - ], - ), - ], - ), - ), + _summary(promos), + const SizedBox(height: AppSpacing.lg), + _campaignList(context, ref, promos, isAdmin: isAdmin), ], ), ), ], ); } + + Widget _summary(List 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 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 days) { + const names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + final sorted = days.toList()..sort(); + return sorted.map((d) => names[d - 1]).join(', '); + } + + Future _confirmDelete(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + 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); + } } diff --git a/lib/presentation/modules/screens/settings_view.dart b/lib/presentation/modules/screens/settings_view.dart index 363fe95..ab161b3 100644 --- a/lib/presentation/modules/screens/settings_view.dart +++ b/lib/presentation/modules/screens/settings_view.dart @@ -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 { + 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 { 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 { .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 { ), _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 _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 { .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 { 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 { '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 { 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 { ); } - 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), diff --git a/lib/presentation/modules/widgets/back_office_dialog.dart b/lib/presentation/modules/widgets/back_office_dialog.dart new file mode 100644 index 0000000..d90357c --- /dev/null +++ b/lib/presentation/modules/widgets/back_office_dialog.dart @@ -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 showBackOfficeDialog(BuildContext context) => showDialog( + context: context, + builder: (_) => const _BackOfficeDialog(), + ); + +class _BackOfficeDialog extends ConsumerStatefulWidget { + const _BackOfficeDialog(); + + @override + ConsumerState<_BackOfficeDialog> createState() => _BackOfficeDialogState(); +} + +class _BackOfficeDialogState extends ConsumerState<_BackOfficeDialog> { + final _formKey = GlobalKey(); + + 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 _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( + 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 _mqttFields() => [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: TextFormField( + controller: _host, + decoration: const InputDecoration( + labelText: 'Broker host', + hintText: 'nats.example.com', + ), + validator: (v) => (v == null || v.trim().isEmpty) + ? 'A broker host is required' + : null, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: TextFormField( + controller: _port, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration(labelText: 'Port'), + validator: (v) { + final port = int.tryParse(v?.trim() ?? ''); + return (port == null || port < 1 || port > 65535) + ? '1–65535' + : null; + }, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _username, + decoration: const InputDecoration(labelText: 'Username'), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: TextFormField( + controller: _password, + obscureText: true, + decoration: const InputDecoration(labelText: 'Password'), + ), + ), + ], + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _useTls, + onChanged: (v) => setState(() => _useTls = v), + title: const Text('Use TLS'), + subtitle: const Text( + 'Bills carry customer names and mobile numbers. Turn this off only ' + 'on a closed network you control.', + style: TextStyle(fontSize: 12), + ), + ), + const _Note( + 'Works against NATS with its MQTT gateway enabled, or any MQTT 3.1.1 ' + 'broker. Topics arrive as NATS subjects with "/" replaced by "." — ' + 'see docs/sync-contract.md.', + ), + ]; + + List _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, + ), + ), + ); +} diff --git a/lib/presentation/modules/widgets/promo_editor_dialog.dart b/lib/presentation/modules/widgets/promo_editor_dialog.dart new file mode 100644 index 0000000..803ca23 --- /dev/null +++ b/lib/presentation/modules/widgets/promo_editor_dialog.dart @@ -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 showPromoEditor(BuildContext context, {Promo? existing}) => + showDialog( + 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(); + + 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 _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 _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( + 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( + 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 []; + + return DropdownButtonFormField( + 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), + ), + ), + ], + ); + } +} diff --git a/lib/presentation/modules/widgets/staff_dialogs.dart b/lib/presentation/modules/widgets/staff_dialogs.dart new file mode 100644 index 0000000..01ccec4 --- /dev/null +++ b/lib/presentation/modules/widgets/staff_dialogs.dart @@ -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 showStaffDialog(BuildContext context) => showDialog( + 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 _confirmDeactivate( + BuildContext context, + WidgetRef ref, + StaffUser user, +) async { + final confirmed = await showDialog( + 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 _openEditor( + BuildContext context, + WidgetRef ref, + StaffUser? existing, +) => + showDialog( + 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(); + 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 _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( + 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 showForcedPinChange(BuildContext context) => showDialog( + 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(); + final _pin = TextEditingController(); + final _confirm = TextEditingController(); + bool _saving = false; + String? _error; + + @override + void dispose() { + _pin.dispose(); + _confirm.dispose(); + super.dispose(); + } + + Future _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, + ), + ], + ), + ); + } +} diff --git a/lib/presentation/modules/widgets/store_details_dialog.dart b/lib/presentation/modules/widgets/store_details_dialog.dart new file mode 100644 index 0000000..3f61763 --- /dev/null +++ b/lib/presentation/modules/widgets/store_details_dialog.dart @@ -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 showStoreDetailsDialog(BuildContext context) => showDialog( + context: context, + builder: (_) => const _StoreDetailsDialog(), + ); + +class _StoreDetailsDialog extends ConsumerStatefulWidget { + const _StoreDetailsDialog(); + + @override + ConsumerState<_StoreDetailsDialog> createState() => + _StoreDetailsDialogState(); +} + +class _StoreDetailsDialogState extends ConsumerState<_StoreDetailsDialog> { + final _formKey = GlobalKey(); + 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 _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, + ), + ], + ); + } +} diff --git a/lib/presentation/payment/providers/payment_controller.dart b/lib/presentation/payment/providers/payment_controller.dart index 2a0193e..b9d8218 100644 --- a/lib/presentation/payment/providers/payment_controller.dart +++ b/lib/presentation/payment/providers/payment_controller.dart @@ -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 { // 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 { _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( diff --git a/lib/presentation/pos/providers/cart_controller.dart b/lib/presentation/pos/providers/cart_controller.dart index 5fdb0f3..b9b65a3 100644 --- a/lib/presentation/pos/providers/cart_controller.dart +++ b/lib/presentation/pos/providers/cart_controller.dart @@ -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 { required TransactionRepository transactions, required SoundService sound, required this.onFeedback, + List 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 { 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 _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 _undoStack = []; @@ -105,18 +132,18 @@ class CartController extends StateNotifier { } 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 { } _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 { 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 { 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 { 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 { Future 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 _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, ); diff --git a/lib/presentation/pos/screens/pos_dashboard_screen.dart b/lib/presentation/pos/screens/pos_dashboard_screen.dart index 6b461fa..f2e4c8a 100644 --- a/lib/presentation/pos/screens/pos_dashboard_screen.dart +++ b/lib/presentation/pos/screens/pos_dashboard_screen.dart @@ -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 { 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 { )..attach(); } + Future _maybePromptForPin() async { + if (_promptedForPin || !mounted) return; + if (!ref.read(mustChangePinProvider)) return; + + _promptedForPin = true; + await showForcedPinChange(context); + } + @override void dispose() { _barcode.dispose(); diff --git a/lib/presentation/pos/widgets/billing_panel.dart b/lib/presentation/pos/widgets/billing_panel.dart index 0ecea36..bd11bde 100644 --- a/lib/presentation/pos/widgets/billing_panel.dart +++ b/lib/presentation/pos/widgets/billing_panel.dart @@ -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), diff --git a/lib/presentation/pos/widgets/page_header.dart b/lib/presentation/pos/widgets/page_header.dart index 9ac28c9..01719c1 100644 --- a/lib/presentation/pos/widgets/page_header.dart +++ b/lib/presentation/pos/widgets/page_header.dart @@ -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, diff --git a/lib/presentation/receipt/screens/receipt_screen.dart b/lib/presentation/receipt/screens/receipt_screen.dart index 9e574af..00ba828 100644 --- a/lib/presentation/receipt/screens/receipt_screen.dart +++ b/lib/presentation/receipt/screens/receipt_screen.dart @@ -249,7 +249,7 @@ class _ReceiptScreenState extends ConsumerState { final sent = await ref .read(receiptServiceProvider) .sendToWhatsApp(txn); - if (!context.mounted || sent) return; + if (!mounted || sent) return; ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(const SnackBar( diff --git a/lib/presentation/sync/providers/sync_controller.dart b/lib/presentation/sync/providers/sync_controller.dart index fa49bd3..042c907 100644 --- a/lib/presentation/sync/providers/sync_controller.dart +++ b/lib/presentation/sync/providers/sync_controller.dart @@ -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 { 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 run() async { if (isRunning) { return const SyncOutcome(attempted: 0, uploaded: 0); @@ -174,7 +183,7 @@ class OrderSyncController extends StateNotifier { 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( (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((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(); + } +}); diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 965d77d..033f602 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include @@ -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); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index eb58066..71b2efc 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux + flutter_secure_storage_linux printing url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 8f62591..2fafd91 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) diff --git a/pubspec.lock b/pubspec.lock index 8b86183..19c0b9c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -137,14 +137,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" - crypto: + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "762c99f890ca8bf87f7337236f99edd42793843bc6c3631da294a76653a54bd0" + url: "https://pub.dev" + source: hosted + version: "7.3.1" + connectivity_plus_platform_interface: dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + crypto: + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted version: "3.0.7" + dbus: + dependency: transitive + description: + name: dbus + sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91" + url: "https://pub.dev" + source: hosted + version: "0.7.13" equatable: dependency: "direct main" description: @@ -153,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + event_bus: + dependency: transitive + description: + name: event_bus + sha256: "1a55e97923769c286d295240048fc180e7b0768902c3c2e869fe059aafa15304" + url: "https://pub.dev" + source: hosted + version: "2.0.1" fake_async: dependency: transitive description: @@ -169,6 +201,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -214,6 +254,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" + url: "https://pub.dev" + source: hosted + version: "10.3.1" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" flutter_shaders: dependency: transitive description: @@ -265,7 +353,7 @@ packages: source: hosted version: "2.0.2" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -384,6 +472,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + mqtt_client: + dependency: "direct main" + description: + name: mqtt_client + sha256: "41c8edd3bc8efc80c1c8ebfb40081c24d12d13085faca96b9280a624eca2d893" + url: "https://pub.dev" + source: hosted + version: "10.11.11" native_toolchain_c: dependency: transitive description: @@ -392,6 +488,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.2" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" objective_c: dependency: transitive description: @@ -789,6 +893,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + url: "https://pub.dev" + source: hosted + version: "6.3.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8de34f3..b8a5f78 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/unit/catalogue_sync_test.dart b/test/unit/catalogue_sync_test.dart new file mode 100644 index 0000000..be54c39 --- /dev/null +++ b/test/unit/catalogue_sync_test.dart @@ -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 _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()), + ); + expect( + () => CatalogueWire.productFromJson({ + 'id': 'p1', + 'name': 'X', + 'barcode': '890', + }), + throwsA(isA()), + ); + }); + + 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> pages, { + List? 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 = []; + 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() + .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() + .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() + .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())); + 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()), + ); + }); + }); + + 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 send(http.BaseRequest request) async { + final response = respond(request); + return http.StreamedResponse( + Stream.value(utf8.encode(response.body)), + response.statusCode, + request: request, + ); + } +} diff --git a/test/unit/checkout_test.dart b/test/unit/checkout_test.dart index 1436d06..4ff25f7 100644 --- a/test/unit/checkout_test.dart +++ b/test/unit/checkout_test.dart @@ -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()), ); @@ -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()), ); }); @@ -180,6 +191,7 @@ void main() { PaymentSplit(method: PaymentMethod.cash, amount: 100, tendered: 100), ], cashierName: 'Suriya', + terminalId: 'T0TEST', ), throwsA(isA()), ); @@ -198,6 +210,7 @@ void main() { ), ], cashierName: 'Suriya', + terminalId: 'T0TEST', ), throwsA(isA()), ); @@ -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 } diff --git a/test/unit/fleet_identity_test.dart b/test/unit/fleet_identity_test.dart new file mode 100644 index 0000000..6721419 --- /dev/null +++ b/test/unit/fleet_identity_test.dart @@ -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)); + }); + }); +} diff --git a/test/unit/hardware_config_test.dart b/test/unit/hardware_config_test.dart new file mode 100644 index 0000000..0bfe2a7 --- /dev/null +++ b/test/unit/hardware_config_test.dart @@ -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>(); + + 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); + }); + }); +} diff --git a/test/unit/migration_test.dart b/test/unit/migration_test.dart index 5ecde58..884c5c2 100644 --- a/test/unit/migration_test.dart +++ b/test/unit/migration_test.dart @@ -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); diff --git a/test/unit/persistence_test.dart b/test/unit/persistence_test.dart index 3dd602e..e49d442 100644 --- a/test/unit/persistence_test.dart +++ b/test/unit/persistence_test.dart @@ -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()), ); @@ -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()), ); @@ -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); diff --git a/test/unit/promo_engine_test.dart b/test/unit/promo_engine_test.dart new file mode 100644 index 0000000..8927c75 --- /dev/null +++ b/test/unit/promo_engine_test.dart @@ -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 run(List 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 run(List 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(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(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, + ); + }); + }); +} diff --git a/test/unit/promo_persistence_test.dart b/test/unit/promo_persistence_test.dart new file mode 100644 index 0000000..bb4a679 --- /dev/null +++ b/test/unit/promo_persistence_test.dart @@ -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()), + ); + }); + + 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()), + ); + }); + + 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()), + ); + }); + + 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()), + ); + }); + + 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()), + ); + }); + }); + + 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); + }); + }); +} diff --git a/test/unit/retention_test.dart b/test/unit/retention_test.dart new file mode 100644 index 0000000..f9c16b9 --- /dev/null +++ b/test/unit/retention_test.dart @@ -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 ids) answer; + + int batches = 0; + + @override + String get label => 'Scripted'; + + @override + bool get isConnected => true; + + @override + Stream get downlink => const Stream.empty(); + + @override + Stream get connectionState => const Stream.empty(); + + @override + Future connect() async {} + + @override + Future pushOrders(List> orders) async { + batches++; + return answer(orders.map((o) => o['id']! as String).toList()); + } + + @override + Future 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 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 = []; + 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); + }); + }); +} diff --git a/test/unit/staff_auth_test.dart b/test/unit/staff_auth_test.dart new file mode 100644 index 0000000..68df606 --- /dev/null +++ b/test/unit/staff_auth_test.dart @@ -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()), + 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()), + ); + await expectLater( + staff.create(name: 'X', role: StaffRole.cashier, pin: 'abcd'), + throwsA(isA()), + ); + }); + + 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()), + ); + }); + + 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()), + ); + await expectLater( + staff.updateDetails(id: admin.id, role: StaffRole.cashier), + throwsA(isA()), + ); + + // 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',); + }); + }); +} diff --git a/test/unit/sync_engine_test.dart b/test/unit/sync_engine_test.dart new file mode 100644 index 0000000..1e83d0b --- /dev/null +++ b/test/unit/sync_engine_test.dart @@ -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 scripted = []; + int calls = 0; + int pending = 0; + + /// Completed by the test to hold a drain open, so overlapping triggers can + /// be observed. + Completer? gate; + + @override + Future 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 unsyncedCount() async => pending; + + @override + Future purgeExpired() async => 0; + + @override + bool get hasCatalogue => true; + + @override + DateTime? get lastImportAt => null; + + @override + String? get catalogueRevision => null; + + @override + List get events => const []; + + @override + Future importCatalogue({ + void Function(double progress, String stage)? onProgress, + }) => + throw UnimplementedError(); + + @override + Future> unsyncedOrders() async => const []; + + @override + Future> orderSyncRows({int limit = 200}) async => const []; + + @override + Future 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 delays = []; + final List 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? connectivity, + Stream? 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(); + final engine = build(); + + engine + ..nudge(SyncTrigger.saleCommitted) + ..nudge(SyncTrigger.saleCommitted) + ..nudge(SyncTrigger.saleCommitted); + + await Future.delayed(Duration.zero); + expect(repo.calls, 1, reason: 'three triggers must yield one drain'); + + repo.gate!.complete(); + repo.gate = null; + await Future.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() + ..pending = 3; + final engine = build(); + + engine.nudge(SyncTrigger.startup); + await Future.delayed(Duration.zero); + expect(repo.calls, 1); + + engine.nudge(SyncTrigger.saleCommitted); + repo.gate!.complete(); + repo.gate = null; + + await Future.delayed(Duration.zero); + await Future.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() + ..pending = 0; + final engine = build(); + + engine.nudge(SyncTrigger.startup); + await Future.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 = {}; + 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(); + 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(); + 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(); + // 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(); + 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 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 _settle() async { + for (var i = 0; i < 6; i++) { + await Future.delayed(Duration.zero); + } +} diff --git a/test/unit/transport_test.dart b/test/unit/transport_test.dart new file mode 100644 index 0000000..17c636a --- /dev/null +++ b/test/unit/transport_test.dart @@ -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.delayed(Duration.zero); + transport.handleInbound( + config.ackTopic, + jsonEncode({'batch_id': 'a-different-batch', 'accepted': ['order-a']}), + ); + + await expectLater(pending, throwsA(isA())); + }); + + 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': []}); + 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 = []; + 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.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()), + ); + 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() + .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() + .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 = []; + 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() + .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 _receiptFor( + MqttOrderTransport transport, + Map 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 send(http.BaseRequest request) async { + final response = respond(request); + return http.StreamedResponse( + Stream.value(utf8.encode(response.body)), + response.statusCode, + request: request, + ); + } +} diff --git a/test/widget/admin_dialogs_test.dart b/test/widget/admin_dialogs_test.dart new file mode 100644 index 0000000..031a478 --- /dev/null +++ b/test/widget/admin_dialogs_test.dart @@ -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 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 open( + WidgetTester tester, { + required StaffUser who, + required Future Function(BuildContext) show, + List 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 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.'); +} diff --git a/test/widget/app_smoke_test.dart b/test/widget/app_smoke_test.dart index 346d2bd..9bdb57e 100644 --- a/test/widget/app_smoke_test.dart +++ b/test/widget/app_smoke_test.dart @@ -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); + }); } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 87d9de7..e4b3558 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,12 +7,18 @@ #include "generated_plugin_registrant.h" #include +#include +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { AudioplayersWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); PrintingPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PrintingPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 27a28a3..cd33903 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,8 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_windows + connectivity_plus + flutter_secure_storage_windows printing url_launcher_windows )