third commit

This commit is contained in:
2026-07-31 17:06:52 +05:30
parent d9886880cc
commit 9891a69a5f
32 changed files with 2083 additions and 557 deletions

View File

@@ -0,0 +1,118 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:printing/printing.dart';
import '../../../app/providers.dart';
import '../../../data/local/app_database.dart';
/// Hardware preferences for this terminal.
///
/// Persisted in `app_meta` rather than held in widget state, so the choice
/// survives a restart — a cashier should not have to reselect the printer
/// every morning.
class PrinterSettings {
const PrinterSettings({
this.printerUrl,
this.printerName,
this.autoPrint = false,
this.openDrawer = true,
});
/// Target passed to `directPrintPdf`. Null means "use the system default".
final String? printerUrl;
/// Shown in Settings. Kept alongside the url because the url is opaque.
final String? printerName;
/// Print silently the moment a sale completes.
///
/// Defaults to **off**: with no printer attached, auto-printing fails
/// invisibly and looks like a bug.
final bool autoPrint;
final bool openDrawer;
bool get hasPrinter => printerUrl != null;
PrinterSettings copyWith({
String? printerUrl,
String? printerName,
bool clearPrinter = false,
bool? autoPrint,
bool? openDrawer,
}) {
return PrinterSettings(
printerUrl: clearPrinter ? null : (printerUrl ?? this.printerUrl),
printerName: clearPrinter ? null : (printerName ?? this.printerName),
autoPrint: autoPrint ?? this.autoPrint,
openDrawer: openDrawer ?? this.openDrawer,
);
}
}
class PrinterSettingsController extends StateNotifier<PrinterSettings> {
PrinterSettingsController(this._ref) : super(const PrinterSettings()) {
_load();
}
final Ref _ref;
Future<void> _load() async {
final store = _ref.read(localStoreProvider);
if (!store.isReady) return;
final dao = store.catalogue;
state = PrinterSettings(
printerUrl: await dao.meta(MetaKeys.printerUrl),
printerName: await dao.meta(MetaKeys.printerName),
autoPrint: (await dao.meta(MetaKeys.autoPrint)) == '1',
openDrawer: (await dao.meta(MetaKeys.openDrawer)) != '0',
);
}
Future<void> selectPrinter(Printer? printer) async {
final dao = _ref.read(localStoreProvider).catalogue;
if (printer == null) {
await dao.setMeta(MetaKeys.printerUrl, '');
await dao.setMeta(MetaKeys.printerName, '');
state = state.copyWith(clearPrinter: true, autoPrint: false);
return;
}
await dao.setMeta(MetaKeys.printerUrl, printer.url);
await dao.setMeta(MetaKeys.printerName, printer.name);
state = state.copyWith(
printerUrl: printer.url,
printerName: printer.name,
);
}
Future<void> setAutoPrint(bool value) async {
// Auto-print with nothing selected would fail silently on every sale.
if (value && !state.hasPrinter) return;
await _ref
.read(localStoreProvider)
.catalogue
.setMeta(MetaKeys.autoPrint, value ? '1' : '0');
state = state.copyWith(autoPrint: value);
}
Future<void> setOpenDrawer(bool value) async {
await _ref
.read(localStoreProvider)
.catalogue
.setMeta(MetaKeys.openDrawer, value ? '1' : '0');
state = state.copyWith(openDrawer: value);
}
}
final printerSettingsProvider =
StateNotifierProvider<PrinterSettingsController, PrinterSettings>(
(ref) => PrinterSettingsController(ref),
);
/// Printers the OS can currently see. Re-read whenever Settings is opened.
final availablePrintersProvider = FutureProvider<List<Printer>>(
(ref) => ref.watch(receiptServiceProvider).availablePrinters(),
);

View File

@@ -8,6 +8,7 @@ import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/formatters.dart';
import '../../../domain/entities/store_account.dart';
import '../../auth/providers/auth_controller.dart';
import '../providers/printer_settings.dart';
import '../../sync/providers/sync_controller.dart';
import '../widgets/module_widgets.dart';
@@ -21,11 +22,8 @@ class SettingsView extends ConsumerStatefulWidget {
class _SettingsViewState extends ConsumerState<SettingsView> {
bool _scannerSound = true;
bool _autoPrint = true;
bool _openDrawer = true;
bool _roundOff = true;
bool _autoLoyalty = true;
bool _offline = false;
@override
Widget build(BuildContext context) {
@@ -138,36 +136,160 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
),
);
Widget _hardwareCard() => PanelCard(
title: 'Hardware & peripherals',
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_toggle(
'Scanner beep',
'Audible confirmation on every scan',
_scannerSound,
(v) => setState(() => _scannerSound = 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()),
),
_toggle(
'Print receipt automatically',
'Sends to the default roll printer with no dialog',
_autoPrint,
(v) => setState(() => _autoPrint = v),
error: (e, _) => Text(
'Could not list printers: $e',
style: const TextStyle(color: AppColors.danger, fontSize: 12.5),
),
_toggle(
'Open cash drawer on cash sales',
'Sends the ESC/POS kick pulse',
_openDrawer,
(v) => setState(() => _openDrawer = v),
),
const Divider(height: AppSpacing.xxl),
_row('Receipt printer', 'EPSON TM-T82 (default)'),
_row('Barcode scanner', 'Keyboard wedge · detected'),
_row('Cash drawer', 'Connected via printer'),
],
),
);
data: (list) {
if (list.isEmpty) {
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: 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 (!context.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',
@@ -212,13 +334,15 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
_row('Unsynced bills', '$outstanding'),
_toggle(
'Simulate offline',
'Forces import and push to fail, so you can confirm nothing is '
'Forces import and sync to fail, so you can confirm nothing is '
'lost when the network drops',
_offline,
// Read straight from the provider — no local copy to drift.
ref.watch(simulateOfflineProvider),
(v) {
setState(() => _offline = v);
ref.read(remoteCatalogueProvider).simulateOffline = v;
ref.read(remoteOrderSinkProvider).simulateOffline = v;
ref.read(simulateOfflineProvider.notifier).state = v;
// Clear any stale failure banner left by the previous setting.
ref.read(catalogueImportProvider.notifier).reset();
ref.read(orderSyncProvider.notifier).reset();
},
),
],
@@ -286,7 +410,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
String title,
String subtitle,
bool value,
ValueChanged<bool> onChanged,
ValueChanged<bool>? onChanged,
) =>
Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),