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>
499 lines
18 KiB
Dart
499 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../../app/providers.dart';
|
|
import '../../../core/config/sync_config.dart';
|
|
import '../../../core/constants/app_constants.dart';
|
|
import '../../../core/theme/app_colors.dart';
|
|
import '../../../core/theme/app_dimens.dart';
|
|
import '../../../core/utils/formatters.dart';
|
|
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';
|
|
|
|
/// Terminal and store configuration.
|
|
class SettingsView extends ConsumerStatefulWidget {
|
|
const SettingsView({super.key});
|
|
|
|
@override
|
|
ConsumerState<SettingsView> createState() => _SettingsViewState();
|
|
}
|
|
|
|
class _SettingsViewState extends ConsumerState<SettingsView> {
|
|
bool _scannerSound = true;
|
|
bool _roundOff = true;
|
|
bool _autoLoyalty = true;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final store = ref.watch(currentStoreProvider);
|
|
final user = ref.watch(currentUserProvider);
|
|
|
|
return ModulePage(
|
|
children: [
|
|
LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final wide = constraints.maxWidth >= 1040;
|
|
|
|
final left = Column(
|
|
children: [
|
|
_storeCard(store),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
_taxCard(),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
_loyaltyCard(),
|
|
],
|
|
);
|
|
final right = Column(
|
|
children: [
|
|
_hardwareCard(),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
_connectivityCard(),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
_staffCard(store, user),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
_aboutCard(),
|
|
],
|
|
);
|
|
|
|
if (!wide) {
|
|
return Column(
|
|
children: [left, const SizedBox(height: AppSpacing.lg), right],
|
|
);
|
|
}
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(child: left),
|
|
const SizedBox(width: AppSpacing.lg),
|
|
Expanded(child: right),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _storeCard(StoreAccount? store) => PanelCard(
|
|
title: 'Store details',
|
|
subtitle: 'Printed on every invoice',
|
|
action: TextButton(onPressed: () {}, child: const Text('Edit')),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_row('Store name', store?.name ?? AppConstants.storeName),
|
|
_row('Address', store?.address ?? AppConstants.storeAddress),
|
|
_row('GSTIN', store?.gstin ?? AppConstants.storeGstin, mono: true),
|
|
_row('Phone', store?.phone ?? AppConstants.storePhone, mono: true),
|
|
_row('Plan', store?.plan ?? 'Business'),
|
|
],
|
|
),
|
|
);
|
|
|
|
Widget _taxCard() => PanelCard(
|
|
title: 'Tax & pricing',
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_row(
|
|
'Default GST slab',
|
|
Formatters.percent(AppConstants.defaultGstRate),
|
|
),
|
|
_row('Prices include tax', 'Yes'),
|
|
_toggle(
|
|
'Round bills to nearest rupee',
|
|
'Shows the adjustment as a Round Off line',
|
|
_roundOff,
|
|
(v) => setState(() => _roundOff = v),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
Widget _loyaltyCard() => PanelCard(
|
|
title: 'Loyalty programme',
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_row(
|
|
'Earn rate',
|
|
'1 point per '
|
|
'${Formatters.money(AppConstants.loyaltyRupeesPerPoint)}',
|
|
),
|
|
_row(
|
|
'Point value',
|
|
Formatters.money(AppConstants.loyaltyPointValue),
|
|
),
|
|
_toggle(
|
|
'Apply tier discount automatically',
|
|
'Silver 2%, Gold 5%, Platinum 8%',
|
|
_autoLoyalty,
|
|
(v) => setState(() => _autoLoyalty = v),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
Widget _hardwareCard() {
|
|
final settings = ref.watch(printerSettingsProvider);
|
|
final controller = ref.read(printerSettingsProvider.notifier);
|
|
final printers = ref.watch(availablePrintersProvider);
|
|
|
|
return PanelCard(
|
|
title: 'Printer & peripherals',
|
|
subtitle: 'Install the printer in the operating system first — it then '
|
|
'appears in this list.',
|
|
action: TextButton.icon(
|
|
onPressed: () => ref.invalidate(availablePrintersProvider),
|
|
icon: const Icon(Icons.refresh_rounded, size: 16),
|
|
label: const Text('Rescan'),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
printers.when(
|
|
loading: () => const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: AppSpacing.lg),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
error: (e, _) => Text(
|
|
'Could not list printers: $e',
|
|
style: const TextStyle(color: AppColors.danger, fontSize: 12.5),
|
|
),
|
|
data: (list) {
|
|
if (list.isEmpty) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(AppSpacing.md),
|
|
decoration: const BoxDecoration(
|
|
color: AppColors.warningSurface,
|
|
borderRadius: AppRadius.brSm,
|
|
),
|
|
child: const Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(Icons.print_disabled_rounded,
|
|
size: 18, color: AppColors.warning,),
|
|
SizedBox(width: AppSpacing.sm),
|
|
Expanded(
|
|
child: Text(
|
|
'No printers found. Add the thermal printer in your '
|
|
'operating system settings, then tap Rescan. Bills '
|
|
'still print to screen in the meantime.',
|
|
style: TextStyle(
|
|
fontSize: 12.5,
|
|
color: AppColors.warning,
|
|
height: 1.45,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
DropdownButtonFormField<String>(
|
|
initialValue: list.any((p) => p.url == settings.printerUrl)
|
|
? settings.printerUrl
|
|
: null,
|
|
isExpanded: true,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Receipt printer',
|
|
prefixIcon: Icon(Icons.print_outlined),
|
|
isDense: true,
|
|
),
|
|
hint: const Text('Print to screen only'),
|
|
items: [
|
|
for (final p in list)
|
|
DropdownMenuItem(
|
|
value: p.url,
|
|
child: Text(
|
|
p.isDefault ? '${p.name} (system default)' : p.name,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
onChanged: (url) => controller.selectPrinter(
|
|
url == null
|
|
? null
|
|
: list.firstWhere((p) => p.url == url),
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
Wrap(
|
|
spacing: AppSpacing.sm,
|
|
runSpacing: AppSpacing.sm,
|
|
children: [
|
|
OutlinedButton.icon(
|
|
onPressed: () async {
|
|
final ok = await ref
|
|
.read(receiptServiceProvider)
|
|
.printTestPage(
|
|
printerUrl: settings.printerUrl,);
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(SnackBar(
|
|
backgroundColor: ok
|
|
? AppColors.success
|
|
: AppColors.danger,
|
|
content: Text(ok
|
|
? 'Test slip sent to the printer.'
|
|
: 'Could not reach the printer.',),
|
|
),);
|
|
},
|
|
icon: const Icon(Icons.receipt_long_rounded, size: 17),
|
|
label: const Text('Print test slip'),
|
|
),
|
|
if (settings.hasPrinter)
|
|
TextButton(
|
|
onPressed: () => controller.selectPrinter(null),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: AppColors.textSecondary,
|
|
),
|
|
child: const Text('Use screen only'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
const Divider(height: AppSpacing.xxl),
|
|
_toggle(
|
|
'Scanner beep',
|
|
'Audible confirmation on every scan',
|
|
_scannerSound,
|
|
(v) => setState(() => _scannerSound = v),
|
|
),
|
|
_toggle(
|
|
'Print receipt automatically',
|
|
settings.hasPrinter
|
|
? 'Sends to the selected printer the moment a sale completes'
|
|
: 'Select a printer above to enable this',
|
|
settings.autoPrint,
|
|
settings.hasPrinter ? controller.setAutoPrint : null,
|
|
),
|
|
_toggle(
|
|
'Open cash drawer on cash sales',
|
|
'Needs a raw ESC/POS link — see the notes in ReceiptService',
|
|
settings.openDrawer,
|
|
controller.setOpenDrawer,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _staffCard(StoreAccount? store, StaffUser? current) => PanelCard(
|
|
title: 'Users & roles',
|
|
action: TextButton(onPressed: () {}, child: const Text('Manage')),
|
|
child: ResponsiveTable(
|
|
stackBelow: 360,
|
|
columns: const [
|
|
TableCol('Name', flex: 3),
|
|
TableCol('Role', flex: 3),
|
|
TableCol('', flex: 2, numeric: true),
|
|
],
|
|
rows: (store?.staff ?? const <StaffUser>[])
|
|
.map((s) => [
|
|
Cell(s.name, bold: true),
|
|
Cell(s.role.label, color: AppColors.textSecondary),
|
|
s.id == current?.id
|
|
? const TagChip('Signed in',
|
|
color: AppColors.success,)
|
|
: const SizedBox.shrink(),
|
|
],)
|
|
.toList(),
|
|
),
|
|
);
|
|
|
|
Widget _connectivityCard() {
|
|
final ready = ref.watch(catalogueReadyProvider);
|
|
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;
|
|
|
|
return PanelCard(
|
|
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: [
|
|
_row('Catalogue', ready ? 'Loaded' : 'Not imported'),
|
|
_row(
|
|
'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(
|
|
'Last upload',
|
|
sync.lastSuccessAt == null
|
|
? 'Never'
|
|
: Formatters.dateTime(sync.lastSuccessAt!),
|
|
),
|
|
if (sync.isHalted)
|
|
_row('Status', 'Halted — ${sync.lastError ?? 'refused'}')
|
|
else if (sync.nextAttemptAt != null)
|
|
_row(
|
|
'Next attempt',
|
|
'${Formatters.time(sync.nextAttemptAt!)} '
|
|
'(attempt ${sync.consecutiveFailures + 1})',
|
|
),
|
|
_row(
|
|
'Bills kept on device',
|
|
'${OrderDao.retentionWindow.inDays} days after upload',
|
|
),
|
|
_toggle(
|
|
'Simulate offline',
|
|
'Forces import and sync to fail, so you can confirm nothing is '
|
|
'lost when the network drops',
|
|
// Read straight from the provider — no local copy to drift.
|
|
ref.watch(simulateOfflineProvider),
|
|
(v) {
|
|
ref.read(simulateOfflineProvider.notifier).state = v;
|
|
// The pill and the drain both read connectivity, so they have to
|
|
// be told the switch moved.
|
|
ref.read(connectivityServiceProvider).refresh();
|
|
// Clear any stale failure banner left by the previous setting.
|
|
ref.read(catalogueImportProvider.notifier).reset();
|
|
ref.read(orderSyncProvider.notifier).reset();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _transportLabel(SyncConfig config) => switch (config.transport) {
|
|
TransportKind.simulated =>
|
|
'Simulated — no back office configured for this terminal',
|
|
TransportKind.http => 'HTTP · ${config.httpBaseUrl}',
|
|
TransportKind.mqtt =>
|
|
'MQTT · ${config.brokerHost}:${config.brokerPort}'
|
|
'${config.useTls ? ' (TLS)' : ''}',
|
|
};
|
|
|
|
Widget _aboutCard() {
|
|
final terminal = ref.watch(terminalIdentityProvider);
|
|
|
|
return PanelCard(
|
|
title: 'About',
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_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: () => 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),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Flexible(
|
|
flex: 3,
|
|
child: Text(
|
|
label,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: AppSpacing.md),
|
|
Flexible(
|
|
flex: 4,
|
|
child: Text(
|
|
value,
|
|
textAlign: TextAlign.right,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textPrimary,
|
|
fontFamily: mono ? 'monospace' : null,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
Widget _toggle(
|
|
String title,
|
|
String subtitle,
|
|
bool value,
|
|
ValueChanged<bool>? onChanged,
|
|
) =>
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
fontSize: 13.5,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
Text(
|
|
subtitle,
|
|
style: const TextStyle(
|
|
fontSize: 11.5,
|
|
color: AppColors.textTertiary,
|
|
height: 1.4,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: AppSpacing.md),
|
|
Switch(value: value, onChanged: onChanged),
|
|
],
|
|
),
|
|
);
|
|
}
|