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