Add a back-office setup dialog, and clear the last lints

Settings > Connectivity > Configure now points a terminal at a back office.
Until this existed a store was wired up by editing syncConfigProvider and
rebuilding, which made every terminal in a fleet its own build.

- Transport picker (offline demo / HTTP / MQTT) with only the relevant fields
  shown, validated: an MQTT route with no host is refused rather than silently
  saved, because a terminal pointed at nothing looks exactly like one that is
  merely offline.
- Terminal name and store id are editable and persist to the database. The
  device id and terminal code are shown but not editable, with a copy button —
  they are what a support call needs, and re-coding a till must not orphan the
  bills already written under the old code.
- TLS defaults on, with a note that bills carry customer names and numbers.
- About card now shows the real terminal, device id and store instead of the
  literal TERM-01, and the dead "Check for updates" button is now the entry
  point to this dialog.

Lints cleared, analyzer now reports zero issues:
- SoundService wrapped a plain bool in a getter and setter that did nothing.
- Two post-await guards used context.mounted inside a State, which the
  analyzer cannot relate to the State's own lifetime. Both are now `mounted`.

Tests: 140 -> 141. The new widget test drives the dialog end to end and asserts
that saving an MQTT route with no host keeps the dialog open with the error
visible. Suite run three times clean.

Known gap, documented in docs/sync-contract.md: broker credentials live in
memory and must be re-entered after a restart. Persisting them means
encrypting at rest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-08-01 11:47:11 +05:30
parent 30d6d1080f
commit 33467e6963
6 changed files with 440 additions and 17 deletions

View File

@@ -173,9 +173,11 @@ overstate the day.
- **Downlink beyond catalogue-changed and sync-requested.** The plumbing routes - **Downlink beyond catalogue-changed and sync-requested.** The plumbing routes
unknown commands to the events log rather than dropping them, so adding one unknown commands to the events log rather than dropping them, so adding one
is a server change plus a case arm. is a server change plus a case arm.
- **Broker credentials in Settings.** `SyncConfig` carries them and Settings - **Credentials survive a restart.** The back-office dialog writes the broker
displays the route, but there is no editor yet — a store is pointed at a host, port, TLS flag and credentials into `syncConfigProvider`, which is
broker in code or by overriding `syncConfigProvider`. in-memory. Terminal name and store id persist (they live in the database);
the credentials do not, and must be re-entered after a restart. Persisting
them means encrypting them at rest, which is the next piece of work.
- **Historical correction.** Bills already synced by an older build went up - **Historical correction.** Bills already synced by an older build went up
with an overstated total. Nothing here fixes that; it needs a server-side with an overstated total. Nothing here fixes that; it needs a server-side
reconciliation against `bill_discount`. reconciliation against `bill_discount`.

View File

