From fdd90f28d96b9f58a9ae0e1a4bbf22241a8704fe Mon Sep 17 00:00:00 2001 From: Suriya Date: Sat, 1 Aug 2026 13:18:56 +0530 Subject: [PATCH] Open the cash drawer for real, and persist the back-office route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cash drawer - openCashDrawer was a debugPrint. The drawer never opened. - It cannot go through the PDF pipeline: a PDF is rendered by the platform driver, which will not pass raw ESC/POS bytes to the device. So it goes over a socket instead — nearly every network thermal printer listens on 9100 and forwards whatever arrives straight to the print head, which makes the whole protocol five bytes. - Printer IP and port are configurable in Settings with a Test button that saves and fires immediately, because a drawer that does not open is indistinguishable from one that is not wired up. - Every failure explains itself: unreachable, refused, or simply not configured — which is the honest state for a USB printer, since there is no raw path to one from Flutter. - Now fires only on a cash tender. A card-only sale that pops the drawer is a shrinkage risk, and it is the first thing a shop notices. Back-office route - Host, port, TLS and transport persist to the database; username, password and API key go to the platform keystore (Keychain / Credential Manager / Android Keystore). Writing credentials into SQLite would put them in the same file as the bills, on a machine behind a shop counter. - Loaded at startup. Previously the dialog wrote settings that were silently ignored on the next launch, which reads exactly like they never saved — and credentials retyped every morning end up on a sticky note instead. - A saved route never overwrites the terminal's store or terminal id. Those belong to the device, and re-pointing a till at a different broker must not change who it is, or its bills and presence records stop lining up. Tests: 168 -> 176. The drawer test stands up a real socket server and asserts the exact bytes arrive. The config test asserts no credential appears anywhere in the meta table while the non-secret settings do. Co-Authored-By: Claude Opus 5 --- lib/core/services/cash_drawer_service.dart | 101 ++++++++++++ lib/core/services/receipt_service.dart | 31 ++-- lib/data/datasources/local_store.dart | 3 + lib/data/local/app_database.dart | 13 ++ lib/data/local/catalogue_dao.dart | 10 ++ lib/data/local/sync_config_store.dart | 105 ++++++++++++ .../modules/providers/printer_settings.dart | 31 ++++ .../modules/screens/settings_view.dart | 107 +++++++++++- .../modules/widgets/back_office_dialog.dart | 31 ++-- .../payment/providers/payment_controller.dart | 14 +- .../sync/providers/sync_controller.dart | 9 + test/unit/hardware_config_test.dart | 156 ++++++++++++++++++ 12 files changed, 575 insertions(+), 36 deletions(-) create mode 100644 lib/core/services/cash_drawer_service.dart create mode 100644 lib/data/local/sync_config_store.dart create mode 100644 test/unit/hardware_config_test.dart 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/receipt_service.dart b/lib/core/services/receipt_service.dart index 12496fd..6bf25fd 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'; @@ -597,26 +599,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/data/datasources/local_store.dart b/lib/data/datasources/local_store.dart index b5da0b6..3ce702f 100644 --- a/lib/data/datasources/local_store.dart +++ b/lib/data/datasources/local_store.dart @@ -5,6 +5,7 @@ import '../local/app_database.dart'; import '../local/catalogue_dao.dart'; import '../local/order_dao.dart'; import '../local/staff_dao.dart'; +import '../local/sync_config_store.dart'; import '../local/sync_log_dao.dart'; import '../local/terminal_identity.dart'; @@ -23,6 +24,7 @@ class LocalStore { late OrderDao orders; late SyncLogDao syncLog; late StaffDao staff; + late SyncConfigStore syncConfig; late TerminalIdentityStore identityStore; /// Who this till is. Minted on first run, then stable forever. @@ -52,6 +54,7 @@ class LocalStore { orders = OrderDao(AppDatabase.instance.db); syncLog = SyncLogDao(AppDatabase.instance.db); staff = StaffDao(AppDatabase.instance.db); + syncConfig = SyncConfigStore(catalogue); identityStore = TerminalIdentityStore(catalogue); // A terminal with no staff cannot be signed into at all, so this runs diff --git a/lib/data/local/app_database.dart b/lib/data/local/app_database.dart index 445bcb6..d01b3f6 100644 --- a/lib/data/local/app_database.dart +++ b/lib/data/local/app_database.dart @@ -414,6 +414,19 @@ class MetaKeys { 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..573e4b9 100644 --- a/lib/data/local/catalogue_dao.dart +++ b/lib/data/local/catalogue_dao.dart @@ -239,6 +239,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/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/presentation/modules/providers/printer_settings.dart b/lib/presentation/modules/providers/printer_settings.dart index c4be353..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, ); } } @@ -69,6 +84,8 @@ class PrinterSettingsController extends StateNotifier { 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; @@ -77,6 +94,8 @@ class PrinterSettingsController extends StateNotifier { printerName: name, autoPrint: autoPrint == '1', openDrawer: openDrawer != '0', + drawerHost: drawerHost, + drawerPort: int.tryParse(drawerPort ?? '') ?? 9100, ); } @@ -120,6 +139,18 @@ class PrinterSettingsController extends StateNotifier { 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/settings_view.dart b/lib/presentation/modules/screens/settings_view.dart index 690313a..ab161b3 100644 --- a/lib/presentation/modules/screens/settings_view.dart +++ b/lib/presentation/modules/screens/settings_view.dart @@ -4,6 +4,7 @@ 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'; @@ -26,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( @@ -290,15 +312,96 @@ 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( diff --git a/lib/presentation/modules/widgets/back_office_dialog.dart b/lib/presentation/modules/widgets/back_office_dialog.dart index 5626e1f..d90357c 100644 --- a/lib/presentation/modules/widgets/back_office_dialog.dart +++ b/lib/presentation/modules/widgets/back_office_dialog.dart @@ -95,20 +95,23 @@ class _BackOfficeDialogState extends ConsumerState<_BackOfficeDialog> { // 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. - ref.read(syncConfigProvider.notifier).state = - 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(), - ); + 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(); } diff --git a/lib/presentation/payment/providers/payment_controller.dart b/lib/presentation/payment/providers/payment_controller.dart index fdc6680..b9d8218 100644 --- a/lib/presentation/payment/providers/payment_controller.dart +++ b/lib/presentation/payment/providers/payment_controller.dart @@ -9,6 +9,7 @@ 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'; @@ -191,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 diff --git a/lib/presentation/sync/providers/sync_controller.dart b/lib/presentation/sync/providers/sync_controller.dart index cd926d8..042c907 100644 --- a/lib/presentation/sync/providers/sync_controller.dart +++ b/lib/presentation/sync/providers/sync_controller.dart @@ -211,6 +211,15 @@ final orderSyncProvider = /// 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); 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); + }); + }); +}