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 createState() => _SettingsViewState(); } class _SettingsViewState extends ConsumerState { 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( 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 []) .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? 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), ], ), ); }