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>
106 lines
4.1 KiB
Dart
106 lines
4.1 KiB
Dart
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);
|
|
}
|
|
}
|
|
}
|