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:
Suriya
2026-08-01 13:18:56 +05:30
parent 3513281a11
commit fdd90f28d9
12 changed files with 575 additions and 36 deletions

View File

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

View File

@@ -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<void> openCashDrawer() async {
debugPrint(
'Cash drawer kick requested — needs a raw ESC/POS channel, '
'not the PDF driver. Command: ${drawerKickCommand.join(' ')}',
);
}
/// `ESC p 0 25 250` — pin 2, 50ms on, 500ms off.
static const List<int> drawerKickCommand = [27, 112, 0, 25, 250];
/// Delegates to [CashDrawerService], which talks raw ESC/POS over a socket.
/// The PDF pipeline cannot carry the command — a PDF is rendered by the
/// platform driver, which will not pass arbitrary bytes through to the
/// device.
Future<DrawerResult> openCashDrawer({
String? host,
int port = 9100,
}) =>
CashDrawerService().open(host: host, port: port);
}
/// One GST rate's slice of a bill.

View File

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

View File

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

View File

@@ -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<Map<String, String>> allMeta() async {
final rows = await _db.query(Tables.meta);
return {
for (final row in rows)
row['key']! as String: (row['value'] as String?) ?? '',
};
}
Future<void> setMeta(String key, String value) async {
await _db.insert(
Tables.meta,

View File

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

View File

@@ -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<PrinterSettings> {
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<PrinterSettings> {
printerName: name,
autoPrint: autoPrint == '1',
openDrawer: openDrawer != '0',
drawerHost: drawerHost,
drawerPort: int.tryParse(drawerPort ?? '') ?? 9100,
);
}
@@ -120,6 +139,18 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
if (!mounted) return;
state = state.copyWith(openDrawer: value);
}
/// Points the drawer kick at a printer.
///
/// A blank host disables it — which is the honest state for a USB printer,
/// since there is no raw path to one from Flutter.
Future<void> setDrawerAddress(String host, int port) async {
final dao = _ref.read(localStoreProvider).catalogue;
await dao.setMeta(MetaKeys.drawerHost, host.trim());
await dao.setMeta(MetaKeys.drawerPort, '$port');
if (!mounted) return;
state = state.copyWith(drawerHost: host.trim(), drawerPort: port);
}
}
final printerSettingsProvider =

View File

@@ -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<SettingsView> {
final _drawerHost = TextEditingController();
final _drawerPort = TextEditingController(text: '9100');
bool _testingDrawer = false;
bool _loadedDrawerFields = false;
bool _scannerSound = true;
bool _roundOff = true;
bool _autoLoyalty = true;
@override
void dispose() {
_drawerHost.dispose();
_drawerPort.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final store = ref.watch(currentStoreProvider);
final user = ref.watch(currentUserProvider);
// Seeded once, from whatever was persisted. Assigning on every build would
// fight the cashier for the cursor while they type.
final printer = ref.watch(printerSettingsProvider);
if (!_loadedDrawerFields && printer.hasDrawer) {
_loadedDrawerFields = true;
_drawerHost.text = printer.drawerHost ?? '';
_drawerPort.text = '${printer.drawerPort}';
}
return ModulePage(
children: [
LayoutBuilder(
@@ -290,15 +312,96 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
),
_toggle(
'Open cash drawer on cash sales',
'Needs a raw ESC/POS link — see the notes in ReceiptService',
settings.hasDrawer
? 'Kicks the drawer on ${settings.drawerHost} after a cash '
'tender'
: 'Enter the printer\'s IP address below to enable this',
settings.openDrawer,
controller.setOpenDrawer,
settings.hasDrawer ? controller.setOpenDrawer : null,
),
const SizedBox(height: AppSpacing.sm),
_drawerAddressField(settings, controller),
],
),
);
}
/// The drawer needs a socket, not the print driver.
///
/// A PDF is rendered by the platform driver, which will not pass raw ESC/POS
/// bytes through to the device — so the printer's own address is the only way
/// to reach the drawer wired to its RJ11 port.
Widget _drawerAddressField(
PrinterSettings settings,
PrinterSettingsController controller,
) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: TextFormField(
controller: _drawerHost,
decoration: const InputDecoration(
labelText: 'Printer IP address',
hintText: '192.168.1.50',
helperText: 'Leave blank for a USB printer',
isDense: true,
),
),
),
const SizedBox(width: AppSpacing.sm),
SizedBox(
width: 84,
child: TextFormField(
controller: _drawerPort,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Port', isDense: true),
),
),
const SizedBox(width: AppSpacing.sm),
Padding(
padding: const EdgeInsets.only(top: AppSpacing.xs),
child: OutlinedButton(
onPressed: _testingDrawer
? null
: () => _saveAndTestDrawer(controller),
child: _testingDrawer
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Test'),
),
),
],
);
}
Future<void> _saveAndTestDrawer(PrinterSettingsController controller) async {
setState(() => _testingDrawer = true);
final port = int.tryParse(_drawerPort.text.trim()) ?? 9100;
await controller.setDrawerAddress(_drawerHost.text, port);
final result = await ref.read(receiptServiceProvider).openCashDrawer(
host: _drawerHost.text,
port: port,
);
if (!mounted) return;
setState(() => _testingDrawer = false);
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
backgroundColor:
result.isSuccess ? AppColors.success : AppColors.danger,
content: Text(result.message),
),);
}
Widget _staffCard(StoreAccount? store, StaffUser? current) => PanelCard(
title: 'Users & roles',
action: TextButton(

View File

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

View File

@@ -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<PaymentState> {
// No auto-print: with no roll printer attached this silently failed and
// looked like a bug. The receipt screen shows the bill on the terminal
// and offers Print, WhatsApp and Share explicitly.
unawaited(_ref.read(receiptServiceProvider).openCashDrawer());
// Only for cash. A card-only sale that pops the drawer is a shrinkage
// risk, and it is what a shop notices first.
final printer = _ref.read(printerSettingsProvider);
final tookCash = splits.any((p) => p.method == PaymentMethod.cash);
if (printer.openDrawer && printer.hasDrawer && tookCash) {
unawaited(
_ref.read(receiptServiceProvider).openCashDrawer(
host: printer.drawerHost,
port: printer.drawerPort,
),
);
}
unawaited(_ref.read(soundServiceProvider).saleComplete());
// Stock changed, so the grid must refresh; the new order changes the

View File

@@ -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<void>((ref) async {
// Restore the route this terminal was pointed at. Without this the settings
// are written on Save and then silently ignored on the next launch, which
// reads exactly like they never saved.
final store = ref.read(localStoreProvider);
if (store.isReady) {
ref.read(syncConfigProvider.notifier).state =
await store.syncConfig.load(ref.read(syncConfigProvider));
}
await ref.read(connectivityServiceProvider).start();
final engine = ref.read(syncEngineProvider);