Open the cash drawer for real, and persist the back-office route
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 <noreply@anthropic.com>
This commit is contained in:
101
lib/core/services/cash_drawer_service.dart
Normal file
101
lib/core/services/cash_drawer_service.dart
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// What happened when the till drawer was asked to open.
|
||||||
|
enum DrawerResult {
|
||||||
|
opened,
|
||||||
|
|
||||||
|
/// No drawer address configured. Not an error — plenty of shops take card
|
||||||
|
/// only, or open the drawer by hand.
|
||||||
|
notConfigured,
|
||||||
|
|
||||||
|
unreachable,
|
||||||
|
refused,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the cash drawer.
|
||||||
|
///
|
||||||
|
/// The drawer is wired to the receipt printer's RJ11 port and fires when the
|
||||||
|
/// printer receives `ESC p m t1 t2`. That is a raw byte sequence, and it cannot
|
||||||
|
/// go through the PDF pipeline — a PDF is rendered by the platform driver,
|
||||||
|
/// which will not pass arbitrary bytes to the device. This used to be a
|
||||||
|
/// `debugPrint`, so the drawer never opened at all.
|
||||||
|
///
|
||||||
|
/// So it goes over the wire instead: nearly every thermal receipt printer with
|
||||||
|
/// a network port listens on 9100 (JetDirect) and forwards whatever arrives
|
||||||
|
/// straight to the print head. Sending five bytes to that socket is the whole
|
||||||
|
/// protocol.
|
||||||
|
///
|
||||||
|
/// A USB-only printer has no such path from Flutter and reports
|
||||||
|
/// [DrawerResult.notConfigured] rather than pretending.
|
||||||
|
class CashDrawerService {
|
||||||
|
CashDrawerService({
|
||||||
|
Future<Socket> Function(String host, int port, {Duration? timeout})? connect,
|
||||||
|
}) : _connect = connect ?? _defaultConnect;
|
||||||
|
|
||||||
|
final Future<Socket> Function(String host, int port, {Duration? timeout})
|
||||||
|
_connect;
|
||||||
|
|
||||||
|
static Future<Socket> _defaultConnect(
|
||||||
|
String host,
|
||||||
|
int port, {
|
||||||
|
Duration? timeout,
|
||||||
|
}) =>
|
||||||
|
Socket.connect(host, port, timeout: timeout ?? const Duration(seconds: 3));
|
||||||
|
|
||||||
|
/// `ESC p 0 25 250` — pin 2, 50ms on, 500ms off.
|
||||||
|
///
|
||||||
|
/// Pin 2 is the near-universal wiring. A drawer on pin 5 wants `27 112 1 …`,
|
||||||
|
/// which is the one thing worth checking if the printer clicks and nothing
|
||||||
|
/// opens.
|
||||||
|
static const List<int> kickCommand = [27, 112, 0, 25, 250];
|
||||||
|
|
||||||
|
/// Short on purpose. This runs while the cashier is taking cash, and a drawer
|
||||||
|
/// that opens three seconds late has already been opened by hand.
|
||||||
|
static const Duration timeout = Duration(seconds: 3);
|
||||||
|
|
||||||
|
Future<DrawerResult> open({String? host, int port = 9100}) async {
|
||||||
|
if (host == null || host.trim().isEmpty) return DrawerResult.notConfigured;
|
||||||
|
|
||||||
|
Socket? socket;
|
||||||
|
try {
|
||||||
|
socket = await _connect(host.trim(), port, timeout: timeout);
|
||||||
|
socket.add(kickCommand);
|
||||||
|
await socket.flush().timeout(timeout);
|
||||||
|
return DrawerResult.opened;
|
||||||
|
} on SocketException catch (e) {
|
||||||
|
debugPrint('Cash drawer at $host:$port unreachable: ${e.message}');
|
||||||
|
return DrawerResult.unreachable;
|
||||||
|
} on TimeoutException {
|
||||||
|
debugPrint('Cash drawer at $host:$port did not accept the kick in time');
|
||||||
|
return DrawerResult.refused;
|
||||||
|
} on Object catch (e) {
|
||||||
|
debugPrint('Cash drawer kick failed: $e');
|
||||||
|
return DrawerResult.refused;
|
||||||
|
} finally {
|
||||||
|
// Never awaited: a printer that accepted the bytes but will not close the
|
||||||
|
// socket must not hold up the sale.
|
||||||
|
unawaited(socket?.close().catchError((_) {}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable outcome, for the Settings test button.
|
||||||
|
extension DrawerResultMessage on DrawerResult {
|
||||||
|
String get message => switch (this) {
|
||||||
|
DrawerResult.opened => 'Drawer opened.',
|
||||||
|
DrawerResult.notConfigured =>
|
||||||
|
'No drawer address set. Enter the receipt printer\'s IP address to '
|
||||||
|
'kick the drawer automatically after a cash sale.',
|
||||||
|
DrawerResult.unreachable =>
|
||||||
|
'Could not reach the printer. Check it is powered on and on the same '
|
||||||
|
'network as this terminal.',
|
||||||
|
DrawerResult.refused =>
|
||||||
|
'The printer accepted the connection but not the command. Check the '
|
||||||
|
'drawer is wired to its RJ11 port.',
|
||||||
|
};
|
||||||
|
|
||||||
|
bool get isSuccess => this == DrawerResult.opened;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'cash_drawer_service.dart';
|
||||||
import 'package:pdf/pdf.dart';
|
import 'package:pdf/pdf.dart';
|
||||||
import 'package:pdf/widgets.dart' as pw;
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
import 'package:printing/printing.dart';
|
import 'package:printing/printing.dart';
|
||||||
@@ -597,26 +599,17 @@ class ReceiptService {
|
|||||||
return b.toString();
|
return b.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ESC/POS drawer kick: `ESC p m t1 t2` on pin 2.
|
/// Opens the till drawer, if one is configured.
|
||||||
///
|
///
|
||||||
/// Most drawers are wired to the printer's RJ11 port and open when the
|
/// Delegates to [CashDrawerService], which talks raw ESC/POS over a socket.
|
||||||
/// printer receives this. It cannot be sent through the PDF pipeline — a
|
/// The PDF pipeline cannot carry the command — a PDF is rendered by the
|
||||||
/// PDF is rendered by the driver, not passed through as bytes — so this
|
/// platform driver, which will not pass arbitrary bytes through to the
|
||||||
/// needs a raw channel to the printer.
|
/// device.
|
||||||
///
|
Future<DrawerResult> openCashDrawer({
|
||||||
/// On desktop with a driver-installed printer there is no raw path from
|
String? host,
|
||||||
/// Flutter, so this stays a no-op. Wire it up when you move to ESC/POS:
|
int port = 9100,
|
||||||
/// send [drawerKickCommand] over the same socket or Bluetooth link that
|
}) =>
|
||||||
/// carries the receipt.
|
CashDrawerService().open(host: host, port: port);
|
||||||
Future<void> openCashDrawer() async {
|
|
||||||
debugPrint(
|
|
||||||
'Cash drawer kick requested — needs a raw ESC/POS channel, '
|
|
||||||
'not the PDF driver. Command: ${drawerKickCommand.join(' ')}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `ESC p 0 25 250` — pin 2, 50ms on, 500ms off.
|
|
||||||
static const List<int> drawerKickCommand = [27, 112, 0, 25, 250];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One GST rate's slice of a bill.
|
/// One GST rate's slice of a bill.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../local/app_database.dart';
|
|||||||
import '../local/catalogue_dao.dart';
|
import '../local/catalogue_dao.dart';
|
||||||
import '../local/order_dao.dart';
|
import '../local/order_dao.dart';
|
||||||
import '../local/staff_dao.dart';
|
import '../local/staff_dao.dart';
|
||||||
|
import '../local/sync_config_store.dart';
|
||||||
import '../local/sync_log_dao.dart';
|
import '../local/sync_log_dao.dart';
|
||||||
import '../local/terminal_identity.dart';
|
import '../local/terminal_identity.dart';
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ class LocalStore {
|
|||||||
late OrderDao orders;
|
late OrderDao orders;
|
||||||
late SyncLogDao syncLog;
|
late SyncLogDao syncLog;
|
||||||
late StaffDao staff;
|
late StaffDao staff;
|
||||||
|
late SyncConfigStore syncConfig;
|
||||||
late TerminalIdentityStore identityStore;
|
late TerminalIdentityStore identityStore;
|
||||||
|
|
||||||
/// Who this till is. Minted on first run, then stable forever.
|
/// Who this till is. Minted on first run, then stable forever.
|
||||||
@@ -52,6 +54,7 @@ class LocalStore {
|
|||||||
orders = OrderDao(AppDatabase.instance.db);
|
orders = OrderDao(AppDatabase.instance.db);
|
||||||
syncLog = SyncLogDao(AppDatabase.instance.db);
|
syncLog = SyncLogDao(AppDatabase.instance.db);
|
||||||
staff = StaffDao(AppDatabase.instance.db);
|
staff = StaffDao(AppDatabase.instance.db);
|
||||||
|
syncConfig = SyncConfigStore(catalogue);
|
||||||
identityStore = TerminalIdentityStore(catalogue);
|
identityStore = TerminalIdentityStore(catalogue);
|
||||||
|
|
||||||
// A terminal with no staff cannot be signed into at all, so this runs
|
// A terminal with no staff cannot be signed into at all, so this runs
|
||||||
|
|||||||
@@ -414,6 +414,19 @@ class MetaKeys {
|
|||||||
static const String storePhone = 'store_phone';
|
static const String storePhone = 'store_phone';
|
||||||
static const String storePlan = 'store_plan';
|
static const String storePlan = 'store_plan';
|
||||||
|
|
||||||
|
/// How this terminal reaches the back office. Non-secret only — the username,
|
||||||
|
/// password and API key go to the platform keystore, not here.
|
||||||
|
static const String syncTransport = 'sync_transport';
|
||||||
|
static const String syncBrokerHost = 'sync_broker_host';
|
||||||
|
static const String syncBrokerPort = 'sync_broker_port';
|
||||||
|
static const String syncUseTls = 'sync_use_tls';
|
||||||
|
static const String syncHttpBaseUrl = 'sync_http_base_url';
|
||||||
|
|
||||||
|
/// Network printer that owns the cash drawer, if it is not the receipt
|
||||||
|
/// printer itself.
|
||||||
|
static const String drawerHost = 'drawer_host';
|
||||||
|
static const String drawerPort = 'drawer_port';
|
||||||
|
|
||||||
/// Printer chosen in Settings. Stored as the printer's `url`, which is what
|
/// Printer chosen in Settings. Stored as the printer's `url`, which is what
|
||||||
/// `Printing.directPrintPdf` needs to target it without a dialog.
|
/// `Printing.directPrintPdf` needs to target it without a dialog.
|
||||||
static const String printerUrl = 'printer_url';
|
static const String printerUrl = 'printer_url';
|
||||||
|
|||||||
@@ -239,6 +239,16 @@ class CatalogueDao {
|
|||||||
return rows.isEmpty ? null : rows.first['value'] as String?;
|
return rows.isEmpty ? null : rows.first['value'] as String?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every stored setting, for support and for tests that assert what is *not*
|
||||||
|
/// in here — credentials, most of all.
|
||||||
|
Future<Map<String, String>> allMeta() async {
|
||||||
|
final rows = await _db.query(Tables.meta);
|
||||||
|
return {
|
||||||
|
for (final row in rows)
|
||||||
|
row['key']! as String: (row['value'] as String?) ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setMeta(String key, String value) async {
|
Future<void> setMeta(String key, String value) async {
|
||||||
await _db.insert(
|
await _db.insert(
|
||||||
Tables.meta,
|
Tables.meta,
|
||||||
|
|||||||
105
lib/data/local/sync_config_store.dart
Normal file
105
lib/data/local/sync_config_store.dart
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
import '../../core/config/sync_config.dart';
|
||||||
|
import 'app_database.dart';
|
||||||
|
import 'catalogue_dao.dart';
|
||||||
|
|
||||||
|
/// Persists how this terminal reaches the back office.
|
||||||
|
///
|
||||||
|
/// Split deliberately across two stores. Which broker, on which port, over TLS
|
||||||
|
/// — that is configuration, and it goes in the database where it can be read
|
||||||
|
/// during support. The username, password and API key are credentials, and go
|
||||||
|
/// to the platform keystore: Keychain on macOS, Credential Manager on Windows,
|
||||||
|
/// the Android Keystore on a tablet.
|
||||||
|
///
|
||||||
|
/// Writing them into SQLite would put them in the same file as the bills, on a
|
||||||
|
/// machine behind a shop counter, readable by anything that can open it.
|
||||||
|
class SyncConfigStore {
|
||||||
|
SyncConfigStore(this._catalogue, {FlutterSecureStorage? secureStorage})
|
||||||
|
: _secure = secureStorage ?? const FlutterSecureStorage();
|
||||||
|
|
||||||
|
final CatalogueDao _catalogue;
|
||||||
|
final FlutterSecureStorage _secure;
|
||||||
|
|
||||||
|
static const _kUsername = 'sync.username';
|
||||||
|
static const _kPassword = 'sync.password';
|
||||||
|
static const _kApiKey = 'sync.api_key';
|
||||||
|
|
||||||
|
/// Reads the stored configuration, falling back to [fallback] per field.
|
||||||
|
///
|
||||||
|
/// The fallback carries the terminal's own store and terminal ids, which are
|
||||||
|
/// never overwritten from here — they belong to the device's identity.
|
||||||
|
Future<SyncConfig> load(SyncConfig fallback) async {
|
||||||
|
final transport = await _catalogue.meta(MetaKeys.syncTransport);
|
||||||
|
final host = await _catalogue.meta(MetaKeys.syncBrokerHost);
|
||||||
|
final port = await _catalogue.meta(MetaKeys.syncBrokerPort);
|
||||||
|
final tls = await _catalogue.meta(MetaKeys.syncUseTls);
|
||||||
|
final httpUrl = await _catalogue.meta(MetaKeys.syncHttpBaseUrl);
|
||||||
|
|
||||||
|
final credentials = await _readCredentials();
|
||||||
|
|
||||||
|
return fallback.copyWith(
|
||||||
|
transport: TransportKind.values
|
||||||
|
.where((k) => k.name == transport)
|
||||||
|
.firstOrNull ??
|
||||||
|
fallback.transport,
|
||||||
|
brokerHost: host ?? fallback.brokerHost,
|
||||||
|
brokerPort: int.tryParse(port ?? '') ?? fallback.brokerPort,
|
||||||
|
useTls: tls == null ? fallback.useTls : tls == '1',
|
||||||
|
httpBaseUrl: httpUrl ?? fallback.httpBaseUrl,
|
||||||
|
username: credentials.username,
|
||||||
|
password: credentials.password,
|
||||||
|
apiKey: credentials.apiKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> save(SyncConfig config) async {
|
||||||
|
await _catalogue.setMeta(MetaKeys.syncTransport, config.transport.name);
|
||||||
|
await _catalogue.setMeta(MetaKeys.syncBrokerHost, config.brokerHost);
|
||||||
|
await _catalogue.setMeta(MetaKeys.syncBrokerPort, '${config.brokerPort}');
|
||||||
|
await _catalogue.setMeta(MetaKeys.syncUseTls, config.useTls ? '1' : '0');
|
||||||
|
await _catalogue.setMeta(MetaKeys.syncHttpBaseUrl, config.httpBaseUrl);
|
||||||
|
|
||||||
|
await _writeSecret(_kUsername, config.username);
|
||||||
|
await _writeSecret(_kPassword, config.password);
|
||||||
|
await _writeSecret(_kApiKey, config.apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<({String? username, String? password, String? apiKey})>
|
||||||
|
_readCredentials() async {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
username: await _secure.read(key: _kUsername),
|
||||||
|
password: await _secure.read(key: _kPassword),
|
||||||
|
apiKey: await _secure.read(key: _kApiKey),
|
||||||
|
);
|
||||||
|
} on Object catch (e) {
|
||||||
|
// No keystore — a headless test host, or a Linux box with no secret
|
||||||
|
// service. The terminal still runs; it just cannot authenticate until
|
||||||
|
// someone re-enters the credentials, which is the safe way to fail.
|
||||||
|
debugPrint('Secure storage unavailable, credentials not loaded: $e');
|
||||||
|
return (username: null, password: null, apiKey: null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _writeSecret(String key, String? value) async {
|
||||||
|
try {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
await _secure.delete(key: key);
|
||||||
|
} else {
|
||||||
|
await _secure.write(key: key, value: value);
|
||||||
|
}
|
||||||
|
} on Object catch (e) {
|
||||||
|
debugPrint('Could not persist $key to secure storage: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wipes stored credentials. Used when a terminal is handed on or re-pointed
|
||||||
|
/// at a different back office.
|
||||||
|
Future<void> clearCredentials() async {
|
||||||
|
for (final key in [_kUsername, _kPassword, _kApiKey]) {
|
||||||
|
await _writeSecret(key, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ class PrinterSettings {
|
|||||||
this.printerName,
|
this.printerName,
|
||||||
this.autoPrint = false,
|
this.autoPrint = false,
|
||||||
this.openDrawer = true,
|
this.openDrawer = true,
|
||||||
|
this.drawerHost,
|
||||||
|
this.drawerPort = 9100,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Target passed to `directPrintPdf`. Null means "use the system default".
|
/// Target passed to `directPrintPdf`. Null means "use the system default".
|
||||||
@@ -31,7 +33,16 @@ class PrinterSettings {
|
|||||||
|
|
||||||
final bool openDrawer;
|
final bool openDrawer;
|
||||||
|
|
||||||
|
/// IP address of the receipt printer the drawer is wired to.
|
||||||
|
///
|
||||||
|
/// Separate from [printerUrl] because that is an opaque platform handle for
|
||||||
|
/// the PDF driver, which cannot carry raw ESC/POS bytes. The drawer kick
|
||||||
|
/// needs a socket, so it needs an address.
|
||||||
|
final String? drawerHost;
|
||||||
|
final int drawerPort;
|
||||||
|
|
||||||
bool get hasPrinter => printerUrl != null;
|
bool get hasPrinter => printerUrl != null;
|
||||||
|
bool get hasDrawer => (drawerHost ?? '').isNotEmpty;
|
||||||
|
|
||||||
PrinterSettings copyWith({
|
PrinterSettings copyWith({
|
||||||
String? printerUrl,
|
String? printerUrl,
|
||||||
@@ -39,12 +50,16 @@ class PrinterSettings {
|
|||||||
bool clearPrinter = false,
|
bool clearPrinter = false,
|
||||||
bool? autoPrint,
|
bool? autoPrint,
|
||||||
bool? openDrawer,
|
bool? openDrawer,
|
||||||
|
String? drawerHost,
|
||||||
|
int? drawerPort,
|
||||||
}) {
|
}) {
|
||||||
return PrinterSettings(
|
return PrinterSettings(
|
||||||
printerUrl: clearPrinter ? null : (printerUrl ?? this.printerUrl),
|
printerUrl: clearPrinter ? null : (printerUrl ?? this.printerUrl),
|
||||||
printerName: clearPrinter ? null : (printerName ?? this.printerName),
|
printerName: clearPrinter ? null : (printerName ?? this.printerName),
|
||||||
autoPrint: autoPrint ?? this.autoPrint,
|
autoPrint: autoPrint ?? this.autoPrint,
|
||||||
openDrawer: openDrawer ?? this.openDrawer,
|
openDrawer: openDrawer ?? this.openDrawer,
|
||||||
|
drawerHost: drawerHost ?? this.drawerHost,
|
||||||
|
drawerPort: drawerPort ?? this.drawerPort,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,6 +84,8 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
|
|||||||
final name = await dao.meta(MetaKeys.printerName);
|
final name = await dao.meta(MetaKeys.printerName);
|
||||||
final autoPrint = await dao.meta(MetaKeys.autoPrint);
|
final autoPrint = await dao.meta(MetaKeys.autoPrint);
|
||||||
final openDrawer = await dao.meta(MetaKeys.openDrawer);
|
final openDrawer = await dao.meta(MetaKeys.openDrawer);
|
||||||
|
final drawerHost = await dao.meta(MetaKeys.drawerHost);
|
||||||
|
final drawerPort = await dao.meta(MetaKeys.drawerPort);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
@@ -77,6 +94,8 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
|
|||||||
printerName: name,
|
printerName: name,
|
||||||
autoPrint: autoPrint == '1',
|
autoPrint: autoPrint == '1',
|
||||||
openDrawer: openDrawer != '0',
|
openDrawer: openDrawer != '0',
|
||||||
|
drawerHost: drawerHost,
|
||||||
|
drawerPort: int.tryParse(drawerPort ?? '') ?? 9100,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +139,18 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
state = state.copyWith(openDrawer: value);
|
state = state.copyWith(openDrawer: value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Points the drawer kick at a printer.
|
||||||
|
///
|
||||||
|
/// A blank host disables it — which is the honest state for a USB printer,
|
||||||
|
/// since there is no raw path to one from Flutter.
|
||||||
|
Future<void> setDrawerAddress(String host, int port) async {
|
||||||
|
final dao = _ref.read(localStoreProvider).catalogue;
|
||||||
|
await dao.setMeta(MetaKeys.drawerHost, host.trim());
|
||||||
|
await dao.setMeta(MetaKeys.drawerPort, '$port');
|
||||||
|
if (!mounted) return;
|
||||||
|
state = state.copyWith(drawerHost: host.trim(), drawerPort: port);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final printerSettingsProvider =
|
final printerSettingsProvider =
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../../app/providers.dart';
|
import '../../../app/providers.dart';
|
||||||
import '../../../core/config/sync_config.dart';
|
import '../../../core/config/sync_config.dart';
|
||||||
import '../../../core/constants/app_constants.dart';
|
import '../../../core/constants/app_constants.dart';
|
||||||
|
import '../../../core/services/cash_drawer_service.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/utils/formatters.dart';
|
import '../../../core/utils/formatters.dart';
|
||||||
@@ -26,15 +27,36 @@ class SettingsView extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsViewState extends ConsumerState<SettingsView> {
|
class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||||
|
final _drawerHost = TextEditingController();
|
||||||
|
final _drawerPort = TextEditingController(text: '9100');
|
||||||
|
bool _testingDrawer = false;
|
||||||
|
bool _loadedDrawerFields = false;
|
||||||
|
|
||||||
bool _scannerSound = true;
|
bool _scannerSound = true;
|
||||||
bool _roundOff = true;
|
bool _roundOff = true;
|
||||||
bool _autoLoyalty = true;
|
bool _autoLoyalty = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_drawerHost.dispose();
|
||||||
|
_drawerPort.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final store = ref.watch(currentStoreProvider);
|
final store = ref.watch(currentStoreProvider);
|
||||||
final user = ref.watch(currentUserProvider);
|
final user = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
|
// Seeded once, from whatever was persisted. Assigning on every build would
|
||||||
|
// fight the cashier for the cursor while they type.
|
||||||
|
final printer = ref.watch(printerSettingsProvider);
|
||||||
|
if (!_loadedDrawerFields && printer.hasDrawer) {
|
||||||
|
_loadedDrawerFields = true;
|
||||||
|
_drawerHost.text = printer.drawerHost ?? '';
|
||||||
|
_drawerPort.text = '${printer.drawerPort}';
|
||||||
|
}
|
||||||
|
|
||||||
return ModulePage(
|
return ModulePage(
|
||||||
children: [
|
children: [
|
||||||
LayoutBuilder(
|
LayoutBuilder(
|
||||||
@@ -290,15 +312,96 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
|||||||
),
|
),
|
||||||
_toggle(
|
_toggle(
|
||||||
'Open cash drawer on cash sales',
|
'Open cash drawer on cash sales',
|
||||||
'Needs a raw ESC/POS link — see the notes in ReceiptService',
|
settings.hasDrawer
|
||||||
|
? 'Kicks the drawer on ${settings.drawerHost} after a cash '
|
||||||
|
'tender'
|
||||||
|
: 'Enter the printer\'s IP address below to enable this',
|
||||||
settings.openDrawer,
|
settings.openDrawer,
|
||||||
controller.setOpenDrawer,
|
settings.hasDrawer ? controller.setOpenDrawer : null,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
_drawerAddressField(settings, controller),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The drawer needs a socket, not the print driver.
|
||||||
|
///
|
||||||
|
/// A PDF is rendered by the platform driver, which will not pass raw ESC/POS
|
||||||
|
/// bytes through to the device — so the printer's own address is the only way
|
||||||
|
/// to reach the drawer wired to its RJ11 port.
|
||||||
|
Widget _drawerAddressField(
|
||||||
|
PrinterSettings settings,
|
||||||
|
PrinterSettingsController controller,
|
||||||
|
) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _drawerHost,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Printer IP address',
|
||||||
|
hintText: '192.168.1.50',
|
||||||
|
helperText: 'Leave blank for a USB printer',
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
SizedBox(
|
||||||
|
width: 84,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _drawerPort,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(labelText: 'Port', isDense: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: AppSpacing.xs),
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: _testingDrawer
|
||||||
|
? null
|
||||||
|
: () => _saveAndTestDrawer(controller),
|
||||||
|
child: _testingDrawer
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Test'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveAndTestDrawer(PrinterSettingsController controller) async {
|
||||||
|
setState(() => _testingDrawer = true);
|
||||||
|
|
||||||
|
final port = int.tryParse(_drawerPort.text.trim()) ?? 9100;
|
||||||
|
await controller.setDrawerAddress(_drawerHost.text, port);
|
||||||
|
|
||||||
|
final result = await ref.read(receiptServiceProvider).openCashDrawer(
|
||||||
|
host: _drawerHost.text,
|
||||||
|
port: port,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _testingDrawer = false);
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(SnackBar(
|
||||||
|
backgroundColor:
|
||||||
|
result.isSuccess ? AppColors.success : AppColors.danger,
|
||||||
|
content: Text(result.message),
|
||||||
|
),);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _staffCard(StoreAccount? store, StaffUser? current) => PanelCard(
|
Widget _staffCard(StoreAccount? store, StaffUser? current) => PanelCard(
|
||||||
title: 'Users & roles',
|
title: 'Users & roles',
|
||||||
action: TextButton(
|
action: TextButton(
|
||||||
|
|||||||
@@ -95,20 +95,23 @@ class _BackOfficeDialogState extends ConsumerState<_BackOfficeDialog> {
|
|||||||
// Deliberately left out of the identity store: credentials belong to the
|
// Deliberately left out of the identity store: credentials belong to the
|
||||||
// route, not to the machine, and re-pointing a terminal should not rewrite
|
// route, not to the machine, and re-pointing a terminal should not rewrite
|
||||||
// who it is.
|
// who it is.
|
||||||
ref.read(syncConfigProvider.notifier).state =
|
final next = ref.read(syncConfigProvider).copyWith(
|
||||||
ref.read(syncConfigProvider).copyWith(
|
transport: _kind,
|
||||||
transport: _kind,
|
storeId: _storeId.text.trim(),
|
||||||
storeId: _storeId.text.trim(),
|
brokerHost: _host.text.trim(),
|
||||||
brokerHost: _host.text.trim(),
|
brokerPort: int.tryParse(_port.text.trim()) ?? 8883,
|
||||||
brokerPort: int.tryParse(_port.text.trim()) ?? 8883,
|
useTls: _useTls,
|
||||||
useTls: _useTls,
|
username: _username.text.trim().isEmpty ? null : _username.text.trim(),
|
||||||
username: _username.text.trim().isEmpty
|
password: _password.text.isEmpty ? null : _password.text,
|
||||||
? null
|
httpBaseUrl: _httpUrl.text.trim(),
|
||||||
: _username.text.trim(),
|
apiKey: _apiKey.text.trim().isEmpty ? null : _apiKey.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();
|
if (mounted) Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../../../domain/entities/transaction.dart';
|
|||||||
import '../../../domain/usecases/checkout_sale.dart';
|
import '../../../domain/usecases/checkout_sale.dart';
|
||||||
import '../../auth/providers/auth_controller.dart';
|
import '../../auth/providers/auth_controller.dart';
|
||||||
import '../../pos/providers/cart_controller.dart';
|
import '../../pos/providers/cart_controller.dart';
|
||||||
|
import '../../modules/providers/printer_settings.dart';
|
||||||
import '../../pos/providers/catalog_providers.dart';
|
import '../../pos/providers/catalog_providers.dart';
|
||||||
import '../../sync/providers/sync_controller.dart';
|
import '../../sync/providers/sync_controller.dart';
|
||||||
|
|
||||||
@@ -191,7 +192,18 @@ class PaymentController extends StateNotifier<PaymentState> {
|
|||||||
// No auto-print: with no roll printer attached this silently failed and
|
// No auto-print: with no roll printer attached this silently failed and
|
||||||
// looked like a bug. The receipt screen shows the bill on the terminal
|
// looked like a bug. The receipt screen shows the bill on the terminal
|
||||||
// and offers Print, WhatsApp and Share explicitly.
|
// and offers Print, WhatsApp and Share explicitly.
|
||||||
unawaited(_ref.read(receiptServiceProvider).openCashDrawer());
|
// Only for cash. A card-only sale that pops the drawer is a shrinkage
|
||||||
|
// risk, and it is what a shop notices first.
|
||||||
|
final printer = _ref.read(printerSettingsProvider);
|
||||||
|
final tookCash = splits.any((p) => p.method == PaymentMethod.cash);
|
||||||
|
if (printer.openDrawer && printer.hasDrawer && tookCash) {
|
||||||
|
unawaited(
|
||||||
|
_ref.read(receiptServiceProvider).openCashDrawer(
|
||||||
|
host: printer.drawerHost,
|
||||||
|
port: printer.drawerPort,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
unawaited(_ref.read(soundServiceProvider).saleComplete());
|
unawaited(_ref.read(soundServiceProvider).saleComplete());
|
||||||
|
|
||||||
// Stock changed, so the grid must refresh; the new order changes the
|
// Stock changed, so the grid must refresh; the new order changes the
|
||||||
|
|||||||
@@ -211,6 +211,15 @@ final orderSyncProvider =
|
|||||||
/// Overridden to a no-op in widget tests, which have no network stack and
|
/// Overridden to a no-op in widget tests, which have no network stack and
|
||||||
/// cannot drive real disk I/O on a fake clock.
|
/// cannot drive real disk I/O on a fake clock.
|
||||||
final syncBootstrapProvider = FutureProvider<void>((ref) async {
|
final syncBootstrapProvider = FutureProvider<void>((ref) async {
|
||||||
|
// Restore the route this terminal was pointed at. Without this the settings
|
||||||
|
// are written on Save and then silently ignored on the next launch, which
|
||||||
|
// reads exactly like they never saved.
|
||||||
|
final store = ref.read(localStoreProvider);
|
||||||
|
if (store.isReady) {
|
||||||
|
ref.read(syncConfigProvider.notifier).state =
|
||||||
|
await store.syncConfig.load(ref.read(syncConfigProvider));
|
||||||
|
}
|
||||||
|
|
||||||
await ref.read(connectivityServiceProvider).start();
|
await ref.read(connectivityServiceProvider).start();
|
||||||
|
|
||||||
final engine = ref.read(syncEngineProvider);
|
final engine = ref.read(syncEngineProvider);
|
||||||
|
|||||||
156
test/unit/hardware_config_test.dart
Normal file
156
test/unit/hardware_config_test.dart
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:nearle_pos/core/config/sync_config.dart';
|
||||||
|
import 'package:nearle_pos/core/services/cash_drawer_service.dart';
|
||||||
|
import 'package:nearle_pos/data/datasources/local_store.dart';
|
||||||
|
import 'package:nearle_pos/data/datasources/seed_data.dart';
|
||||||
|
import 'package:nearle_pos/data/local/sync_config_store.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(() {
|
||||||
|
LocalStore.registerSeed(
|
||||||
|
products: SeedData.products,
|
||||||
|
customers: SeedData.customers,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('cash drawer', () {
|
||||||
|
test('sends the ESC/POS kick to the printer', () async {
|
||||||
|
// Previously a debugPrint, so the drawer never opened. The PDF pipeline
|
||||||
|
// cannot carry these bytes — the platform driver renders a document, it
|
||||||
|
// does not pass raw commands through — so this goes over a socket.
|
||||||
|
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
final received = Completer<List<int>>();
|
||||||
|
|
||||||
|
server.listen((socket) {
|
||||||
|
socket.listen((bytes) {
|
||||||
|
if (!received.isCompleted) received.complete(bytes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
final result = await CashDrawerService()
|
||||||
|
.open(host: server.address.address, port: server.port);
|
||||||
|
|
||||||
|
expect(result, DrawerResult.opened);
|
||||||
|
expect(await received.future, CashDrawerService.kickCommand);
|
||||||
|
// ESC p 0 25 250 — pin 2, the near-universal wiring.
|
||||||
|
expect(CashDrawerService.kickCommand, [27, 112, 0, 25, 250]);
|
||||||
|
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a blank address is not an error', () async {
|
||||||
|
// Plenty of shops take card only, or open the drawer by hand. A USB
|
||||||
|
// printer also has no raw path from Flutter, so this is the honest state
|
||||||
|
// rather than a failure to report.
|
||||||
|
expect(await CashDrawerService().open(), DrawerResult.notConfigured);
|
||||||
|
expect(
|
||||||
|
await CashDrawerService().open(host: ' '),
|
||||||
|
DrawerResult.notConfigured,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unreachable printer reports it instead of hanging the sale',
|
||||||
|
() async {
|
||||||
|
// Port 1 on loopback refuses immediately.
|
||||||
|
final result =
|
||||||
|
await CashDrawerService().open(host: '127.0.0.1', port: 1);
|
||||||
|
|
||||||
|
expect(result, DrawerResult.unreachable);
|
||||||
|
expect(result.isSuccess, isFalse);
|
||||||
|
expect(result.message, contains('Could not reach'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every outcome explains itself to the cashier', () {
|
||||||
|
for (final result in DrawerResult.values) {
|
||||||
|
expect(result.message, isNotEmpty);
|
||||||
|
}
|
||||||
|
expect(DrawerResult.opened.isSuccess, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('sync configuration', () {
|
||||||
|
late LocalStore store;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
store = LocalStore.instance;
|
||||||
|
await store.reset(withCatalogue: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the route survives a restart', () async {
|
||||||
|
// Held only in memory these had to be retyped after every restart, which
|
||||||
|
// on a shop-floor terminal means they end up on a sticky note instead.
|
||||||
|
const configured = SyncConfig(
|
||||||
|
transport: TransportKind.mqtt,
|
||||||
|
brokerHost: 'nats.example.com',
|
||||||
|
brokerPort: 1883,
|
||||||
|
useTls: false,
|
||||||
|
storeId: 'store-77',
|
||||||
|
terminalId: 'T9A1',
|
||||||
|
);
|
||||||
|
|
||||||
|
await store.syncConfig.save(configured);
|
||||||
|
|
||||||
|
// A fresh store object, as if the app had been relaunched.
|
||||||
|
final reloaded = await SyncConfigStore(store.catalogue)
|
||||||
|
.load(const SyncConfig(storeId: 'store-77', terminalId: 'T9A1'));
|
||||||
|
|
||||||
|
expect(reloaded.transport, TransportKind.mqtt);
|
||||||
|
expect(reloaded.brokerHost, 'nats.example.com');
|
||||||
|
expect(reloaded.brokerPort, 1883);
|
||||||
|
expect(reloaded.useTls, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no credential is written to the database', () async {
|
||||||
|
// The whole reason they go to the platform keystore. SQLite holds the
|
||||||
|
// bills, on a machine behind a shop counter.
|
||||||
|
await store.syncConfig.save(const SyncConfig(
|
||||||
|
transport: TransportKind.mqtt,
|
||||||
|
brokerHost: 'nats.example.com',
|
||||||
|
username: 'till-04',
|
||||||
|
password: 'sup3rs3cret',
|
||||||
|
apiKey: 'ak_live_9f21',
|
||||||
|
),);
|
||||||
|
|
||||||
|
final rows = await store.catalogue.allMeta();
|
||||||
|
final everything = rows.entries.map((e) => '${e.key}=${e.value}').join('|');
|
||||||
|
|
||||||
|
expect(everything, isNot(contains('sup3rs3cret')));
|
||||||
|
expect(everything, isNot(contains('ak_live_9f21')));
|
||||||
|
// Non-secret settings are expected to be there — that is the point of
|
||||||
|
// the split.
|
||||||
|
expect(everything, contains('nats.example.com'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the terminal identity is never overwritten by a saved route',
|
||||||
|
() async {
|
||||||
|
// Store and terminal ids belong to the device. Re-pointing a till at a
|
||||||
|
// different broker must not change who it is, or its bills and its
|
||||||
|
// presence records stop lining up.
|
||||||
|
await store.syncConfig.save(const SyncConfig(
|
||||||
|
transport: TransportKind.http,
|
||||||
|
httpBaseUrl: 'https://api.example.com',
|
||||||
|
storeId: 'wrong-store',
|
||||||
|
terminalId: 'WRONG',
|
||||||
|
),);
|
||||||
|
|
||||||
|
final reloaded = await SyncConfigStore(store.catalogue).load(
|
||||||
|
const SyncConfig(storeId: 'store-01', terminalId: 'T4A9'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reloaded.storeId, 'store-01');
|
||||||
|
expect(reloaded.terminalId, 'T4A9');
|
||||||
|
expect(reloaded.httpBaseUrl, 'https://api.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unconfigured terminal falls back rather than failing', () async {
|
||||||
|
final loaded = await SyncConfigStore(store.catalogue)
|
||||||
|
.load(const SyncConfig());
|
||||||
|
|
||||||
|
expect(loaded.transport, TransportKind.simulated);
|
||||||
|
expect(loaded.isConfigured, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user