Files
nearle_pos/lib/presentation/modules/widgets/back_office_dialog.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

375 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/config/sync_config.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/primary_button.dart';
/// Points this terminal at a back office, and names it.
///
/// Until this existed a store was wired up by editing `syncConfigProvider` and
/// rebuilding — which is not something a shop can do, and made every terminal
/// in a fleet a separate build.
Future<void> showBackOfficeDialog(BuildContext context) => showDialog<void>(
context: context,
builder: (_) => const _BackOfficeDialog(),
);
class _BackOfficeDialog extends ConsumerStatefulWidget {
const _BackOfficeDialog();
@override
ConsumerState<_BackOfficeDialog> createState() => _BackOfficeDialogState();
}
class _BackOfficeDialogState extends ConsumerState<_BackOfficeDialog> {
final _formKey = GlobalKey<FormState>();
late TransportKind _kind;
late final TextEditingController _terminalName;
late final TextEditingController _storeId;
late final TextEditingController _host;
late final TextEditingController _port;
late final TextEditingController _username;
late final TextEditingController _password;
late final TextEditingController _httpUrl;
late final TextEditingController _apiKey;
late bool _useTls;
bool _saving = false;
@override
void initState() {
super.initState();
final config = ref.read(syncConfigProvider);
final terminal = ref.read(terminalIdentityProvider);
_kind = config.transport;
_useTls = config.useTls;
_terminalName = TextEditingController(text: terminal.name);
_storeId = TextEditingController(text: terminal.storeId);
_host = TextEditingController(text: config.brokerHost);
_port = TextEditingController(text: '${config.brokerPort}');
_username = TextEditingController(text: config.username ?? '');
_password = TextEditingController(text: config.password ?? '');
_httpUrl = TextEditingController(text: config.httpBaseUrl);
_apiKey = TextEditingController(text: config.apiKey ?? '');
}
@override
void dispose() {
for (final c in [
_terminalName,
_storeId,
_host,
_port,
_username,
_password,
_httpUrl,
_apiKey,
]) {
c.dispose();
}
super.dispose();
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() => _saving = true);
final store = ref.read(localStoreProvider);
// Name and store id are the terminal's own, and live in its database — a
// reinstall must not lose which shop this till belongs to.
await store.identityStore.rename(
name: _terminalName.text.trim(),
storeId: _storeId.text.trim(),
);
await store.hydrate();
if (!mounted) return;
// 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.
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();
}
@override
Widget build(BuildContext context) {
final terminal = ref.watch(terminalIdentityProvider);
return AlertDialog(
title: const Text('Back office connection'),
content: SizedBox(
width: 520,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_IdentityBanner(code: terminal.code, deviceId: terminal.deviceId),
const SizedBox(height: AppSpacing.lg),
TextFormField(
controller: _terminalName,
decoration: const InputDecoration(
labelText: 'Terminal name',
helperText: 'What staff call this till, e.g. "Counter 2"',
),
),
const SizedBox(height: AppSpacing.md),
TextFormField(
controller: _storeId,
decoration: const InputDecoration(
labelText: 'Store ID',
helperText: 'Namespaces this shop on the broker',
),
validator: (v) => (v == null || v.trim().isEmpty)
? 'Every terminal must belong to a store'
: null,
),
const SizedBox(height: AppSpacing.lg),
SegmentedButton<TransportKind>(
segments: const [
ButtonSegment(
value: TransportKind.simulated,
label: Text('Offline demo'),
),
ButtonSegment(
value: TransportKind.http,
label: Text('HTTP'),
),
ButtonSegment(
value: TransportKind.mqtt,
label: Text('MQTT'),
),
],
selected: {_kind},
onSelectionChanged: (s) => setState(() => _kind = s.first),
),
const SizedBox(height: AppSpacing.lg),
if (_kind == TransportKind.mqtt) ..._mqttFields(),
if (_kind == TransportKind.http) ..._httpFields(),
if (_kind == TransportKind.simulated)
const _Note(
'Bills queue and drain against a local stub. Nothing '
'leaves this terminal — use it to rehearse a shift '
'without a server.',
),
],
),
),
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
PrimaryButton(
label: 'Save',
expanded: false,
busy: _saving,
onPressed: _save,
),
],
);
}
List<Widget> _mqttFields() => [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: TextFormField(
controller: _host,
decoration: const InputDecoration(
labelText: 'Broker host',
hintText: 'nats.example.com',
),
validator: (v) => (v == null || v.trim().isEmpty)
? 'A broker host is required'
: null,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: TextFormField(
controller: _port,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(labelText: 'Port'),
validator: (v) {
final port = int.tryParse(v?.trim() ?? '');
return (port == null || port < 1 || port > 65535)
? '165535'
: null;
},
),
),
],
),
const SizedBox(height: AppSpacing.md),
Row(
children: [
Expanded(
child: TextFormField(
controller: _username,
decoration: const InputDecoration(labelText: 'Username'),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: TextFormField(
controller: _password,
obscureText: true,
decoration: const InputDecoration(labelText: 'Password'),
),
),
],
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _useTls,
onChanged: (v) => setState(() => _useTls = v),
title: const Text('Use TLS'),
subtitle: const Text(
'Bills carry customer names and mobile numbers. Turn this off only '
'on a closed network you control.',
style: TextStyle(fontSize: 12),
),
),
const _Note(
'Works against NATS with its MQTT gateway enabled, or any MQTT 3.1.1 '
'broker. Topics arrive as NATS subjects with "/" replaced by "." — '
'see docs/sync-contract.md.',
),
];
List<Widget> _httpFields() => [
TextFormField(
controller: _httpUrl,
decoration: const InputDecoration(
labelText: 'Base URL',
hintText: 'https://api.example.com',
helperText: 'Bills are posted to {base}/orders',
),
validator: (v) {
final text = v?.trim() ?? '';
if (text.isEmpty) return 'A base URL is required';
final uri = Uri.tryParse(text);
if (uri == null || !uri.isAbsolute) return 'Not a valid URL';
return null;
},
),
const SizedBox(height: AppSpacing.md),
TextFormField(
controller: _apiKey,
obscureText: true,
decoration: const InputDecoration(
labelText: 'API key',
helperText: 'Sent as a bearer token',
),
),
];
}
/// The two identifiers a support call needs, and neither is editable.
class _IdentityBanner extends StatelessWidget {
const _IdentityBanner({required this.code, required this.deviceId});
final String code;
final String deviceId;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: const BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brSm,
),
child: Row(
children: [
const Icon(Icons.point_of_sale_rounded,
size: 18, color: AppColors.primary,),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Terminal $code',
style: const TextStyle(
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
Text(
'Device $deviceId',
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
),
),
],
),
),
IconButton(
tooltip: 'Copy device ID',
icon: const Icon(Icons.copy_rounded, size: 16),
onPressed: () => Clipboard.setData(ClipboardData(text: deviceId)),
),
],
),
);
}
}
class _Note extends StatelessWidget {
const _Note(this.text);
final String text;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(top: AppSpacing.sm),
child: Text(
text,
style: const TextStyle(
fontSize: 12,
color: AppColors.textTertiary,
height: 1.45,
),
),
);
}