@@ -16,10 +16,8 @@ class SoundService {
static final SoundService instance = SoundService._(); static final SoundService instance = SoundService._();
final AudioPlayer _player = AudioPlayer(playerId: 'nearle_pos_sfx'); final AudioPlayer _player = AudioPlayer(playerId: 'nearle_pos_sfx');
bool _enabled = true; /// Muted from Settings when a shop finds the beeps intrusive.
bool enabled = true;
bool get enabled => _enabled;
set enabled(bool value) => _enabled = value;
Future<void> preload() async { Future<void> preload() async {
try { try {
@@ -40,7 +38,7 @@ class SoundService {
bool haptic = false, bool haptic = false,
bool heavy = false, bool heavy = false,
}) async { }) async {
if (!_enabled) return; if (!enabled) return;
// Haptics matter on tablets where the speaker may be muted on the floor. // Haptics matter on tablets where the speaker may be muted on the floor.
if (heavy) { if (heavy) {

View File

@@ -11,6 +11,7 @@ import '../../../data/local/order_dao.dart';
import '../../../domain/entities/store_account.dart'; import '../../../domain/entities/store_account.dart';
import '../../auth/providers/auth_controller.dart'; import '../../auth/providers/auth_controller.dart';
import '../providers/printer_settings.dart'; import '../providers/printer_settings.dart';
import '../widgets/back_office_dialog.dart';
import '../../sync/providers/sync_controller.dart'; import '../../sync/providers/sync_controller.dart';
import '../widgets/module_widgets.dart'; import '../widgets/module_widgets.dart';
@@ -238,7 +239,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
.read(receiptServiceProvider) .read(receiptServiceProvider)
.printTestPage( .printTestPage(
printerUrl: settings.printerUrl,); printerUrl: settings.printerUrl,);
if (!context.mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
..hideCurrentSnackBar() ..hideCurrentSnackBar()
..showSnackBar(SnackBar( ..showSnackBar(SnackBar(
@@ -321,6 +322,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
final lastImport = ref.watch(lastImportAtProvider); final lastImport = ref.watch(lastImportAtProvider);
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0; final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
final config = ref.watch(syncConfigProvider); final config = ref.watch(syncConfigProvider);
final terminal = ref.watch(terminalIdentityProvider);
final sync = ref.watch(syncEngineStateProvider).value ?? final sync = ref.watch(syncEngineStateProvider).value ??
ref.watch(syncEngineProvider).state; ref.watch(syncEngineProvider).state;
@@ -328,6 +330,10 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
title: 'Connectivity & sync', title: 'Connectivity & sync',
subtitle: 'Bills are written to this terminal first and uploaded in the ' subtitle: 'Bills are written to this terminal first and uploaded in the '
'background. Nothing is ever held up waiting for the network.', 'background. Nothing is ever held up waiting for the network.',
action: TextButton(
onPressed: () => showBackOfficeDialog(context),
child: const Text('Configure'),
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -336,6 +342,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
'Last import', 'Last import',
lastImport == null ? 'Never' : Formatters.dateTime(lastImport), lastImport == null ? 'Never' : Formatters.dateTime(lastImport),
), ),
_row('Terminal', '${terminal.name} · ${terminal.code}'),
_row('Route', _transportLabel(config)), _row('Route', _transportLabel(config)),
_row('Waiting to upload', '$outstanding bill(s)'), _row('Waiting to upload', '$outstanding bill(s)'),
_row( _row(
@@ -386,26 +393,35 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
'${config.useTls ? ' (TLS)' : ''}', '${config.useTls ? ' (TLS)' : ''}',
}; };
Widget _aboutCard() => PanelCard( Widget _aboutCard() {
final terminal = ref.watch(terminalIdentityProvider);
return PanelCard(
title: 'About', title: 'About',
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_row('Application', '${AppConstants.appName} 1.0.0'), _row('Application',
_row('Terminal', 'TERM-01'), '${AppConstants.appName} ${AppConstants.appVersion}',),
_row('Data store', 'SQLite (on device)'), _row('Terminal', '${terminal.name} (${terminal.code})'),
// The identifier support asks for. Stable for the life of the
// device, and the only thing that ties this till to its history.
_row('Device ID', terminal.deviceId, mono: true),
_row('Store', terminal.storeId),
_row('Data store', 'SQLite (on device, WAL)'),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: () {}, onPressed: () => showBackOfficeDialog(context),
icon: const Icon(Icons.sync_rounded, size: 17), icon: const Icon(Icons.settings_ethernet_rounded, size: 17),
label: const Text('Check for updates'), label: const Text('Back office connection'),
), ),
), ),
], ],
), ),
); );
}
Widget _row(String label, String value, {bool mono = false}) => Padding( Widget _row(String label, String value, {bool mono = false}) => Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),

View File

@@ -0,0 +1,371 @@
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.
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(),
);
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,
),
),
);
}

View File

@@ -249,7 +249,7 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
final sent = await ref final sent = await ref
.read(receiptServiceProvider) .read(receiptServiceProvider)
.sendToWhatsApp(txn); .sendToWhatsApp(txn);
if (!context.mounted || sent) return; if (!mounted || sent) return;
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
..hideCurrentSnackBar() ..hideCurrentSnackBar()
..showSnackBar(const SnackBar( ..showSnackBar(const SnackBar(

View File

@@ -132,4 +132,40 @@ void main() {
expect(tester.takeException(), isNull, reason: 'opening "$label" threw'); expect(tester.takeException(), isNull, reason: 'opening "$label" threw');
} }
}); });
testWidgets('the back office connection dialog opens and validates',
(tester) async {
// The only way a shop can point a till at a broker. Until it existed a
// store was wired up by editing a provider and rebuilding.
tester.view.physicalSize = const Size(1800, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await bootApp(tester);
await signIn(tester);
await tester.tap(find.text('Settings').first);
await settle(tester);
await tester.tap(find.text('Configure').first);
await settle(tester);
// "Back office connection" is both the dialog title and the About card's
// button, so match the dialog itself.
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text('MQTT'), findsOneWidget);
// Switching to MQTT and saving with no host must be refused, not silently
// accepted — a terminal pointed at nothing looks identical to one that is
// simply offline.
await tester.tap(find.text('MQTT'));
await settle(tester);
await tester.tap(find.text('Save'));
await settle(tester);
expect(find.text('A broker host is required'), findsOneWidget);
expect(find.byType(AlertDialog), findsOneWidget,
reason: 'the dialog must stay open on a validation failure',);
expect(tester.takeException(), isNull);
});
} }