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 '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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
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.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 =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user