Files
nearle_pos/lib/data/local/sync_config_store.dart
Suriya fdd90f28d9 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>
2026-08-01 13:18:56 +05:30

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