diff --git a/docs/sync-contract.md b/docs/sync-contract.md index 33a9207..23add7d 100644 --- a/docs/sync-contract.md +++ b/docs/sync-contract.md @@ -173,9 +173,11 @@ overstate the day. - **Downlink beyond catalogue-changed and sync-requested.** The plumbing routes unknown commands to the events log rather than dropping them, so adding one is a server change plus a case arm. -- **Broker credentials in Settings.** `SyncConfig` carries them and Settings - displays the route, but there is no editor yet — a store is pointed at a - broker in code or by overriding `syncConfigProvider`. +- **Credentials survive a restart.** The back-office dialog writes the broker + host, port, TLS flag and credentials into `syncConfigProvider`, which is + 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 with an overstated total. Nothing here fixes that; it needs a server-side reconciliation against `bill_discount`. diff --git a/lib/core/services/sound_service.dart b/lib/core/services/sound_service.dart index 1f4fdcf..aa3d058 100644 --- a/lib/core/services/sound_service.dart +++ b/lib/core/services/sound_service.dart @@ -16,10 +16,8 @@ class SoundService { static final SoundService instance = SoundService._(); final AudioPlayer _player = AudioPlayer(playerId: 'nearle_pos_sfx'); - bool _enabled = true; - - bool get enabled => _enabled; - set enabled(bool value) => _enabled = value; + /// Muted from Settings when a shop finds the beeps intrusive. + bool enabled = true; Future preload() async { try { @@ -40,7 +38,7 @@ class SoundService { bool haptic = false, bool heavy = false, }) async { - if (!_enabled) return; + if (!enabled) return; // Haptics matter on tablets where the speaker may be muted on the floor. if (heavy) { diff --git a/lib/presentation/modules/screens/settings_view.dart b/lib/presentation/modules/screens/settings_view.dart index 8253065..3055765 100644 --- a/lib/presentation/modules/screens/settings_view.dart +++ b/lib/presentation/modules/screens/settings_view.dart @@ -11,6 +11,7 @@ import '../../../data/local/order_dao.dart'; import '../../../domain/entities/store_account.dart'; import '../../auth/providers/auth_controller.dart'; import '../providers/printer_settings.dart'; +import '../widgets/back_office_dialog.dart'; import '../../sync/providers/sync_controller.dart'; import '../widgets/module_widgets.dart'; @@ -238,7 +239,7 @@ class _SettingsViewState extends ConsumerState { .read(receiptServiceProvider) .printTestPage( printerUrl: settings.printerUrl,); - if (!context.mounted) return; + if (!mounted) return; ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(SnackBar( @@ -321,6 +322,7 @@ class _SettingsViewState extends ConsumerState { final lastImport = ref.watch(lastImportAtProvider); final outstanding = ref.watch(unsyncedCountProvider).value ?? 0; final config = ref.watch(syncConfigProvider); + final terminal = ref.watch(terminalIdentityProvider); final sync = ref.watch(syncEngineStateProvider).value ?? ref.watch(syncEngineProvider).state; @@ -328,6 +330,10 @@ class _SettingsViewState extends ConsumerState { title: 'Connectivity & sync', subtitle: 'Bills are written to this terminal first and uploaded in the ' 'background. Nothing is ever held up waiting for the network.', + action: TextButton( + onPressed: () => showBackOfficeDialog(context), + child: const Text('Configure'), + ), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -336,6 +342,7 @@ class _SettingsViewState extends ConsumerState { 'Last import', lastImport == null ? 'Never' : Formatters.dateTime(lastImport), ), + _row('Terminal', '${terminal.name} · ${terminal.code}'), _row('Route', _transportLabel(config)), _row('Waiting to upload', '$outstanding bill(s)'), _row( @@ -386,26 +393,35 @@ class _SettingsViewState extends ConsumerState { '${config.useTls ? ' (TLS)' : ''}', }; - Widget _aboutCard() => PanelCard( + Widget _aboutCard() { + final terminal = ref.watch(terminalIdentityProvider); + + return PanelCard( title: 'About', child: Column( mainAxisSize: MainAxisSize.min, children: [ - _row('Application', '${AppConstants.appName} 1.0.0'), - _row('Terminal', 'TERM-01'), - _row('Data store', 'SQLite (on device)'), + _row('Application', + '${AppConstants.appName} ${AppConstants.appVersion}',), + _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), SizedBox( width: double.infinity, child: OutlinedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.sync_rounded, size: 17), - label: const Text('Check for updates'), + onPressed: () => showBackOfficeDialog(context), + icon: const Icon(Icons.settings_ethernet_rounded, size: 17), + label: const Text('Back office connection'), ), ), ], ), ); + } Widget _row(String label, String value, {bool mono = false}) => Padding( padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), diff --git a/lib/presentation/modules/widgets/back_office_dialog.dart b/lib/presentation/modules/widgets/back_office_dialog.dart new file mode 100644 index 0000000..5626e1f --- /dev/null +++ b/lib/presentation/modules/widgets/back_office_dialog.dart @@ -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 showBackOfficeDialog(BuildContext context) => showDialog( + context: context, + builder: (_) => const _BackOfficeDialog(), + ); + +class _BackOfficeDialog extends ConsumerStatefulWidget { + const _BackOfficeDialog(); + + @override + ConsumerState<_BackOfficeDialog> createState() => _BackOfficeDialogState(); +} + +class _BackOfficeDialogState extends ConsumerState<_BackOfficeDialog> { + final _formKey = GlobalKey(); + + 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 _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( + 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 _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) + ? '1–65535' + : 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 _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, + ), + ), + ); +} diff --git a/lib/presentation/receipt/screens/receipt_screen.dart b/lib/presentation/receipt/screens/receipt_screen.dart index 9e574af..00ba828 100644 --- a/lib/presentation/receipt/screens/receipt_screen.dart +++ b/lib/presentation/receipt/screens/receipt_screen.dart @@ -249,7 +249,7 @@ class _ReceiptScreenState extends ConsumerState { final sent = await ref .read(receiptServiceProvider) .sendToWhatsApp(txn); - if (!context.mounted || sent) return; + if (!mounted || sent) return; ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(const SnackBar( diff --git a/test/widget/app_smoke_test.dart b/test/widget/app_smoke_test.dart index 8d38e72..8753ba0 100644 --- a/test/widget/app_smoke_test.dart +++ b/test/widget/app_smoke_test.dart @@ -132,4 +132,40 @@ void main() { 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); + }); }