From af3933092f6bdbb8ecda8c14ebee1211b9d0d506 Mon Sep 17 00:00:00 2001 From: Suriya Date: Fri, 31 Jul 2026 18:34:10 +0530 Subject: [PATCH] Fix billing data integrity, sale atomicity and stock safety Bills were persisted correctly but read back wrong. The read path rebuilt a cart from its lines alone, dropping bill-level discounts and loyalty, so every figure derived from a stored bill was overstated: the upload payload, the day archive and the shift report. A discounted 529 bill read back as 620. Money and data integrity - order_dao: restore bill_discount and points_redeemed when rebuilding a cart; keep the reconstruction tier-less so the membership discount is not applied twice. Trust the recorded total and points via SaleTransaction.storedTotal. - checkout_sale + order_dao.commitSale: write the bill, its stock movement and the loyalty update in one transaction. Previously a failure part-way through left a persisted bill the cashier believed had failed, inviting a duplicate. - checkout_sale: re-check every line against live stock. A parked bill resumed after its stock was sold passed validation and oversold. - catalogue_dao: allocate the invoice sequence in one transaction; the previous read-modify-write could hand two sales the same number and fail UNIQUE. - local_store: replay unsynced sales after a catalogue import, so a mid-shift re-import cannot restore stock that has already been sold. - payment_controller: stamp the signed-in operator on the bill instead of the hardcoded seed session, and pass the terminal id through. - cart: reconcile per-slab GST against the bill total so the parts sum to the whole on a tax invoice. Sync and reporting - sync_repository: drain unsynced bills in a loop rather than silently capping at one page; stop on rejection so rejected rows cannot loop forever. - sync_log_dao (new): persist the sync history to the sync_log table, which the schema already defined but nothing used. It was in memory, so the only record that bills had been uploaded died at restart. - Scope shift reports by cashier. day_archive is re-keyed to (business_date, cashier_name) so a till stays settleable after its bills are uploaded and deleted. Schema v4 with a migration that carries v3 rows across. Input and UI - barcode_service: consume machine-paced keystrokes so a scan cannot also land in the focused field, and raise the bar to 60ms/char while a text field has focus so typing a mobile number is not read as a scan. Clock and focus check injected so the behaviour is testable. - primary_button: make the label flexible; label plus trailing total overflowed the Charge button by up to 131px. - app_router: redirect instead of null-casting when the receipt route is entered without its transaction. - customer_repository: reduce the search query to digits so a punctuated mobile number matches. Cleanup - Remove TransactionRepository.save, CustomerRepository.recordSale and OrderDao.insertOrder, all superseded by commitSale. - dart fix across the tree; 251 analyzer issues down to 3 info-level. Tests: 23 passing / 15 failing -> 90 passing. Fixed the two defects that broke the existing suite (containsAll type argument, reset() needing a catalogue) and deleted the leftover template test. Added coverage for the order round trip, the day archive after a real sync, stock safety, checkout atomicity, the v3->v4 migration, scanner-versus-human input, and an app-level smoke test that renders every module. Note: bills already uploaded with a discount went up overstated. This stops it happening again but does not correct historical server data. Co-Authored-By: Claude Opus 5 --- lib/core/router/app_router.dart | 5 + lib/core/services/barcode_service.dart | 90 +++- lib/core/services/receipt_service.dart | 93 ++-- lib/core/theme/app_theme.dart | 6 +- lib/core/theme/app_typography.dart | 6 +- lib/core/utils/extensions.dart | 2 +- lib/core/widgets/empty_state.dart | 2 +- lib/core/widgets/primary_button.dart | 46 +- lib/data/datasources/local_store.dart | 37 ++ lib/data/local/app_database.dart | 73 ++- lib/data/local/catalogue_dao.dart | 30 +- lib/data/local/order_dao.dart | 257 +++++++--- lib/data/local/sync_log_dao.dart | 75 +++ .../customer_repository_impl.dart | 38 +- .../repositories/sync_repository_impl.dart | 197 ++++---- .../transaction_repository_impl.dart | 22 +- lib/domain/entities/cart.dart | 22 +- lib/domain/entities/customer.dart | 21 + lib/domain/entities/shift_report.dart | 4 +- lib/domain/entities/transaction.dart | 17 +- .../repositories/customer_repository.dart | 6 - lib/domain/repositories/sync_repository.dart | 4 + .../repositories/transaction_repository.dart | 12 +- lib/domain/usecases/checkout_sale.dart | 70 ++- lib/main.dart | 4 +- .../auth/screens/login_screen.dart | 15 +- .../widgets/customer_capture_sheet.dart | 6 +- .../modules/screens/customers_view.dart | 4 +- .../modules/screens/events_view.dart | 10 +- .../modules/screens/product_import_view.dart | 34 +- .../modules/screens/promos_view.dart | 8 +- .../modules/screens/settings_view.dart | 14 +- .../modules/widgets/module_widgets.dart | 2 +- .../payment/providers/payment_controller.dart | 24 +- .../payment/screens/payment_screen.dart | 28 +- .../pos/providers/cart_controller.dart | 19 +- .../pos/providers/navigation_provider.dart | 4 +- .../pos/screens/pos_dashboard_screen.dart | 10 +- lib/presentation/pos/screens/pos_view.dart | 8 +- lib/presentation/pos/widgets/app_sidebar.dart | 4 +- .../pos/widgets/billing_panel.dart | 22 +- lib/presentation/pos/widgets/cart_fab.dart | 4 +- .../pos/widgets/cart_line_tile.dart | 24 +- .../pos/widgets/category_chips.dart | 2 +- .../pos/widgets/customer_bar.dart | 8 +- .../pos/widgets/discount_sheet.dart | 14 +- lib/presentation/pos/widgets/page_header.dart | 6 +- .../pos/widgets/product_card.dart | 9 +- lib/presentation/pos/widgets/scan_toast.dart | 4 +- .../pos/widgets/search_field.dart | 17 +- .../receipt/screens/receipt_screen.dart | 20 +- .../receipt/widgets/receipt_preview.dart | 98 ++-- .../sync/providers/sync_controller.dart | 18 +- .../sync/widgets/sign_out_dialog.dart | 5 +- test/unit/barcode_service_test.dart | 169 +++++++ test/unit/cart_test.dart | 6 +- test/unit/checkout_test.dart | 12 +- test/unit/migration_test.dart | 204 ++++++++ test/unit/persistence_test.dart | 463 ++++++++++++++++++ test/widget/app_smoke_test.dart | 130 +++++ test/widget/primary_button_test.dart | 2 +- test/widget_test.dart | 30 -- 62 files changed, 2035 insertions(+), 561 deletions(-) create mode 100644 lib/data/local/sync_log_dao.dart create mode 100644 test/unit/barcode_service_test.dart create mode 100644 test/unit/migration_test.dart create mode 100644 test/unit/persistence_test.dart create mode 100644 test/widget/app_smoke_test.dart delete mode 100644 test/widget_test.dart diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 0680aed..d777f40 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -68,6 +68,11 @@ final routerProvider = Provider((ref) { GoRoute( path: AppRoutes.receipt, name: 'receipt', + // The bill travels in `extra`, which does not survive a reload, a deep + // link or a restored session. Sending the cashier back to billing beats + // crashing on a null cast. + redirect: (context, state) => + state.extra is SaleTransaction ? null : AppRoutes.pos, pageBuilder: (context, state) => _fade( state, ReceiptScreen(transaction: state.extra! as SaleTransaction), diff --git a/lib/core/services/barcode_service.dart b/lib/core/services/barcode_service.dart index b1798d1..1677182 100644 --- a/lib/core/services/barcode_service.dart +++ b/lib/core/services/barcode_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import '../constants/app_constants.dart'; @@ -11,17 +12,39 @@ import '../constants/app_constants.dart'; /// when the characters arrived faster than a human could type — that way the /// cashier can still type into the same field by hand. class BarcodeService { - BarcodeService({this.onScan, this.onManualKey}); + BarcodeService({ + this.onScan, + this.onManualKey, + DateTime Function()? clock, + bool Function()? isEditingText, + }) : _clock = clock ?? DateTime.now, + _isEditingText = isEditingText ?? _primaryFocusIsTextField; final void Function(String code)? onScan; final VoidCallback? onManualKey; + /// Injectable so tests can drive keystroke timing exactly rather than + /// sleeping, which is what makes scanner-versus-human behaviour testable. + final DateTime Function() _clock; + final bool Function() _isEditingText; + final StringBuffer _buffer = StringBuffer(); DateTime? _lastKeyAt; + DateTime? _firstKeyAt; + int _charCount = 0; + + /// Cleared when any gap in the buffer is too slow to be a machine. + bool _machinePaced = true; + Timer? _flushTimer; bool _attached = false; + /// Sustained pace no typist holds. Applied when a text field has focus, where + /// the cost of a false positive is a swallowed keystroke rather than nothing. + static Duration get _strictGap => + Duration(microseconds: AppConstants.barcodeScanTimeout.inMicroseconds ~/ 2); + void attach() { if (_attached) return; HardwareKeyboard.instance.addHandler(_handleKey); @@ -35,22 +58,44 @@ class BarcodeService { _attached = false; } + /// Whether the cashier is typing into a field right now. + /// + /// A scan still registers while a field has focus, but the bar for calling it + /// a scan is raised, because getting it wrong means eating real keystrokes. + static bool _primaryFocusIsTextField() { + final context = FocusManager.instance.primaryFocus?.context; + if (context == null) return false; + return context.widget is EditableText || + context.findAncestorWidgetOfExactType() != null; + } + + void _resetBuffer() { + _buffer.clear(); + _firstKeyAt = null; + _charCount = 0; + _machinePaced = true; + } + + /// The key handler, exposed so it can be driven directly in tests. + @visibleForTesting + bool handleKey(KeyEvent event) => _handleKey(event); + bool _handleKey(KeyEvent event) { if (event is! KeyDownEvent) return false; - final now = DateTime.now(); - final gap = _lastKeyAt == null - ? Duration.zero - : now.difference(_lastKeyAt!); + final now = _clock(); + final gap = _lastKeyAt == null ? Duration.zero : now.difference(_lastKeyAt!); _lastKeyAt = now; // A long pause means a new entry started; discard whatever was buffered. if (gap > AppConstants.barcodeScanTimeout) { - _buffer.clear(); + _resetBuffer(); } if (event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter) { + // Only swallow Enter if the buffer actually resolves to a scan — + // otherwise a form submit on a hand-typed field would be eaten. return _flush(); } @@ -58,9 +103,19 @@ class BarcodeService { if (char == null || char.trim().isEmpty) return false; if (!RegExp(r'^[0-9A-Za-z\-]$').hasMatch(char)) return false; + if (_charCount > 0 && gap > AppConstants.barcodeScanTimeout) { + _machinePaced = false; + } + _buffer.write(char); + _firstKeyAt ??= now; + _charCount++; _scheduleFlush(); - return false; + + // Consume the keystroke once the burst is unmistakably machine-paced. + // Without this the scanned digits also land in whatever field has focus, + // so a scan would add the item *and* type the barcode into the search box. + return _looksLikeScan(atLeast: 2); } /// Some scanners are not configured to send a terminating Enter, so we also @@ -69,16 +124,31 @@ class BarcodeService { _flushTimer?.cancel(); _flushTimer = Timer( AppConstants.barcodeScanTimeout * 2, - () => _flush(), + _flush, ); } + /// Whether the buffer so far has the shape and speed of a scanner burst. + bool _looksLikeScan({required int atLeast}) { + if (_charCount < atLeast || !_machinePaced) return false; + + final first = _firstKeyAt; + if (first == null || _charCount < 2) return false; + + final span = _lastKeyAt!.difference(first); + final averageGap = span ~/ (_charCount - 1); + + return averageGap <= + (_isEditingText() ? _strictGap : AppConstants.barcodeScanTimeout); + } + bool _flush() { _flushTimer?.cancel(); final code = _buffer.toString().trim(); - _buffer.clear(); + final wasScan = _looksLikeScan(atLeast: AppConstants.minBarcodeLength); + _resetBuffer(); - if (code.length >= AppConstants.minBarcodeLength) { + if (wasScan && code.length >= AppConstants.minBarcodeLength) { onScan?.call(code); return true; } diff --git a/lib/core/services/receipt_service.dart b/lib/core/services/receipt_service.dart index 731179e..12496fd 100644 --- a/lib/core/services/receipt_service.dart +++ b/lib/core/services/receipt_service.dart @@ -1,4 +1,3 @@ -import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:pdf/pdf.dart'; @@ -58,7 +57,7 @@ class ReceiptService { doc.addPage( pw.Page( - pageFormat: PdfPageFormat( + pageFormat: const PdfPageFormat( _rollWidth, double.infinity, marginAll: 5 * PdfPageFormat.mm, @@ -94,23 +93,23 @@ class ReceiptService { pw.Widget _header(SaleTransaction txn) => pw.Column(children: [ pw.Text( AppConstants.storeName.toUpperCase(), - style: pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold), + style: const pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold), ), pw.Text(AppConstants.storeLegalName, - style: const pw.TextStyle(fontSize: 7)), + style: const pw.TextStyle(fontSize: 7),), pw.SizedBox(height: 2), pw.Text(AppConstants.storeAddress, style: const pw.TextStyle(fontSize: 6.5), - textAlign: pw.TextAlign.center), + textAlign: pw.TextAlign.center,), pw.Text('Customer care : ${AppConstants.storePhone}', - style: const pw.TextStyle(fontSize: 6.5)), + style: const pw.TextStyle(fontSize: 6.5),), pw.Text('CIN No : ${AppConstants.storeCin}', - style: const pw.TextStyle(fontSize: 6.5)), + style: const pw.TextStyle(fontSize: 6.5),), pw.Text('GSTIN : ${AppConstants.storeGstin}', - style: const pw.TextStyle(fontSize: 6.5)), + style: const pw.TextStyle(fontSize: 6.5),), pw.Text('FSSAI Lic No : ${AppConstants.storeFssai}', - style: const pw.TextStyle(fontSize: 6.5)), - ]); + style: const pw.TextStyle(fontSize: 6.5),), + ],); pw.Widget _savingsLine(SaleTransaction txn) { final saved = _grossSalesValue(txn) - txn.cart.netAmount; @@ -120,7 +119,7 @@ class ReceiptService { padding: const pw.EdgeInsets.symmetric(vertical: 4), child: pw.Text( 'You have saved Rs.${saved.toStringAsFixed(2)}', - style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold), + style: const pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.center, ), ); @@ -128,11 +127,11 @@ class ReceiptService { pw.Widget _invoiceTitle() => pw.Column(children: [ pw.Text('TAX INVOICE', - style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold)), + style: const pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold),), pw.SizedBox(height: 1), pw.Text('xxxxxx Original for Recipient xxxxxx', - style: const pw.TextStyle(fontSize: 6)), - ]); + style: const pw.TextStyle(fontSize: 6),), + ],); pw.Widget _meta(SaleTransaction txn) { final c = txn.customer; @@ -167,7 +166,7 @@ class ReceiptService { pw.Expanded(flex: 16, child: _th('Net Price', right: true)), pw.Expanded(flex: 8, child: _th('Qty', right: true)), pw.Expanded(flex: 18, child: _th('Value', right: true)), - ]), + ],), pw.SizedBox(height: 2), for (final slab in slabs) ...[ pw.Padding( @@ -175,7 +174,7 @@ class ReceiptService { child: pw.Text( '${slab.index}) CGST @ ${slab.halfPercent} ' 'SGST @ ${slab.halfPercent}', - style: pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold), + style: const pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold), ), ), for (final line in slab.lines) @@ -193,7 +192,7 @@ class ReceiptService { pw.Expanded( flex: 16, child: _td(line.product.price.toStringAsFixed(2), - right: true, size: 6.5), + right: true, size: 6.5,), ), pw.Expanded( flex: 8, @@ -202,9 +201,9 @@ class ReceiptService { pw.Expanded( flex: 18, child: _td(line.payable.toStringAsFixed(2), - right: true, size: 6.5), + right: true, size: 6.5,), ), - ]), + ],), ), ], ], @@ -237,14 +236,14 @@ class ReceiptService { if (txn.changeDue > 0) _amount('Change Returned', txn.changeDue), pw.SizedBox(height: 1), pw.Text('(AMOUNT INCLUSIVE OF APPLICABLE TAXES)', - style: const pw.TextStyle(fontSize: 6)), - ]); + style: const pw.TextStyle(fontSize: 6),), + ],); } pw.Widget _gstBreakup(SaleTransaction txn, List<_Slab> slabs) { return pw.Column(children: [ pw.Text('------GST Breakup Details------ Amount (INR)', - style: const pw.TextStyle(fontSize: 6.5)), + style: const pw.TextStyle(fontSize: 6.5),), pw.SizedBox(height: 3), pw.Row(children: [ pw.Expanded(flex: 10, child: _th('GST\nIND', size: 6)), @@ -253,7 +252,7 @@ class ReceiptService { pw.Expanded(flex: 16, child: _th('SGST', right: true, size: 6)), pw.Expanded(flex: 14, child: _th('CESS', right: true, size: 6)), pw.Expanded(flex: 20, child: _th('Total\nAmount', right: true, size: 6)), - ]), + ],), pw.SizedBox(height: 2), for (final s in slabs) pw.Padding( @@ -263,19 +262,19 @@ class ReceiptService { pw.Expanded( flex: 20, child: _td(s.taxable.toStringAsFixed(2), - right: true, size: 6.5)), + right: true, size: 6.5,),), pw.Expanded( flex: 16, - child: _td(s.cgst.toStringAsFixed(2), right: true, size: 6.5)), + child: _td(s.cgst.toStringAsFixed(2), right: true, size: 6.5),), pw.Expanded( flex: 16, - child: _td(s.sgst.toStringAsFixed(2), right: true, size: 6.5)), + child: _td(s.sgst.toStringAsFixed(2), right: true, size: 6.5),), pw.Expanded(flex: 14, child: _td('0.00', right: true, size: 6.5)), pw.Expanded( flex: 20, child: - _td(s.total.toStringAsFixed(2), right: true, size: 6.5)), - ]), + _td(s.total.toStringAsFixed(2), right: true, size: 6.5),), + ],), ), pw.Divider(height: 4, borderStyle: pw.BorderStyle.dashed), pw.Row(children: [ @@ -285,24 +284,24 @@ class ReceiptService { child: _th( slabs.fold(0.0, (a, s) => a + s.taxable).toStringAsFixed(2), right: true, - size: 6.5)), + size: 6.5,),), pw.Expanded( flex: 16, child: _th(slabs.fold(0.0, (a, s) => a + s.cgst).toStringAsFixed(2), - right: true, size: 6.5)), + right: true, size: 6.5,),), pw.Expanded( flex: 16, child: _th(slabs.fold(0.0, (a, s) => a + s.sgst).toStringAsFixed(2), - right: true, size: 6.5)), + right: true, size: 6.5,),), pw.Expanded(flex: 14, child: _th('0.00', right: true, size: 6.5)), pw.Expanded( flex: 20, child: _th( slabs.fold(0.0, (a, s) => a + s.total).toStringAsFixed(2), right: true, - size: 6.5)), - ]), - ]); + size: 6.5,),), + ],), + ],); } pw.Widget _references(SaleTransaction txn) { @@ -341,14 +340,14 @@ class ReceiptService { ), pw.SizedBox(height: 3), pw.Text('* Thank You for Shopping with us *', - style: pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold)), + style: const pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold),), pw.Text('Goods once sold are exchangeable within 7 days with this bill', style: const pw.TextStyle(fontSize: 5.5), - textAlign: pw.TextAlign.center), + textAlign: pw.TextAlign.center,), pw.SizedBox(height: 2), pw.Text('Powered by Nearle POS', - style: const pw.TextStyle(fontSize: 5.5)), - ]); + style: const pw.TextStyle(fontSize: 5.5),), + ],); // -------------------------------------------------------------- Helpers /// Pre-discount value, using printed MRP where one is known. @@ -391,12 +390,12 @@ class ReceiptService { style: pw.TextStyle( fontSize: 6.8, fontWeight: bold ? pw.FontWeight.bold : pw.FontWeight.normal, - )), + ),), pw.Text(value.toStringAsFixed(2), style: pw.TextStyle( fontSize: 6.8, fontWeight: bold ? pw.FontWeight.bold : pw.FontWeight.normal, - )), + ),), ], ), ); @@ -404,14 +403,14 @@ class ReceiptService { pw.Widget _th(String text, {bool right = false, double size = 6.8}) => pw.Text(text, textAlign: right ? pw.TextAlign.right : pw.TextAlign.left, - style: pw.TextStyle(fontSize: size, fontWeight: pw.FontWeight.bold)); + style: pw.TextStyle(fontSize: size, fontWeight: pw.FontWeight.bold),); pw.Widget _td(String text, {bool right = false, double size = 6.8}) => pw.Text(text, maxLines: 1, overflow: pw.TextOverflow.clip, textAlign: right ? pw.TextAlign.right : pw.TextAlign.left, - style: pw.TextStyle(fontSize: size)); + style: pw.TextStyle(fontSize: size),); // --------------------------------------------------------- Printing / IO /// Opens the system print preview. @@ -483,7 +482,7 @@ class ReceiptService { final font = await PdfGoogleFonts.robotoMonoRegular(); doc.addPage( pw.Page( - pageFormat: PdfPageFormat( + pageFormat: const PdfPageFormat( _rollWidth, double.infinity, marginAll: 5 * PdfPageFormat.mm, @@ -491,16 +490,16 @@ class ReceiptService { theme: pw.ThemeData.withFont(base: font), build: (_) => pw.Column(children: [ pw.Text(AppConstants.storeName.toUpperCase(), - style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)), + style: const pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold),), pw.SizedBox(height: 6), pw.Text('PRINTER TEST', style: const pw.TextStyle(fontSize: 10)), pw.SizedBox(height: 4), pw.Text(target.name, style: const pw.TextStyle(fontSize: 7)), pw.Text(Formatters.dateTime(DateTime.now()), - style: const pw.TextStyle(fontSize: 7)), + style: const pw.TextStyle(fontSize: 7),), pw.SizedBox(height: 6), pw.Text('1234567890 ABCDEFGHIJ', - style: const pw.TextStyle(fontSize: 8)), + style: const pw.TextStyle(fontSize: 8),), pw.Text('₹ 1,234.56', style: const pw.TextStyle(fontSize: 8)), pw.SizedBox(height: 6), pw.BarcodeWidget( @@ -510,7 +509,7 @@ class ReceiptService { height: 30, drawText: false, ), - ]), + ],), ), ); diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index cfa1f25..4f0f339 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -40,13 +40,13 @@ class AppTheme { ?.copyWith(color: AppColors.textOnPrimary), ), - cardTheme: CardThemeData( + cardTheme: const CardThemeData( color: AppColors.surface, elevation: 0, margin: EdgeInsets.zero, shape: RoundedRectangleBorder( borderRadius: AppRadius.brLg, - side: const BorderSide(color: AppColors.border), + side: BorderSide(color: AppColors.border), ), ), @@ -149,7 +149,7 @@ class AppTheme { TargetPlatform.macOS: FadeUpwardsPageTransitionsBuilder(), TargetPlatform.linux: FadeUpwardsPageTransitionsBuilder(), TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(), - }), + },), ); } diff --git a/lib/core/theme/app_typography.dart b/lib/core/theme/app_typography.dart index 50cdb8e..90b1473 100644 --- a/lib/core/theme/app_typography.dart +++ b/lib/core/theme/app_typography.dart @@ -31,12 +31,12 @@ class AppTypography { bodyLarge: _s(base.bodyLarge, 15.5, FontWeight.w400, 0), bodyMedium: _s(base.bodyMedium, 14.5, FontWeight.w400, 0), bodySmall: _s(base.bodySmall, 12.5, FontWeight.w400, 0, - color: AppColors.textSecondary), + color: AppColors.textSecondary,), labelLarge: _s(base.labelLarge, 14.5, FontWeight.w600, 0), labelMedium: _s(base.labelMedium, 12.5, FontWeight.w600, 0.1), labelSmall: _s(base.labelSmall, 10.5, FontWeight.w600, 0.6, - color: AppColors.textTertiary), + color: AppColors.textTertiary,), ); } @@ -58,7 +58,7 @@ class AppTypography { /// Tabular figures — essential so totals don't jitter as quantities change. static TextStyle money(double size, - {FontWeight weight = FontWeight.w700, Color? color}) => + {FontWeight weight = FontWeight.w700, Color? color,}) => GoogleFonts.poppins( fontSize: size, fontWeight: weight, diff --git a/lib/core/utils/extensions.dart b/lib/core/utils/extensions.dart index 55f750b..bc5ccfa 100644 --- a/lib/core/utils/extensions.dart +++ b/lib/core/utils/extensions.dart @@ -29,7 +29,7 @@ extension BuildContextX on BuildContext { content: Text(message), backgroundColor: background, duration: const Duration(seconds: 2), - )); + ),); } } diff --git a/lib/core/widgets/empty_state.dart b/lib/core/widgets/empty_state.dart index c7c35f2..e931f88 100644 --- a/lib/core/widgets/empty_state.dart +++ b/lib/core/widgets/empty_state.dart @@ -36,7 +36,7 @@ class EmptyState extends StatelessWidget { ), alignment: Alignment.center, child: Text(emoji, - style: TextStyle(fontSize: compact ? 28 : 40)), + style: TextStyle(fontSize: compact ? 28 : 40),), ), SizedBox(height: compact ? AppSpacing.lg : AppSpacing.xxl), Text( diff --git a/lib/core/widgets/primary_button.dart b/lib/core/widgets/primary_button.dart index 9e7fa62..7f5951d 100644 --- a/lib/core/widgets/primary_button.dart +++ b/lib/core/widgets/primary_button.dart @@ -49,28 +49,36 @@ class PrimaryButton extends StatelessWidget { ? MainAxisAlignment.spaceBetween : MainAxisAlignment.center, children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (icon != null) ...[ - Icon(icon, size: large ? 24 : 20, color: fg), - const SizedBox(width: AppSpacing.sm), - ], - Flexible( - child: Text( - label, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: fg, - fontSize: large ? 19 : 16, - fontWeight: FontWeight.w700, - letterSpacing: 0.1, + // Flexible so the label gives way to the trailing total rather + // than overflowing the button: on a narrow bill panel "Charge" + // plus a five-figure amount is wider than the button itself. + Flexible( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: large ? 24 : 20, color: fg), + const SizedBox(width: AppSpacing.sm), + ], + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: fg, + fontSize: large ? 19 : 16, + fontWeight: FontWeight.w700, + letterSpacing: 0.1, + ), ), ), - ), - ], + ], + ), ), - if (trailing != null) trailing!, + if (trailing != null) ...[ + const SizedBox(width: AppSpacing.sm), + trailing!, + ], ], ); diff --git a/lib/data/datasources/local_store.dart b/lib/data/datasources/local_store.dart index 069cfb8..130f192 100644 --- a/lib/data/datasources/local_store.dart +++ b/lib/data/datasources/local_store.dart @@ -1,8 +1,10 @@ import '../../domain/entities/customer.dart'; import '../../domain/entities/product.dart'; +import '../../domain/entities/sync_event.dart'; import '../local/app_database.dart'; import '../local/catalogue_dao.dart'; import '../local/order_dao.dart'; +import '../local/sync_log_dao.dart'; /// Terminal-side storage facade. /// @@ -17,9 +19,11 @@ class LocalStore { late CatalogueDao catalogue; late OrderDao orders; + late SyncLogDao syncLog; final Map _products = {}; final Map _customers = {}; + final List _syncEvents = []; DateTime? _lastImportAt; String? _catalogueRevision; @@ -39,6 +43,7 @@ class LocalStore { catalogue = CatalogueDao(AppDatabase.instance.db); orders = OrderDao(AppDatabase.instance.db); + syncLog = SyncLogDao(AppDatabase.instance.db); await hydrate(); _ready = true; @@ -63,6 +68,20 @@ class LocalStore { : DateTime.fromMillisecondsSinceEpoch(int.parse(stamp)); _catalogueRevision = await catalogue.meta(MetaKeys.catalogueRevision); _unsyncedOrders = await orders.unsyncedCount(); + + _syncEvents + ..clear() + ..addAll(await syncLog.recent()); + } + + // ---------------------------------------------------------------- Sync log + /// Newest first. Backed by the table, so it survives a restart. + List get syncEvents => List.unmodifiable(_syncEvents); + + Future appendSyncEvent(SyncEvent event) async { + await syncLog.insert(event); + await syncLog.trim(); + _syncEvents.insert(0, event); } /// Test helper: wipes every table and reloads. @@ -102,6 +121,15 @@ class LocalStore { required DateTime at, }) async { await catalogue.replaceCatalogue(products: products, customers: customers); + + // The server's stock figure predates any sale this terminal has made but + // not yet uploaded, so those units would reappear on the shelf. Replay them + // before anyone can bill against the inflated count. + final committed = await orders.unsyncedStockCommitments(); + if (committed.isNotEmpty) { + await catalogue.decrementStock(committed); + } + await catalogue.setMeta( MetaKeys.lastImportAt, '${at.millisecondsSinceEpoch}', @@ -131,6 +159,12 @@ class LocalStore { Future applyStockMovement(Map quantities) async { await catalogue.decrementStock(quantities); + cacheStockMovement(quantities); + } + + /// Mirrors a stock decrement already written to disk into the memory cache. + /// Used after a sale is committed as part of a larger transaction. + void cacheStockMovement(Map quantities) { quantities.forEach((id, qty) { final p = _products[id]; if (p == null) return; @@ -149,6 +183,9 @@ class LocalStore { _customers[c.id] = c; } + /// Mirrors a customer already written to disk into the memory cache. + void cacheCustomer(Customer c) => _customers[c.id] = c; + // ----------------------------------------------------------------- Orders /// Refreshes the cached unsynced tally after a write or a sync. Future refreshUnsyncedCount() async { diff --git a/lib/data/local/app_database.dart b/lib/data/local/app_database.dart index f94492a..d2c0082 100644 --- a/lib/data/local/app_database.dart +++ b/lib/data/local/app_database.dart @@ -1,6 +1,5 @@ import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; -import 'package:sqflite/sqflite.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; /// SQLite database for the terminal. @@ -14,7 +13,7 @@ class AppDatabase { static final AppDatabase instance = AppDatabase._(); static const String _fileName = 'nearle_pos.db'; - static const int _version = 3; + static const int _version = 4; Database? _db; @@ -70,6 +69,7 @@ class AppDatabase { 'ALTER TABLE ${Tables.products} ADD COLUMN hsn_code TEXT', ); } + if (from < 4) await _upgradeToV4(db, from: from); }, ), ); @@ -238,18 +238,7 @@ class AppDatabase { '''); // -------------------------------------------------------------- syncLog - await db.execute(''' - CREATE TABLE ${Tables.syncLog} ( - id TEXT PRIMARY KEY, - type TEXT NOT NULL, - status TEXT NOT NULL, - created_at INTEGER NOT NULL, - synced_at INTEGER, - summary TEXT NOT NULL, - error TEXT, - attempts INTEGER NOT NULL DEFAULT 0 - ) - '''); + await db.execute(_createSyncLog); // ------------------------------------------------------------- archive await db.execute(_createDayArchive); @@ -264,14 +253,61 @@ class AppDatabase { } } -/// Running totals per business day. +/// Moves the schema to v4. +/// +/// Adds the payload column the sync log needs to be usable at all, and re-keys +/// the day archive by cashier so a shift report can be scoped to whoever is +/// settling their till. Existing archived rows predate per-cashier attribution, +/// so they are folded under an empty name rather than guessed at. +Future _upgradeToV4(Database db, {required int from}) async { + // A v1 database had no sync_log payload column; v2+ did not either. + await db.execute( + 'ALTER TABLE ${Tables.syncLog} ADD COLUMN payload_json TEXT', + ); + + await db.execute('ALTER TABLE ${Tables.dayArchive} RENAME TO _day_archive_v3'); + await db.execute(_createDayArchive); + await db.execute(''' + INSERT INTO ${Tables.dayArchive} ( + business_date, cashier_name, bill_count, item_count, gross_sales, + tax_collected, discount_given, round_off, points_issued, + points_redeemed, payments_json, first_bill_at, last_bill_at, + synced_bills + ) + SELECT + business_date, '', bill_count, item_count, gross_sales, + tax_collected, discount_given, round_off, points_issued, + points_redeemed, payments_json, first_bill_at, last_bill_at, + synced_bills + FROM _day_archive_v3 + '''); + await db.execute('DROP TABLE _day_archive_v3'); +} + +const String _createSyncLog = ''' + CREATE TABLE sync_log ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + status TEXT NOT NULL, + created_at INTEGER NOT NULL, + synced_at INTEGER, + summary TEXT NOT NULL, + error TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + payload_json TEXT + ) +'''; + +/// Running totals per business day and cashier. /// /// Synced orders are deleted from the terminal, so their figures are folded in /// here first — otherwise "Bills Today" would collapse to zero the moment a -/// mid-shift sync ran. +/// mid-shift sync ran. Keyed by cashier as well as date, because once the +/// orders are gone this row is the only thing left to settle a till against. const String _createDayArchive = ''' CREATE TABLE day_archive ( - business_date TEXT PRIMARY KEY, + business_date TEXT NOT NULL, + cashier_name TEXT NOT NULL DEFAULT '', bill_count INTEGER NOT NULL DEFAULT 0, item_count REAL NOT NULL DEFAULT 0, gross_sales REAL NOT NULL DEFAULT 0, @@ -283,7 +319,8 @@ const String _createDayArchive = ''' payments_json TEXT NOT NULL DEFAULT '{}', first_bill_at INTEGER, last_bill_at INTEGER, - synced_bills INTEGER NOT NULL DEFAULT 0 + synced_bills INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (business_date, cashier_name) ) '''; diff --git a/lib/data/local/catalogue_dao.dart b/lib/data/local/catalogue_dao.dart index a608f1b..f74282d 100644 --- a/lib/data/local/catalogue_dao.dart +++ b/lib/data/local/catalogue_dao.dart @@ -78,7 +78,7 @@ class CatalogueDao { static DateTime? _date(Object? millis) => millis == null ? null - : DateTime.fromMillisecondsSinceEpoch(millis! as int); + : DateTime.fromMillisecondsSinceEpoch(millis as int); // -------------------------------------------------------------- Products Future> allProducts() async { @@ -248,10 +248,30 @@ class CatalogueDao { } /// Monotonic invoice counter held in the meta table. + /// + /// Read and write happen in one transaction. Done as separate awaits, two + /// checkouts could interleave, take the same number, and the second insert + /// would then fail the UNIQUE constraint on `invoice_number` — after the + /// sequence had already been consumed. Future nextInvoiceSequence() async { - final current = int.tryParse(await meta(MetaKeys.invoiceSequence) ?? '0') ?? 0; - final next = current + 1; - await setMeta(MetaKeys.invoiceSequence, '$next'); - return next; + return _db.transaction((txn) async { + final rows = await txn.query( + Tables.meta, + where: 'key = ?', + whereArgs: [MetaKeys.invoiceSequence], + limit: 1, + ); + final current = rows.isEmpty + ? 0 + : int.tryParse(rows.first['value']! as String) ?? 0; + final next = current + 1; + + await txn.insert( + Tables.meta, + {'key': MetaKeys.invoiceSequence, 'value': '$next'}, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + return next; + }); } } diff --git a/lib/data/local/order_dao.dart b/lib/data/local/order_dao.dart index 4dccba5..d9643a2 100644 --- a/lib/data/local/order_dao.dart +++ b/lib/data/local/order_dao.dart @@ -28,76 +28,127 @@ class OrderDao { '${dt.day.toString().padLeft(2, '0')}'; // ----------------------------------------------------------------- Write - /// Writes the bill and its lines atomically. - Future insertOrder(SaleTransaction t) async { - final cart = t.cart; - + /// Commits an entire sale in one transaction. + /// + /// The bill, the stock it consumed and the shopper's loyalty movement have to + /// land together or not at all. Applied as three separate writes, a failure + /// part-way through left a persisted bill with unapplied loyalty while the + /// cashier saw an error and rang the sale again — a duplicate bill and a + /// double stock decrement. + Future commitSale({ + required SaleTransaction transaction, + required Map stockMovements, + Map? customerRow, + }) async { await _db.transaction((txn) async { - await txn.insert( - Tables.orders, - { - 'id': t.id, - 'invoice_number': t.invoiceNumber, - 'created_at': t.createdAt.millisecondsSinceEpoch, - 'business_date': businessDateOf(t.createdAt), - 'cashier_name': t.cashierName, - 'terminal_id': t.terminalId, - 'customer_id': cart.customer?.id, - 'customer_mobile': cart.customer?.mobile, - 'customer_name': cart.customer?.name, - 'subtotal': cart.subtotal, - 'line_discount': cart.lineDiscountTotal, - 'bill_discount': cart.billDiscountTotal, - 'loyalty_value': cart.loyaltyRedemptionValue, - 'taxable_amount': cart.taxableAmount, - 'tax_amount': cart.taxAmount, - 'round_off': cart.roundOff, - 'total': t.total, - 'points_earned': cart.pointsEarned, - 'points_redeemed': cart.pointsRedeemed, - 'payments_json': jsonEncode([ - for (final p in t.payments) - { - 'method': p.method.name, - 'amount': p.amount, - 'tendered': p.tendered, - 'reference': p.reference, - }, - ]), - 'status': t.status.name, - 'sync_status': pending, - 'sync_attempts': 0, - }, - conflictAlgorithm: ConflictAlgorithm.replace, - ); + await _insertOrder(txn, transaction); final batch = txn.batch(); - for (final line in cart.lines) { - batch.insert(Tables.orderItems, { - 'order_id': t.id, - 'product_id': line.product.id, - 'name': line.product.name, - 'barcode': line.product.barcode, - 'sku': line.product.sku, - 'unit': line.product.unit.name, - 'unit_price': line.product.price, - 'quantity': line.quantity, - 'discount': line.discountAmount, - 'gst_rate': line.product.gstRate, - 'tax_amount': line.taxAmount, - 'line_total': line.payable, - }); + final now = DateTime.now().millisecondsSinceEpoch; + stockMovements.forEach((id, qty) { + batch.rawUpdate( + 'UPDATE ${Tables.products} ' + 'SET stock = MAX(0, stock - ?), updated_at = ? WHERE id = ?', + [qty, now, id], + ); + }); + if (customerRow != null) { + batch.insert( + Tables.customers, + customerRow, + conflictAlgorithm: ConflictAlgorithm.replace, + ); } await batch.commit(noResult: true); }); } + Future _insertOrder(DatabaseExecutor txn, SaleTransaction t) async { + final cart = t.cart; + + // A replace of the order row does not reliably cascade to its lines, so + // clear them first — otherwise re-saving a bill doubles its items. + await txn.delete(Tables.orderItems, where: 'order_id = ?', whereArgs: [t.id]); + + await txn.insert( + Tables.orders, + { + 'id': t.id, + 'invoice_number': t.invoiceNumber, + 'created_at': t.createdAt.millisecondsSinceEpoch, + 'business_date': businessDateOf(t.createdAt), + 'cashier_name': t.cashierName, + 'terminal_id': t.terminalId, + 'customer_id': cart.customer?.id, + 'customer_mobile': cart.customer?.mobile, + 'customer_name': cart.customer?.name, + 'subtotal': cart.subtotal, + 'line_discount': cart.lineDiscountTotal, + 'bill_discount': cart.billDiscountTotal, + 'loyalty_value': cart.loyaltyRedemptionValue, + 'taxable_amount': cart.taxableAmount, + 'tax_amount': cart.taxAmount, + 'round_off': cart.roundOff, + 'total': t.total, + 'points_earned': t.pointsEarned, + 'points_redeemed': cart.pointsRedeemed, + 'payments_json': jsonEncode([ + for (final p in t.payments) + { + 'method': p.method.name, + 'amount': p.amount, + 'tendered': p.tendered, + 'reference': p.reference, + }, + ]), + 'status': t.status.name, + 'sync_status': pending, + 'sync_attempts': 0, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + + final batch = txn.batch(); + for (final line in cart.lines) { + batch.insert(Tables.orderItems, { + 'order_id': t.id, + 'product_id': line.product.id, + 'name': line.product.name, + 'barcode': line.product.barcode, + 'sku': line.product.sku, + 'unit': line.product.unit.name, + 'unit_price': line.product.price, + 'quantity': line.quantity, + 'discount': line.discountAmount, + 'gst_rate': line.product.gstRate, + 'tax_amount': line.taxAmount, + 'line_total': line.payable, + }); + } + await batch.commit(noResult: true); + } + // ------------------------------------------------------------------ Read Future> recent({int limit = 100}) => _query(orderBy: 'created_at DESC', limit: limit); - Future> forBusinessDate(DateTime day) => - _query(where: 'business_date = ?', whereArgs: [businessDateOf(day)]); + /// Bills for a day, optionally narrowed to one operator. + /// + /// A shift report that is settled against a till has to cover exactly the + /// bills that cashier rang, not everything the terminal did that day. + Future> forBusinessDate( + DateTime day, { + String? cashierName, + }) => + _query( + where: cashierName == null + ? 'business_date = ?' + : 'business_date = ? AND cashier_name = ?', + whereArgs: [ + businessDateOf(day), + if (cashierName != null) cashierName, + ], + ); /// The end-of-day upload set. Future> unsynced({int limit = 500}) => _query( @@ -124,6 +175,25 @@ class OrderDao { return r.first['c']! as int; } + /// Stock already consumed by bills that have not yet reached the server. + /// + /// A re-import overwrites stock with the server's figure, which does not know + /// about these sales. Replaying them keeps the shelf count honest when the + /// catalogue is pulled again mid-shift. + Future> unsyncedStockCommitments() async { + final rows = await _db.rawQuery( + 'SELECT i.product_id AS pid, SUM(i.quantity) AS q ' + 'FROM ${Tables.orderItems} i ' + 'JOIN ${Tables.orders} o ON o.id = i.order_id ' + 'WHERE o.sync_status = ? GROUP BY i.product_id', + [pending], + ); + return { + for (final r in rows) + r['pid']! as String: (r['q']! as num).toDouble(), + }; + } + Future salesTotalForDay(DateTime day) async { final r = await _db.rawQuery( 'SELECT COALESCE(SUM(total), 0) AS t FROM ${Tables.orders} ' @@ -182,19 +252,23 @@ class OrderDao { if (orders.isEmpty) return; await _db.transaction((txn) async { - final byDate = >{}; + // Grouped by cashier as well as day: the archive is what a till is + // settled against once the bills themselves are gone. + final byDate = <({String date, String cashier}), List>{}; for (final o in orders) { - byDate.putIfAbsent(businessDateOf(o.createdAt), () => []).add(o); + final key = (date: businessDateOf(o.createdAt), cashier: o.cashierName); + byDate.putIfAbsent(key, () => []).add(o); } for (final entry in byDate.entries) { - final date = entry.key; + final date = entry.key.date; + final cashier = entry.key.cashier; final batchOrders = entry.value; final prior = await txn.query( Tables.dayArchive, - where: 'business_date = ?', - whereArgs: [date], + where: 'business_date = ? AND cashier_name = ?', + whereArgs: [date, cashier], limit: 1, ); final existing = prior.isEmpty ? null : prior.first; @@ -221,6 +295,7 @@ class OrderDao { Tables.dayArchive, { 'business_date': date, + 'cashier_name': cashier, 'bill_count': ((existing?['bill_count'] as int?) ?? 0) + batchOrders.length, 'item_count': ((existing?['item_count'] as num?)?.toDouble() ?? 0) + @@ -267,16 +342,24 @@ class OrderDao { }); } - /// Archived figures for a business day, or null if nothing has synced yet. - Future?> dayArchive(DateTime day) async { - final rows = await _db.query( - Tables.dayArchive, - where: 'business_date = ?', - whereArgs: [businessDateOf(day)], - limit: 1, - ); - return rows.isEmpty ? null : rows.first; - } + /// Archived figures for a business day, one row per cashier. + /// + /// Empty when nothing has synced yet. Pass [cashierName] to scope it to a + /// single operator; omit it for the whole terminal. + Future>> dayArchive( + DateTime day, { + String? cashierName, + }) => + _db.query( + Tables.dayArchive, + where: cashierName == null + ? 'business_date = ?' + : 'business_date = ? AND cashier_name = ?', + whereArgs: [ + businessDateOf(day), + if (cashierName != null) cashierName, + ], + ); /// Records a failed attempt. The rows stay at `sync_status = 0`. Future markFailed(List orderIds, String error) async { @@ -316,7 +399,7 @@ class OrderDao { cart: _cartFromJson( jsonDecode(r['cart_json']! as String) as Map, ), - )) + ),) .toList(); } @@ -389,6 +472,9 @@ class OrderDao { }).toList(); final customerId = o['customer_id'] as String?; + // Rebuilt with no lifetime spend on purpose: the tier discount this shopper + // earned is already included in the recorded `bill_discount`, so giving the + // reconstruction a tier would apply it a second time. final customer = customerId == null ? null : Customer( @@ -404,18 +490,37 @@ class OrderDao { amount: (p['amount']! as num).toDouble(), tendered: (p['tendered'] as num?)?.toDouble(), reference: p['reference'] as String?, - )) + ),) .toList(); + // Bill-level reductions live on the order row, not on the lines, so they + // have to be put back explicitly. Without this the rebuilt cart bills at + // the undiscounted subtotal and every downstream figure — the upload + // payload, the day archive, the shift report — is overstated. + final billDiscount = (o['bill_discount']! as num).toDouble(); + return SaleTransaction( id: o['id']! as String, invoiceNumber: o['invoice_number']! as String, - cart: Cart(lines: lines, customer: customer), + cart: Cart( + lines: lines, + customer: customer, + billDiscount: billDiscount > 0 + ? Discount( + type: DiscountType.flat, + value: billDiscount, + reason: 'Bill discount', + ) + : Discount.none, + pointsRedeemed: (o['points_redeemed'] as int?) ?? 0, + ), payments: payments, createdAt: DateTime.fromMillisecondsSinceEpoch(o['created_at']! as int), cashierName: o['cashier_name']! as String, terminalId: o['terminal_id']! as String, status: TransactionStatus.values.byName(o['status']! as String), + storedTotal: (o['total']! as num).toDouble(), + storedPointsEarned: (o['points_earned'] as int?) ?? 0, ); } diff --git a/lib/data/local/sync_log_dao.dart b/lib/data/local/sync_log_dao.dart new file mode 100644 index 0000000..a8ba3aa --- /dev/null +++ b/lib/data/local/sync_log_dao.dart @@ -0,0 +1,75 @@ +import 'dart:convert'; + +import 'package:sqflite/sqflite.dart'; + +import '../../domain/entities/sync_event.dart'; +import 'app_database.dart'; + +/// Persists the history of this terminal's conversations with the server. +/// +/// Held on disk rather than in memory because it is the only record of a sync +/// that survives the bills themselves: once the server accepts an order the row +/// is deleted from the terminal, so without this the evidence that it ever went +/// up disappears at the next restart. +class SyncLogDao { + const SyncLogDao(this._db); + + final Database _db; + + Future insert(SyncEvent e) async { + await _db.insert( + Tables.syncLog, + { + 'id': e.id, + 'type': e.type.name, + 'status': e.status.name, + 'created_at': e.createdAt.millisecondsSinceEpoch, + 'synced_at': e.syncedAt?.millisecondsSinceEpoch, + 'summary': e.summary, + 'error': e.error, + 'attempts': e.attempts, + 'payload_json': e.payload.isEmpty ? null : jsonEncode(e.payload), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + /// Newest first, which is the order the events log renders. + Future> recent({int limit = 200}) async { + final rows = await _db.query( + Tables.syncLog, + orderBy: 'created_at DESC', + limit: limit, + ); + return rows.map(_fromRow).toList(); + } + + /// Keeps the log from growing without bound on a terminal that runs for + /// months. Only ever trims the oldest entries. + Future trim({int keep = 500}) async { + await _db.rawDelete( + 'DELETE FROM ${Tables.syncLog} WHERE id NOT IN (' + 'SELECT id FROM ${Tables.syncLog} ORDER BY created_at DESC LIMIT ?)', + [keep], + ); + } + + SyncEvent _fromRow(Map r) { + final payload = r['payload_json'] as String?; + return SyncEvent( + id: r['id']! as String, + type: SyncEventType.values.byName(r['type']! as String), + status: SyncStatus.values.byName(r['status']! as String), + createdAt: DateTime.fromMillisecondsSinceEpoch(r['created_at']! as int), + summary: r['summary']! as String, + payload: payload == null + ? const {} + : (jsonDecode(payload) as Map).cast(), + syncedAt: r['synced_at'] == null + ? null + : DateTime.fromMillisecondsSinceEpoch(r['synced_at']! as int), + error: r['error'] as String?, + attempts: (r['attempts'] as int?) ?? 0, + ); + } +} diff --git a/lib/data/repositories/customer_repository_impl.dart b/lib/data/repositories/customer_repository_impl.dart index 1ab993a..7e931f4 100644 --- a/lib/data/repositories/customer_repository_impl.dart +++ b/lib/data/repositories/customer_repository_impl.dart @@ -53,44 +53,26 @@ class CustomerRepositoryImpl implements CustomerRepository { return customer; } - @override - Future recordSale({ - required String customerId, - required double amount, - required int pointsEarned, - required int pointsRedeemed, - }) async { - final current = _store.customerById(customerId); - if (current == null) { - throw StateError('Customer $customerId not found.'); - } - final updated = current.copyWith( - loyaltyPoints: - (current.loyaltyPoints - pointsRedeemed + pointsEarned) - .clamp(0, 1 << 31), - lifetimeSpend: (current.lifetimeSpend + amount).asMoney, - visitCount: current.visitCount + 1, - lastVisitAt: DateTime.now(), - ); - await _store.putCustomer(updated); - return updated; - } - @override Future> search(String query) async { final q = query.trim().toLowerCase(); if (q.isEmpty) return recent(); - return _store.customers - .where((c) => - c.name.toLowerCase().contains(q) || _digits(c.mobile).contains(q)) - .toList(); + + // Stored numbers are digits only, so the query has to be reduced the same + // way — otherwise a cashier typing "98765 43210" or "98-76" matches nothing. + final digits = _digits(q); + + return _store.customers.where((c) { + if (c.name.toLowerCase().contains(q)) return true; + return digits.isNotEmpty && _digits(c.mobile).contains(digits); + }).toList(); } @override Future> recent({int limit = 20}) async { final list = _store.customers.toList() ..sort((a, b) => (b.lastVisitAt ?? DateTime(2000)) - .compareTo(a.lastVisitAt ?? DateTime(2000))); + .compareTo(a.lastVisitAt ?? DateTime(2000)),); return list.take(limit).toList(); } } diff --git a/lib/data/repositories/sync_repository_impl.dart b/lib/data/repositories/sync_repository_impl.dart index 4b1d9c4..3a2d312 100644 --- a/lib/data/repositories/sync_repository_impl.dart +++ b/lib/data/repositories/sync_repository_impl.dart @@ -20,9 +20,8 @@ class SyncRepositoryImpl implements SyncRepository { static const _uuid = Uuid(); - /// In-session event log. Order sync state itself lives on the order rows, - /// so this is only a human-readable history of attempts. - final List _events = []; + /// Human-readable history of sync attempts, backed by the `sync_log` table. + /// Order sync state itself lives on the order rows. @override bool get hasCatalogue => _store.hasCatalogue; @@ -34,9 +33,9 @@ class SyncRepositoryImpl implements SyncRepository { String? get catalogueRevision => _store.catalogueRevision; @override - List get events => List.unmodifiable(_events.reversed); + List get events => _store.syncEvents; - void _log(SyncEvent e) => _events.add(e); + Future _log(SyncEvent e) => _store.appendSyncEvent(e); // ------------------------------------------------------- Morning: import @override @@ -71,7 +70,7 @@ class SyncRepositoryImpl implements SyncRepository { }, attempts: 1, ); - _log(event); + await _log(event); return event; } catch (e) { final event = SyncEvent( @@ -83,7 +82,7 @@ class SyncRepositoryImpl implements SyncRepository { error: e.toString(), attempts: 1, ); - _log(event); + await _log(event); return event; } } @@ -99,39 +98,45 @@ class SyncRepositoryImpl implements SyncRepository { Future todayReport({ required String terminalId, required String cashierName, + bool scopeToCashier = false, }) async { final today = DateTime.now(); + final scope = scopeToCashier ? cashierName : null; // Bills still held locally. - final live = ShiftReport.fromTransactions( - transactions: await _store.orders.forBusinessDate(today), + var report = ShiftReport.fromTransactions( + transactions: + await _store.orders.forBusinessDate(today, cashierName: scope), businessDate: today, terminalId: terminalId, cashierName: cashierName, ); - // Bills already uploaded and deleted survive only as archived totals. - final row = await _store.orders.dayArchive(today); - if (row == null) return live; + // Bills already uploaded and deleted survive only as archived totals, one + // row per cashier. Unscoped, every operator's row folds into the total. + final rows = await _store.orders.dayArchive(today, cashierName: scope); - final payments = (jsonDecode(row['payments_json']! as String) - as Map) - .map( - (k, v) => MapEntry( - PaymentMethod.values.byName(k), - (v! as num).toDouble(), - ), - ); + for (final row in rows) { + final payments = (jsonDecode(row['payments_json']! as String) + as Map) + .map( + (k, v) => MapEntry( + PaymentMethod.values.byName(k), + (v! as num).toDouble(), + ), + ); - final archived = ShiftReport.fromArchive( - row: row, - payments: payments, - businessDate: today, - terminalId: terminalId, - cashierName: cashierName, - ); + report = ShiftReport.fromArchive( + row: row, + payments: payments, + businessDate: today, + terminalId: terminalId, + cashierName: cashierName, + ) + + report; + } - return archived + live; + return report; } @override @@ -150,7 +155,7 @@ class SyncRepositoryImpl implements SyncRepository { : DateTime.fromMillisecondsSinceEpoch(r['synced_at']! as int), attempts: (r['sync_attempts'] as int?) ?? 0, error: r['sync_error'] as String?, - )) + ),) .toList(); } @@ -161,71 +166,99 @@ class SyncRepositoryImpl implements SyncRepository { }) async { onProgress?.call(0.05, 'Collecting unsynced bills…'); - final pending = await _store.orders.unsynced(); - if (pending.isEmpty) { + final started = DateTime.now(); + final total = await _store.orders.unsyncedCount(); + + if (total == 0) { onProgress?.call(1, 'Nothing to upload'); return const SyncOutcome(attempted: 0, uploaded: 0); } - final started = DateTime.now(); - final ids = pending.map((o) => o.id).toList(); + var attempted = 0; + var uploaded = 0; + final syncedInvoices = []; - onProgress?.call(0.35, 'Uploading ${pending.length} bills…'); + // `unsynced()` returns a bounded page. Draining it in a loop means a day + // with more bills than one page still uploads completely, instead of + // reporting success with the remainder silently left behind. + while (true) { + final batch = await _store.orders.unsynced(); + if (batch.isEmpty) break; - try { - final accepted = await _orderSink.pushOrders( - pending.map(_orderToPayload).toList(), + final ids = batch.map((o) => o.id).toList(); + attempted += batch.length; + + onProgress?.call( + (attempted / total).clamp(0.05, 0.9), + 'Uploading $attempted of $total bills…', ); - onProgress?.call(0.85, 'Clearing uploaded bills from this terminal…'); + try { + final accepted = await _orderSink.pushOrders( + batch.map(_orderToPayload).toList(), + ); - // Only what the server confirmed is archived and removed. Anything it - // did not acknowledge stays on disk. - final acceptedOrders = - pending.where((o) => accepted.contains(o.id)).toList(); - await _store.orders.archiveAndDelete(acceptedOrders); - await _store.refreshUnsyncedCount(); + // Only what the server confirmed is archived and removed. Anything it + // did not acknowledge stays on disk. + final acceptedOrders = + batch.where((o) => accepted.contains(o.id)).toList(); + await _store.orders.archiveAndDelete(acceptedOrders); + await _store.refreshUnsyncedCount(); - final rejected = ids.where((id) => !accepted.contains(id)).toList(); - if (rejected.isNotEmpty) { - await _store.orders.markFailed(rejected, 'Rejected by server'); + uploaded += acceptedOrders.length; + syncedInvoices.addAll(acceptedOrders.map((o) => o.invoiceNumber)); + + final rejected = ids.where((id) => !accepted.contains(id)).toList(); + if (rejected.isNotEmpty) { + await _store.orders.markFailed(rejected, 'Rejected by server'); + // Rejected rows stay pending, so the next page would return the same + // bills forever. Stop and let the cashier retry. + break; + } + } catch (e) { + // Transport failed: record the attempt but leave every row at 0. + await _store.orders.markFailed(ids, e.toString()); + await _store.refreshUnsyncedCount(); + + final remaining = await _store.orders.unsyncedCount(); + final pendingValue = + batch.fold(0, (s, o) => s + o.total); + + await _log(SyncEvent( + id: _uuid.v4(), + type: SyncEventType.shiftReport, + status: SyncStatus.failed, + createdAt: started, + summary: '$remaining bills still pending ' + '(${Formatters.money(pendingValue)})', + error: e.toString(), + attempts: 1, + ),); + + return SyncOutcome( + attempted: attempted, + uploaded: uploaded, + error: e.toString(), + ); } - - onProgress?.call(1, 'Done'); - - _log(SyncEvent( - id: _uuid.v4(), - type: SyncEventType.shiftReport, - status: SyncStatus.synced, - createdAt: started, - syncedAt: DateTime.now(), - summary: '${accepted.length} of ${pending.length} bills uploaded', - attempts: 1, - )); - - return SyncOutcome(attempted: pending.length, uploaded: accepted.length); - } catch (e) { - // Transport failed: record the attempt but leave every row at 0. - await _store.orders.markFailed(ids, e.toString()); - await _store.refreshUnsyncedCount(); - - _log(SyncEvent( - id: _uuid.v4(), - type: SyncEventType.shiftReport, - status: SyncStatus.failed, - createdAt: started, - summary: '${pending.length} bills still pending ' - '(${Formatters.money(pending.fold(0, (s, o) => s + o.total))})', - error: e.toString(), - attempts: 1, - )); - - return SyncOutcome( - attempted: pending.length, - uploaded: 0, - error: e.toString(), - ); } + + onProgress?.call(1, 'Done'); + + // Synced bills are deleted from the terminal, so this log line is the only + // remaining record on the device that they went up. + await _log(SyncEvent( + id: _uuid.v4(), + type: SyncEventType.shiftReport, + status: SyncStatus.synced, + createdAt: started, + syncedAt: DateTime.now(), + summary: '$uploaded of $attempted bills uploaded', + payload: {'invoices': syncedInvoices}, + attempts: 1, + ),); + + return SyncOutcome(attempted: attempted, uploaded: uploaded); } /// The JSON body sent per order. diff --git a/lib/data/repositories/transaction_repository_impl.dart b/lib/data/repositories/transaction_repository_impl.dart index 5bd390a..66eb744 100644 --- a/lib/data/repositories/transaction_repository_impl.dart +++ b/lib/data/repositories/transaction_repository_impl.dart @@ -1,6 +1,8 @@ +import '../../domain/entities/customer.dart'; import '../../domain/entities/transaction.dart'; import '../../domain/repositories/transaction_repository.dart'; import '../datasources/local_store.dart'; +import '../local/catalogue_dao.dart'; /// All bill persistence goes straight to SQLite. class TransactionRepositoryImpl implements TransactionRepository { @@ -9,11 +11,23 @@ class TransactionRepositoryImpl implements TransactionRepository { final LocalStore _store; @override - Future save(SaleTransaction transaction) async { - // Written with sync_status = 0; the end-of-day upload picks it up. - await _store.orders.insertOrder(transaction); + Future commitSale({ + required SaleTransaction transaction, + required Map stockMovements, + Customer? updatedCustomer, + }) async { + await _store.orders.commitSale( + transaction: transaction, + stockMovements: stockMovements, + customerRow: updatedCustomer == null + ? null + : CatalogueDao.customerToRow(updatedCustomer), + ); + + // Disk is committed; bring the read caches in line with it. + _store.cacheStockMovement(stockMovements); + if (updatedCustomer != null) _store.cacheCustomer(updatedCustomer); await _store.refreshUnsyncedCount(); - return transaction; } @override diff --git a/lib/domain/entities/cart.dart b/lib/domain/entities/cart.dart index 7155b36..59d0a35 100644 --- a/lib/domain/entities/cart.dart +++ b/lib/domain/entities/cart.dart @@ -174,11 +174,29 @@ class Cart extends Equatable { double get taxableAmount => (netAmount - taxAmount).asMoney; /// GST broken out per slab — required on a compliant tax invoice. + /// + /// The slabs are reconciled against [taxAmount] before being returned. + /// Rounding each slab on its own leaves the parts summing to a paisa either + /// side of the total printed on the same bill, which a tax invoice cannot + /// show; the residue is absorbed by the largest slab. Map get taxBreakdown { - final map = {}; + final raw = {}; for (final line in lines) { final rate = line.product.gstRate; - map[rate] = ((map[rate] ?? 0) + line.taxAmount * _billFactor).asMoney; + raw[rate] = (raw[rate] ?? 0) + line.taxAmount * _billFactor; + } + if (raw.isEmpty) return const {}; + + final map = { + for (final e in raw.entries) e.key: e.value.asMoney, + }; + + final drift = + (taxAmount - map.values.fold(0.0, (a, b) => a + b)).asMoney; + if (drift != 0) { + final largest = + raw.entries.reduce((a, b) => a.value >= b.value ? a : b).key; + map[largest] = (map[largest]! + drift).asMoney; } return map; } diff --git a/lib/domain/entities/customer.dart b/lib/domain/entities/customer.dart index 7fb1f31..e5a80da 100644 --- a/lib/domain/entities/customer.dart +++ b/lib/domain/entities/customer.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; import '../../core/constants/app_constants.dart'; +import '../../core/utils/extensions.dart'; enum Gender { male('Male'), @@ -93,6 +94,26 @@ class Customer extends Equatable { return dob.month == now.month && dob.day == now.day; } + /// The shopper as they stand after a completed sale. + /// + /// Pure, so the caller can compute the new row and persist it in the same + /// transaction as the bill rather than as a separate write that might fail + /// on its own. + Customer applySale({ + required double amount, + required int pointsEarned, + required int pointsRedeemed, + DateTime? at, + }) { + return copyWith( + loyaltyPoints: + (loyaltyPoints - pointsRedeemed + pointsEarned).clamp(0, 1 << 31), + lifetimeSpend: (lifetimeSpend + amount).asMoney, + visitCount: visitCount + 1, + lastVisitAt: at ?? DateTime.now(), + ); + } + Customer copyWith({ String? name, String? email, diff --git a/lib/domain/entities/shift_report.dart b/lib/domain/entities/shift_report.dart index 4f4e03c..53f3b31 100644 --- a/lib/domain/entities/shift_report.dart +++ b/lib/domain/entities/shift_report.dart @@ -88,7 +88,7 @@ class ShiftReport extends Equatable { t.status == TransactionStatus.completed && t.createdAt.year == businessDate.year && t.createdAt.month == businessDate.month && - t.createdAt.day == businessDate.day) + t.createdAt.day == businessDate.day,) .toList() ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); @@ -111,7 +111,7 @@ class ShiftReport extends Equatable { completed.fold(0.0, (s, t) => s + t.cart.taxAmount).asMoney, discountGiven: completed .fold(0.0, - (s, t) => s + t.cart.billDiscountTotal + t.cart.lineDiscountTotal) + (s, t) => s + t.cart.billDiscountTotal + t.cart.lineDiscountTotal,) .asMoney, roundOff: completed.fold(0.0, (s, t) => s + t.cart.roundOff).asMoney, paymentBreakdown: byMethod, diff --git a/lib/domain/entities/transaction.dart b/lib/domain/entities/transaction.dart index 03c18c4..e2607f9 100644 --- a/lib/domain/entities/transaction.dart +++ b/lib/domain/entities/transaction.dart @@ -70,6 +70,8 @@ class SaleTransaction extends Equatable { required this.cashierName, this.status = TransactionStatus.completed, this.terminalId = 'TERM-01', + this.storedTotal, + this.storedPointsEarned, }); final String id; @@ -81,9 +83,20 @@ class SaleTransaction extends Equatable { final TransactionStatus status; final String terminalId; + /// The figure actually charged, as recorded at sale time. + /// + /// Set only when a bill is read back from storage. Deriving the total from + /// [cart] is correct for a live sale, but a rehydrated cart is a + /// reconstruction — if it ever loses a component the money must not move with + /// it. The recorded value wins whenever there is one. + final double? storedTotal; + + /// Points issued by this sale, as recorded at sale time. See [storedTotal]. + final int? storedPointsEarned; + Customer? get customer => cart.customer; - double get total => cart.grandTotal; + double get total => storedTotal ?? cart.grandTotal; double get amountPaid => payments.fold(0.0, (sum, p) => sum + p.amount).asMoney; @@ -101,7 +114,7 @@ class SaleTransaction extends Equatable { bool get isSplit => payments.length > 1; - int get pointsEarned => cart.pointsEarned; + int get pointsEarned => storedPointsEarned ?? cart.pointsEarned; int get pointsRedeemed => cart.pointsRedeemed; String get paymentSummary => diff --git a/lib/domain/repositories/customer_repository.dart b/lib/domain/repositories/customer_repository.dart index ba343a5..d9e9787 100644 --- a/lib/domain/repositories/customer_repository.dart +++ b/lib/domain/repositories/customer_repository.dart @@ -11,12 +11,6 @@ abstract class CustomerRepository { Future update(Customer customer); /// Applies loyalty and lifetime-spend changes once a sale completes. - Future recordSale({ - required String customerId, - required double amount, - required int pointsEarned, - required int pointsRedeemed, - }); Future> search(String query); diff --git a/lib/domain/repositories/sync_repository.dart b/lib/domain/repositories/sync_repository.dart index ba3d591..e5872b0 100644 --- a/lib/domain/repositories/sync_repository.dart +++ b/lib/domain/repositories/sync_repository.dart @@ -62,9 +62,13 @@ abstract class SyncRepository { Future> unsyncedOrders(); /// Today's trading totals, read back from SQLite. + /// + /// Terminal-wide by default. Set [scopeToCashier] to cover only the bills + /// [cashierName] rang — what a till is actually settled against. Future todayReport({ required String terminalId, required String cashierName, + bool scopeToCashier = false, }); /// End-of-day step — uploads pending orders and flips the accepted ones to diff --git a/lib/domain/repositories/transaction_repository.dart b/lib/domain/repositories/transaction_repository.dart index 4fee42d..262ed31 100644 --- a/lib/domain/repositories/transaction_repository.dart +++ b/lib/domain/repositories/transaction_repository.dart @@ -1,7 +1,17 @@ +import '../entities/customer.dart'; import '../entities/transaction.dart'; abstract class TransactionRepository { - Future save(SaleTransaction transaction); + /// Persists a completed sale as one atomic unit. + /// + /// The bill, the stock it consumed and the shopper's loyalty movement either + /// all land or none do, so a failure part-way through can never leave a + /// persisted bill that the cashier believes failed. + Future commitSale({ + required SaleTransaction transaction, + required Map stockMovements, + Customer? updatedCustomer, + }); Future> history({int limit = 50}); diff --git a/lib/domain/usecases/checkout_sale.dart b/lib/domain/usecases/checkout_sale.dart index e9746b5..63a696c 100644 --- a/lib/domain/usecases/checkout_sale.dart +++ b/lib/domain/usecases/checkout_sale.dart @@ -51,10 +51,33 @@ class CheckoutSale { required Cart cart, required List payments, required String cashierName, + String terminalId = 'TERM-01', }) async { _validate(cart, payments); + await _assertStockAvailable(cart); final now = DateTime.now(); + + // The shopper's new balance is computed before anything is written, so it + // can be persisted in the same transaction as the bill. + Customer? updatedCustomer; + final customer = cart.customer; + if (customer != null) { + final current = await _customers.findById(customer.id); + if (current == null) { + throw const CheckoutFailure( + 'This customer is no longer on file. Remove them from the bill to ' + 'continue.', + ); + } + updatedCustomer = current.applySale( + amount: cart.grandTotal, + pointsEarned: cart.pointsEarned, + pointsRedeemed: cart.pointsRedeemed, + at: now, + ); + } + final sequence = await _transactions.nextInvoiceSequence(); final transaction = SaleTransaction( @@ -64,24 +87,16 @@ class CheckoutSale { payments: payments, createdAt: now, cashierName: cashierName, + terminalId: terminalId, ); - await _transactions.save(transaction); - - await _products.decrementStock({ - for (final line in cart.lines) line.product.id: line.quantity, - }); - - Customer? updatedCustomer; - final customer = cart.customer; - if (customer != null) { - updatedCustomer = await _customers.recordSale( - customerId: customer.id, - amount: cart.grandTotal, - pointsEarned: cart.pointsEarned, - pointsRedeemed: cart.pointsRedeemed, - ); - } + await _transactions.commitSale( + transaction: transaction, + stockMovements: { + for (final line in cart.lines) line.product.id: line.quantity, + }, + updatedCustomer: updatedCustomer, + ); return CheckoutResult( transaction: transaction, @@ -89,6 +104,29 @@ class CheckoutSale { ); } + /// Re-checks every line against live stock. + /// + /// [CartLine.exceedsStock] reads the product snapshot taken when the item was + /// added, which goes stale the moment anything else sells the same item — a + /// parked bill resumed after its stock was sold would otherwise pass + /// validation and oversell. + Future _assertStockAvailable(Cart cart) async { + for (final line in cart.lines) { + final live = await _products.findById(line.product.id); + if (live == null) { + throw CheckoutFailure( + '${line.product.name} is no longer in the catalogue.', + ); + } + if (line.quantity > live.stock) { + throw CheckoutFailure( + 'Only ${live.stock.toStringAsFixed(0)} ${live.unit.symbol} of ' + '${live.name} in stock.', + ); + } + } + } + void _validate(Cart cart, List payments) { if (cart.isEmpty) { throw const CheckoutFailure('Add at least one item before charging.'); diff --git a/lib/main.dart b/lib/main.dart index 04bfdca..e2be30b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -76,12 +76,12 @@ class _StartupFailureApp extends StatelessWidget { Container( width: 56, height: 56, - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.dangerSurface, borderRadius: AppRadius.brMd, ), child: const Icon(Icons.error_outline_rounded, - color: AppColors.danger, size: 28), + color: AppColors.danger, size: 28,), ), const SizedBox(height: AppSpacing.xl), const Text( diff --git a/lib/presentation/auth/screens/login_screen.dart b/lib/presentation/auth/screens/login_screen.dart index b98e6f0..4386203 100644 --- a/lib/presentation/auth/screens/login_screen.dart +++ b/lib/presentation/auth/screens/login_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -408,14 +407,14 @@ class _FormPanel extends ConsumerWidget { const SizedBox(height: AppSpacing.sm), Container( padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.dangerSurface, borderRadius: AppRadius.brSm, ), child: Row( children: [ const Icon(Icons.error_outline_rounded, - color: AppColors.danger, size: 18), + color: AppColors.danger, size: 18,), const SizedBox(width: AppSpacing.sm), Expanded( child: Text( @@ -509,13 +508,13 @@ class _DemoHint extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.info_outline_rounded, - size: 17, color: AppColors.primary), + size: 17, color: AppColors.primary,), const SizedBox(width: AppSpacing.sm), - Expanded( + const Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + Text( 'Demo account', style: TextStyle( fontSize: 12.5, @@ -523,10 +522,10 @@ class _DemoHint extends StatelessWidget { color: AppColors.primary, ), ), - const SizedBox(height: 2), + SizedBox(height: 2), SelectableText( '${DemoCredentials.email} · ${DemoCredentials.password}', - style: const TextStyle( + style: TextStyle( fontSize: 12, color: AppColors.textSecondary, ), diff --git a/lib/presentation/customer/widgets/customer_capture_sheet.dart b/lib/presentation/customer/widgets/customer_capture_sheet.dart index 7cf6f71..4e120bc 100644 --- a/lib/presentation/customer/widgets/customer_capture_sheet.dart +++ b/lib/presentation/customer/widgets/customer_capture_sheet.dart @@ -174,7 +174,7 @@ class _CustomerCaptureSheetState width: 40, height: 4, margin: const EdgeInsets.symmetric(vertical: AppSpacing.md), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.border, borderRadius: AppRadius.brPill, ), @@ -378,7 +378,7 @@ class _CustomerCaptureSheetState children: [ Container( padding: const EdgeInsets.all(AppSpacing.lg), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.successSurface, borderRadius: AppRadius.brLg, ), @@ -433,7 +433,7 @@ class _CustomerCaptureSheetState children: [ Expanded( child: _miniStat( - '${c.loyaltyPoints}', 'points held'), + '${c.loyaltyPoints}', 'points held',), ), Expanded( child: _miniStat( diff --git a/lib/presentation/modules/screens/customers_view.dart b/lib/presentation/modules/screens/customers_view.dart index a28e382..e489a62 100644 --- a/lib/presentation/modules/screens/customers_view.dart +++ b/lib/presentation/modules/screens/customers_view.dart @@ -195,9 +195,9 @@ class _CustomersViewState extends ConsumerState { StatusPill.tier(c.tier, dense: true), Cell('${c.loyaltyPoints}', mono: true), Cell(Formatters.moneyCompact(c.lifetimeSpend), - mono: true, bold: true), + mono: true, bold: true,), Cell('${c.visitCount}', mono: true), - ]) + ],) .toList(), ), ], diff --git a/lib/presentation/modules/screens/events_view.dart b/lib/presentation/modules/screens/events_view.dart index 22debde..ac7eb21 100644 --- a/lib/presentation/modules/screens/events_view.dart +++ b/lib/presentation/modules/screens/events_view.dart @@ -146,7 +146,7 @@ class EventsView extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.shield_outlined, - size: 15, color: AppColors.textTertiary), + size: 15, color: AppColors.textTertiary,), SizedBox(width: AppSpacing.sm), Expanded( child: Text( @@ -181,14 +181,14 @@ class EventsView extends ConsumerWidget { .map((o) => [ Cell(o.invoiceNumber, bold: true, mono: true), Cell(Formatters.time(o.createdAt), - color: AppColors.textTertiary), + color: AppColors.textTertiary,), Cell(Formatters.money(o.total), mono: true, bold: true), TagChip( o.isSynced ? 'Synced' : 'Pending', color: o.isSynced ? AppColors.success : AppColors.warning, ), - ]) + ],) .toList(), ), ), @@ -227,8 +227,8 @@ class EventsView extends ConsumerWidget { : AppColors.textSecondary, ), Cell(Formatters.time(e.createdAt), - color: AppColors.textTertiary), - ]) + color: AppColors.textTertiary,), + ],) .toList(), ), ), diff --git a/lib/presentation/modules/screens/product_import_view.dart b/lib/presentation/modules/screens/product_import_view.dart index 830b4ce..a0f8c68 100644 --- a/lib/presentation/modules/screens/product_import_view.dart +++ b/lib/presentation/modules/screens/product_import_view.dart @@ -102,14 +102,14 @@ class ProductImportView extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ Text(p.emoji, - style: const TextStyle(fontSize: 17)), + style: const TextStyle(fontSize: 17),), const SizedBox(width: AppSpacing.sm), Flexible(child: Cell(p.name, bold: true)), ], ), Cell(p.sku, color: AppColors.textTertiary), TagChip(p.category.label, - color: AppColors.textSecondary), + color: AppColors.textSecondary,), Cell(Formatters.money(p.price), mono: true, bold: true), TagChip( p.isOutOfStock @@ -121,7 +121,7 @@ class ProductImportView extends ConsumerWidget { ? AppColors.warning : AppColors.success), ), - ]) + ],) .toList(), ), ), @@ -146,13 +146,13 @@ class _NotImportedBanner extends StatelessWidget { borderRadius: AppRadius.brLg, border: Border.all(color: AppColors.warning.withValues(alpha: 0.35)), ), - child: Row( + child: const Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.cloud_download_outlined, - color: AppColors.warning, size: 22), - const SizedBox(width: AppSpacing.md), - const Expanded( + Icon(Icons.cloud_download_outlined, + color: AppColors.warning, size: 22,), + SizedBox(width: AppSpacing.md), + Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -225,7 +225,7 @@ class _ImportPanel extends ConsumerWidget { Container( padding: const EdgeInsets.all(AppSpacing.md), margin: const EdgeInsets.only(bottom: AppSpacing.lg), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.dangerSurface, borderRadius: AppRadius.brSm, ), @@ -233,7 +233,7 @@ class _ImportPanel extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.wifi_off_rounded, - color: AppColors.danger, size: 18), + color: AppColors.danger, size: 18,), const SizedBox(width: AppSpacing.sm), Expanded( child: Text( @@ -254,14 +254,14 @@ class _ImportPanel extends ConsumerWidget { Container( padding: const EdgeInsets.all(AppSpacing.md), margin: const EdgeInsets.only(bottom: AppSpacing.lg), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.successSurface, borderRadius: AppRadius.brSm, ), child: Row( children: [ const Icon(Icons.check_circle_outline_rounded, - color: AppColors.success, size: 18), + color: AppColors.success, size: 18,), const SizedBox(width: AppSpacing.sm), Expanded( child: Text( @@ -312,13 +312,13 @@ class _ImportPanel extends ConsumerWidget { ), const SizedBox(height: AppSpacing.lg), - Row( + const Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.info_outline_rounded, - size: 15, color: AppColors.textTertiary), - const SizedBox(width: AppSpacing.sm), - const Expanded( + Icon(Icons.info_outline_rounded, + size: 15, color: AppColors.textTertiary,), + SizedBox(width: AppSpacing.sm), + Expanded( child: Text( 'After this import the terminal works entirely offline. ' 'Sales, customers and parked bills are held locally and are ' diff --git a/lib/presentation/modules/screens/promos_view.dart b/lib/presentation/modules/screens/promos_view.dart index 69290a4..07c2e55 100644 --- a/lib/presentation/modules/screens/promos_view.dart +++ b/lib/presentation/modules/screens/promos_view.dart @@ -64,21 +64,21 @@ class _PromosViewState extends State { icon: Icons.campaign_rounded, caption: 'of ${_campaigns.length} configured', ), - StatTile( + const StatTile( label: 'Redemptions', value: '970', icon: Icons.confirmation_number_rounded, color: AppColors.info, caption: 'this month', ), - StatTile( + const StatTile( label: 'Discount Given', value: '₹48,240', icon: Icons.local_offer_rounded, color: AppColors.warning, caption: '2.6% of sales', ), - StatTile( + const StatTile( label: 'Incremental Sales', value: '₹2.14L', icon: Icons.trending_up_rounded, @@ -133,7 +133,7 @@ class _PromosViewState extends State { borderRadius: AppRadius.brSm, ), child: Icon(Icons.sell_rounded, - size: 18, color: c.$6), + size: 18, color: c.$6,), ), const SizedBox(width: AppSpacing.md), Expanded( diff --git a/lib/presentation/modules/screens/settings_view.dart b/lib/presentation/modules/screens/settings_view.dart index 06774d2..363fe95 100644 --- a/lib/presentation/modules/screens/settings_view.dart +++ b/lib/presentation/modules/screens/settings_view.dart @@ -167,7 +167,7 @@ class _SettingsViewState extends ConsumerState { if (list.isEmpty) { return Container( padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.warningSurface, borderRadius: AppRadius.brSm, ), @@ -175,7 +175,7 @@ class _SettingsViewState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.print_disabled_rounded, - size: 18, color: AppColors.warning), + size: 18, color: AppColors.warning,), SizedBox(width: AppSpacing.sm), Expanded( child: Text( @@ -235,7 +235,7 @@ class _SettingsViewState extends ConsumerState { final ok = await ref .read(receiptServiceProvider) .printTestPage( - printerUrl: settings.printerUrl); + printerUrl: settings.printerUrl,); if (!context.mounted) return; ScaffoldMessenger.of(context) ..hideCurrentSnackBar() @@ -245,8 +245,8 @@ class _SettingsViewState extends ConsumerState { : AppColors.danger, content: Text(ok ? 'Test slip sent to the printer.' - : 'Could not reach the printer.'), - )); + : 'Could not reach the printer.',), + ),); }, icon: const Icon(Icons.receipt_long_rounded, size: 17), label: const Text('Print test slip'), @@ -307,9 +307,9 @@ class _SettingsViewState extends ConsumerState { Cell(s.role.label, color: AppColors.textSecondary), s.id == current?.id ? const TagChip('Signed in', - color: AppColors.success) + color: AppColors.success,) : const SizedBox.shrink(), - ]) + ],) .toList(), ), ); diff --git a/lib/presentation/modules/widgets/module_widgets.dart b/lib/presentation/modules/widgets/module_widgets.dart index 69c4a62..1907b37 100644 --- a/lib/presentation/modules/widgets/module_widgets.dart +++ b/lib/presentation/modules/widgets/module_widgets.dart @@ -407,7 +407,7 @@ class Cell extends StatelessWidget { overflow: TextOverflow.ellipsis, style: mono ? AppTypography.money(13.5, - weight: bold ? FontWeight.w700 : FontWeight.w500, color: color) + weight: bold ? FontWeight.w700 : FontWeight.w500, color: color,) : TextStyle( fontSize: 13.5, fontWeight: bold ? FontWeight.w600 : FontWeight.w400, diff --git a/lib/presentation/payment/providers/payment_controller.dart b/lib/presentation/payment/providers/payment_controller.dart index 77665a3..2a0193e 100644 --- a/lib/presentation/payment/providers/payment_controller.dart +++ b/lib/presentation/payment/providers/payment_controller.dart @@ -6,6 +6,7 @@ import '../../../app/providers.dart'; import '../../../core/utils/extensions.dart'; import '../../../domain/entities/transaction.dart'; import '../../../domain/usecases/checkout_sale.dart'; +import '../../auth/providers/auth_controller.dart'; import '../../pos/providers/cart_controller.dart'; import '../../pos/providers/catalog_providers.dart'; import '../../sync/providers/sync_controller.dart'; @@ -44,6 +45,7 @@ class PaymentState { String? error, bool clearError = false, CheckoutResult? result, + bool clearResult = false, }) { return PaymentState( splits: splits ?? this.splits, @@ -52,7 +54,7 @@ class PaymentState { reference: reference ?? this.reference, isProcessing: isProcessing ?? this.isProcessing, error: clearError ? null : (error ?? this.error), - result: result ?? this.result, + result: clearResult ? null : (result ?? this.result), ); } } @@ -156,7 +158,10 @@ class PaymentController extends StateNotifier { if (state.isProcessing) return null; // A single-tender sale needn't be staged first — fold it in automatically. - var splits = state.splits; + // Remembered so it can be rolled back if the sale is rejected, otherwise a + // retry starts with a phantom tender already staged. + final stagedSplits = state.splits; + var splits = stagedSplits; if (splits.isEmpty || balanceDue > 0.01) { addSplit(); splits = state.splits; @@ -168,10 +173,16 @@ class PaymentController extends StateNotifier { final cart = _ref.read(cartControllerProvider); final session = _ref.read(cashierSessionProvider); + // The signed-in operator owns the bill. Falling back to the seed session + // stamped every sale with the same name, so a bill could not be traced to + // whoever actually rang it. + final user = _ref.read(currentUserProvider); + final result = await _ref.read(checkoutSaleProvider)( cart: cart, payments: splits, - cashierName: session.name, + cashierName: user?.name ?? session.name, + terminalId: session.terminalId, ); state = state.copyWith(isProcessing: false, result: result); @@ -190,12 +201,17 @@ class PaymentController extends StateNotifier { return result; } on CheckoutFailure catch (e) { - state = state.copyWith(isProcessing: false, error: e.message); + state = state.copyWith( + isProcessing: false, + error: e.message, + splits: stagedSplits, + ); return null; } catch (e) { state = state.copyWith( isProcessing: false, error: 'Could not complete the sale. $e', + splits: stagedSplits, ); return null; } diff --git a/lib/presentation/payment/screens/payment_screen.dart b/lib/presentation/payment/screens/payment_screen.dart index 1f5c98d..29d2350 100644 --- a/lib/presentation/payment/screens/payment_screen.dart +++ b/lib/presentation/payment/screens/payment_screen.dart @@ -50,7 +50,7 @@ class _PaymentScreenState extends ConsumerState { void _backspaceCash() { if (_cashBuffer.isEmpty) return; setState( - () => _cashBuffer = _cashBuffer.substring(0, _cashBuffer.length - 1)); + () => _cashBuffer = _cashBuffer.substring(0, _cashBuffer.length - 1),); _syncCash(); } @@ -196,7 +196,7 @@ class _PaymentScreenState extends ConsumerState { : AppColors.primarySurface, child: customer == null ? const Icon(Icons.person_add_alt_1_outlined, - size: 19, color: AppColors.textSecondary) + size: 19, color: AppColors.textSecondary,) : Text( Formatters.initials(customer.name), style: const TextStyle( @@ -316,7 +316,7 @@ class _PaymentScreenState extends ConsumerState { child: Row( children: [ Text(e.value.method.emoji, - style: const TextStyle(fontSize: 15)), + style: const TextStyle(fontSize: 15),), const SizedBox(width: AppSpacing.sm), Expanded( child: Text( @@ -326,7 +326,7 @@ class _PaymentScreenState extends ConsumerState { ), ), Text(Formatters.money(e.value.amount), - style: AppTypography.money(13.5)), + style: AppTypography.money(13.5),), IconButton( onPressed: () => controller.removeSplit(e.key), icon: const Icon(Icons.close_rounded, size: 16), @@ -380,7 +380,7 @@ class _PaymentScreenState extends ConsumerState { children: [ const Text('₹', style: - TextStyle(fontSize: 22, color: AppColors.textTertiary)), + TextStyle(fontSize: 22, color: AppColors.textTertiary),), const SizedBox(width: AppSpacing.sm), Expanded( child: FittedBox( @@ -481,7 +481,7 @@ class _PaymentScreenState extends ConsumerState { Row( children: [ Text(state.activeMethod.emoji, - style: const TextStyle(fontSize: 20)), + style: const TextStyle(fontSize: 20),), const SizedBox(width: AppSpacing.sm), Expanded( child: Text( @@ -498,13 +498,13 @@ class _PaymentScreenState extends ConsumerState { child: Container( width: 104, height: 104, - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.primarySurface, borderRadius: AppRadius.brXl, ), alignment: Alignment.center, child: Text(state.activeMethod.emoji, - style: const TextStyle(fontSize: 46)), + style: const TextStyle(fontSize: 46),), ), ), const SizedBox(height: AppSpacing.lg), @@ -543,9 +543,7 @@ class _PaymentScreenState extends ConsumerState { onPressed: controller.balanceDue > 0 ? () { controller.addSplit( - amount: amount == null - ? null - : amount.clamp(0, controller.balanceDue).toDouble(), + amount: amount?.clamp(0, controller.balanceDue).toDouble(), ); setState(() => _cashBuffer = ''); } @@ -586,14 +584,14 @@ class _PaymentScreenState extends ConsumerState { width: double.infinity, padding: const EdgeInsets.all(AppSpacing.md), margin: const EdgeInsets.only(bottom: AppSpacing.md), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.dangerSurface, borderRadius: AppRadius.brSm, ), child: Row( children: [ const Icon(Icons.error_outline_rounded, - color: AppColors.danger, size: 18), + color: AppColors.danger, size: 18,), const SizedBox(width: AppSpacing.sm), Expanded( child: Text( @@ -613,7 +611,7 @@ class _PaymentScreenState extends ConsumerState { child: Row( children: [ const Icon(Icons.info_outline_rounded, - size: 15, color: AppColors.textTertiary), + size: 15, color: AppColors.textTertiary,), const SizedBox(width: AppSpacing.sm), Expanded( child: Text( @@ -634,7 +632,7 @@ class _PaymentScreenState extends ConsumerState { ), ), child: const Text('Exact', - style: TextStyle(fontSize: 12.5)), + style: TextStyle(fontSize: 12.5),), ), ], ), diff --git a/lib/presentation/pos/providers/cart_controller.dart b/lib/presentation/pos/providers/cart_controller.dart index e0e54fa..5fdb0f3 100644 --- a/lib/presentation/pos/providers/cart_controller.dart +++ b/lib/presentation/pos/providers/cart_controller.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uuid/uuid.dart'; @@ -80,7 +82,7 @@ class CartController extends StateNotifier { stamp: DateTime.now(), product: product, message: '${product.name} is out of stock', - )); + ),); return; } @@ -98,7 +100,7 @@ class CartController extends StateNotifier { product: product, message: 'Only ${product.stock.toStringAsFixed(0)} ' '${product.unit.symbol} left', - )); + ),); return; } @@ -110,7 +112,7 @@ class CartController extends StateNotifier { quantity: quantity, addedAt: DateTime.now(), ), - ]); + ],); } else { state = state.copyWith( lines: _replace(existing.copyWith(quantity: requested)), @@ -123,7 +125,7 @@ class CartController extends StateNotifier { outcome: existing == null ? ScanOutcome.added : ScanOutcome.incremented, stamp: DateTime.now(), product: product, - )); + ),); } /// Scanner entry point. Resolves the barcode and adds it with no dialogs. @@ -131,12 +133,13 @@ class CartController extends StateNotifier { final product = await _products.findByBarcode(code); if (product == null) { - _sound.scanError(); + // Deliberately not awaited — the beep must never delay the next scan. + unawaited(_sound.scanError()); onFeedback(ScanFeedback( outcome: ScanOutcome.notFound, stamp: DateTime.now(), message: 'No product for barcode $code', - )); + ),); return; } @@ -163,7 +166,7 @@ class CartController extends StateNotifier { stamp: DateTime.now(), product: line.product, message: 'Only ${line.product.stock.toStringAsFixed(0)} in stock', - )); + ),); return; } @@ -265,7 +268,7 @@ class CartController extends StateNotifier { cart: state, parkedAt: DateTime.now(), label: label, - )); + ),); reset(); } diff --git a/lib/presentation/pos/providers/navigation_provider.dart b/lib/presentation/pos/providers/navigation_provider.dart index 536c458..7bf0699 100644 --- a/lib/presentation/pos/providers/navigation_provider.dart +++ b/lib/presentation/pos/providers/navigation_provider.dart @@ -6,10 +6,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; enum PosModule { pos('Point of Sale', 'POS', Icons.point_of_sale_rounded, NavSection.billing), customers('Customers', 'Customers', Icons.people_alt_rounded, - NavSection.billing), + NavSection.billing,), productImport('Product Import', 'Product Import', - Icons.cloud_download_rounded, NavSection.catalogue), + Icons.cloud_download_rounded, NavSection.catalogue,), promos('Promotions', 'Promo', Icons.sell_rounded, NavSection.catalogue), events('Events', 'Events', Icons.sync_rounded, NavSection.session), diff --git a/lib/presentation/pos/screens/pos_dashboard_screen.dart b/lib/presentation/pos/screens/pos_dashboard_screen.dart index 21f3289..6b461fa 100644 --- a/lib/presentation/pos/screens/pos_dashboard_screen.dart +++ b/lib/presentation/pos/screens/pos_dashboard_screen.dart @@ -13,6 +13,7 @@ import '../../modules/screens/promos_view.dart'; import '../../modules/screens/settings_view.dart'; import '../../sync/providers/sync_controller.dart'; import '../providers/cart_controller.dart'; +import '../providers/catalog_providers.dart'; import '../providers/navigation_provider.dart'; import '../widgets/app_sidebar.dart'; import '../widgets/billing_panel.dart'; @@ -56,6 +57,9 @@ class _PosDashboardScreenState extends ConsumerState { // Without an imported catalogue there is nothing to resolve against. if (!ref.read(catalogueReadyProvider)) return; ref.read(activeModuleProvider.notifier).state = PosModule.pos; + // Drop any character the burst leaked into the search box before the + // scan was recognised, so the grid is not left filtered to nothing. + ref.read(searchQueryProvider.notifier).state = ''; ref.read(cartControllerProvider.notifier).scanBarcode(code); }, )..attach(); @@ -73,13 +77,13 @@ class _PosDashboardScreenState extends ConsumerState { context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (_) => FractionallySizedBox( + builder: (_) => const FractionallySizedBox( heightFactor: 0.92, child: ClipRRect( - borderRadius: const BorderRadius.vertical( + borderRadius: BorderRadius.vertical( top: Radius.circular(AppRadius.xxl), ), - child: const BillingPanel(inSheet: true), + child: BillingPanel(inSheet: true), ), ), ); diff --git a/lib/presentation/pos/screens/pos_view.dart b/lib/presentation/pos/screens/pos_view.dart index bfa1192..0cacdb2 100644 --- a/lib/presentation/pos/screens/pos_view.dart +++ b/lib/presentation/pos/screens/pos_view.dart @@ -98,7 +98,7 @@ class _CatalogueRequired extends ConsumerWidget { shape: BoxShape.circle, ), child: const Icon(Icons.cloud_download_outlined, - size: 42, color: AppColors.primary), + size: 42, color: AppColors.primary,), ), const SizedBox(height: AppSpacing.xxl), Text( @@ -136,7 +136,7 @@ class _CatalogueRequired extends ConsumerWidget { minHeight: 8, backgroundColor: AppColors.divider, valueColor: const AlwaysStoppedAnimation( - AppColors.primary), + AppColors.primary,), ), ), const SizedBox(height: AppSpacing.lg), @@ -146,7 +146,7 @@ class _CatalogueRequired extends ConsumerWidget { Container( padding: const EdgeInsets.all(AppSpacing.md), margin: const EdgeInsets.only(bottom: AppSpacing.lg), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.dangerSurface, borderRadius: AppRadius.brSm, ), @@ -154,7 +154,7 @@ class _CatalogueRequired extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.wifi_off_rounded, - color: AppColors.danger, size: 18), + color: AppColors.danger, size: 18,), const SizedBox(width: AppSpacing.sm), Expanded( child: Text( diff --git a/lib/presentation/pos/widgets/app_sidebar.dart b/lib/presentation/pos/widgets/app_sidebar.dart index 46a1029..f2fee29 100644 --- a/lib/presentation/pos/widgets/app_sidebar.dart +++ b/lib/presentation/pos/widgets/app_sidebar.dart @@ -245,7 +245,7 @@ class _Section extends ConsumerWidget { AppSpacing.sm, ), child: Text(section.label.toUpperCase(), - style: AppTypography.sectionLabel()), + style: AppTypography.sectionLabel(),), ) else const Padding( @@ -459,7 +459,7 @@ class _LogoutTile extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.logout_rounded, - size: 19, color: AppColors.danger), + size: 19, color: AppColors.danger,), if (expanded) ...[ const SizedBox(width: AppSpacing.md), const Text( diff --git a/lib/presentation/pos/widgets/billing_panel.dart b/lib/presentation/pos/widgets/billing_panel.dart index 6d4771e..0ecea36 100644 --- a/lib/presentation/pos/widgets/billing_panel.dart +++ b/lib/presentation/pos/widgets/billing_panel.dart @@ -1,8 +1,9 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../../core/constants/app_constants.dart'; import '../../../core/router/app_router.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; @@ -66,7 +67,7 @@ class BillingPanel extends ConsumerWidget { ), if (cart.isNotEmpty) _Summary(cart: cart), _Actions(cart: cart), - ]), + ],), ); } } @@ -101,7 +102,7 @@ class _Header extends ConsumerWidget { const SizedBox(width: AppSpacing.sm), Container( padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.primarySurface, borderRadius: AppRadius.brPill, ), @@ -213,13 +214,13 @@ class _Summary extends ConsumerWidget { horizontal: AppSpacing.md, vertical: AppSpacing.sm + 2, ), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.successSurface, borderRadius: AppRadius.brSm, ), child: Row(children: [ const Icon(Icons.stars_rounded, - size: 16, color: AppColors.success), + size: 16, color: AppColors.success,), const SizedBox(width: AppSpacing.sm), Text( 'This sale earns +${cart.pointsEarned} pts', @@ -229,7 +230,7 @@ class _Summary extends ConsumerWidget { fontSize: 13, ), ), - ]), + ],), ), _Row(label: 'Subtotal', value: Formatters.money(cart.subtotal)), @@ -324,7 +325,7 @@ class _Summary extends ConsumerWidget { ), ), ), - ]), + ],), ); } } @@ -365,7 +366,7 @@ class _Row extends StatelessWidget { style: const TextStyle( fontSize: 11.5, color: AppColors.textTertiary, - )), + ),), ], const SizedBox(width: AppSpacing.sm), const Spacer(), @@ -381,7 +382,7 @@ class _Row extends StatelessWidget { const SizedBox(width: AppSpacing.xs), Icon(trailingIcon, size: 14, color: AppColors.textTertiary), ], - ]), + ],), ); } } @@ -412,7 +413,8 @@ class _Actions extends ConsumerWidget { if (ref.read(cartControllerProvider).customer == null) { await showCustomerCaptureSheet(context); } - if (context.mounted) context.push(AppRoutes.payment); + // Navigation result is not needed here. + if (context.mounted) unawaited(context.push(AppRoutes.payment)); } : null, trailing: enabled diff --git a/lib/presentation/pos/widgets/cart_fab.dart b/lib/presentation/pos/widgets/cart_fab.dart index b15854e..e1638a1 100644 --- a/lib/presentation/pos/widgets/cart_fab.dart +++ b/lib/presentation/pos/widgets/cart_fab.dart @@ -36,7 +36,7 @@ class CartFab extends ConsumerWidget { child: Container( height: AppSizes.buttonHeightLarge, padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl), - decoration: BoxDecoration( + decoration: const BoxDecoration( borderRadius: AppRadius.brLg, boxShadow: AppColors.shadowLg, ), @@ -79,7 +79,7 @@ class CartFab extends ConsumerWidget { ), const SizedBox(width: AppSpacing.sm), const Icon(Icons.keyboard_arrow_up_rounded, - color: Colors.white, size: 20), + color: Colors.white, size: 20,), ], ), ), diff --git a/lib/presentation/pos/widgets/cart_line_tile.dart b/lib/presentation/pos/widgets/cart_line_tile.dart index 5278c2d..bb3776c 100644 --- a/lib/presentation/pos/widgets/cart_line_tile.dart +++ b/lib/presentation/pos/widgets/cart_line_tile.dart @@ -34,12 +34,12 @@ class CartLineTile extends StatelessWidget { background: Container( alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: AppSpacing.xl), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.dangerSurface, borderRadius: AppRadius.brMd, ), child: const Icon(Icons.delete_outline_rounded, - color: AppColors.danger), + color: AppColors.danger,), ), child: Container( padding: const EdgeInsets.all(AppSpacing.md), @@ -83,18 +83,18 @@ class CartLineTile extends StatelessWidget { Formatters.money(p.price), style: AppTypography.money(13.5, weight: FontWeight.w600, - color: AppColors.textSecondary), + color: AppColors.textSecondary,), ), Text(' / ${p.unit.symbol}', style: const TextStyle( fontSize: 11.5, color: AppColors.textTertiary, - )), + ),), if (line.discount.isActive) ...[ const SizedBox(width: AppSpacing.sm), Container( padding: const EdgeInsets.symmetric( - horizontal: 5, vertical: 1), + horizontal: 5, vertical: 1,), decoration: BoxDecoration( color: AppColors.successSurface, borderRadius: BorderRadius.circular(4), @@ -109,7 +109,7 @@ class CartLineTile extends StatelessWidget { ), ), ], - ]), + ],), ], ), ), @@ -121,7 +121,7 @@ class CartLineTile extends StatelessWidget { padding: EdgeInsets.zero, tooltip: 'Remove', ), - ]), + ],), const SizedBox(height: AppSpacing.sm), @@ -162,14 +162,14 @@ class CartLineTile extends StatelessWidget { ), ], ), - ]), + ],), if (line.exceedsStock) Padding( padding: const EdgeInsets.only(top: AppSpacing.sm), child: Row(children: [ const Icon(Icons.error_outline_rounded, - size: 14, color: AppColors.danger), + size: 14, color: AppColors.danger,), const SizedBox(width: AppSpacing.xs), Text( 'Only ${p.stock.toStringAsFixed(0)} ${p.unit.symbol} ' @@ -180,9 +180,9 @@ class CartLineTile extends StatelessWidget { fontWeight: FontWeight.w600, ), ), - ]), + ],), ), - ]), + ],), ), ); } @@ -222,7 +222,7 @@ class _Stepper extends StatelessWidget { ), ), _btn(Icons.add_rounded, onIncrement), - ]), + ],), ); } diff --git a/lib/presentation/pos/widgets/category_chips.dart b/lib/presentation/pos/widgets/category_chips.dart index bcbf105..449d2b2 100644 --- a/lib/presentation/pos/widgets/category_chips.dart +++ b/lib/presentation/pos/widgets/category_chips.dart @@ -108,7 +108,7 @@ class _Chip extends StatelessWidget { ), ), ], - ]), + ],), ), ), ), diff --git a/lib/presentation/pos/widgets/customer_bar.dart b/lib/presentation/pos/widgets/customer_bar.dart index c3f5055..7b3b006 100644 --- a/lib/presentation/pos/widgets/customer_bar.dart +++ b/lib/presentation/pos/widgets/customer_bar.dart @@ -37,7 +37,7 @@ class CustomerBar extends ConsumerWidget { : AppColors.primarySurface, child: customer == null ? const Icon(Icons.directions_walk_rounded, - size: 20, color: AppColors.textSecondary) + size: 20, color: AppColors.textSecondary,) : Text( Formatters.initials(customer.name), style: const TextStyle( @@ -65,7 +65,7 @@ class CustomerBar extends ConsumerWidget { const SizedBox(width: AppSpacing.sm), StatusPill.tier(customer.tier, dense: true), ], - ]), + ],), if (customer != null) Text( '${Formatters.mobile(customer.mobile)} · ' @@ -92,7 +92,7 @@ class CustomerBar extends ConsumerWidget { icon: const Icon(Icons.person_off_outlined, size: 17), label: const Text('Detach'), style: TextButton.styleFrom( - foregroundColor: AppColors.textSecondary), + foregroundColor: AppColors.textSecondary,), ), const SizedBox(width: AppSpacing.sm), OutlinedButton.icon( @@ -104,7 +104,7 @@ class CustomerBar extends ConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), ), ), - ]), + ],), ); } } diff --git a/lib/presentation/pos/widgets/discount_sheet.dart b/lib/presentation/pos/widgets/discount_sheet.dart index 09cc046..cf1a324 100644 --- a/lib/presentation/pos/widgets/discount_sheet.dart +++ b/lib/presentation/pos/widgets/discount_sheet.dart @@ -116,7 +116,7 @@ class _DiscountSheetState extends State<_DiscountSheet> { Container( width: 40, height: 4, - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.border, borderRadius: AppRadius.brPill, ), @@ -124,13 +124,13 @@ class _DiscountSheetState extends State<_DiscountSheet> { const SizedBox(height: AppSpacing.xl), Text(widget.title, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),), const SizedBox(height: 2), Text(widget.subtitle, style: const TextStyle( fontSize: 13, color: AppColors.textSecondary, - )), + ),), const SizedBox(height: AppSpacing.xxl), SegmentedButton( @@ -177,10 +177,10 @@ class _DiscountSheetState extends State<_DiscountSheet> { .map((v) => ActionChip( label: Text(_type == DiscountType.percentage ? '$v%' - : '₹$v'), + : '₹$v',), onPressed: () => setState(() => _value.text = v.toString()), - )) + ),) .toList(), ), const SizedBox(height: AppSpacing.xxl), @@ -205,8 +205,8 @@ class _DiscountSheetState extends State<_DiscountSheet> { onPressed: _apply, ), ), - ]), - ]), + ],), + ],), ), ); } diff --git a/lib/presentation/pos/widgets/page_header.dart b/lib/presentation/pos/widgets/page_header.dart index 8f2267e..9ac28c9 100644 --- a/lib/presentation/pos/widgets/page_header.dart +++ b/lib/presentation/pos/widgets/page_header.dart @@ -133,13 +133,13 @@ class _Breadcrumb extends StatelessWidget { const Padding( padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs + 2), child: Icon(Icons.chevron_right_rounded, - size: 13, color: AppColors.textTertiary), + size: 13, color: AppColors.textTertiary,), ), Text(module.section.label, style: style), const Padding( padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs + 2), child: Icon(Icons.chevron_right_rounded, - size: 13, color: AppColors.textTertiary), + size: 13, color: AppColors.textTertiary,), ), Text( module.label, @@ -285,7 +285,7 @@ class _ParkedBillsButton extends ConsumerWidget { final bill = parked[i]; return ListTile( leading: const Icon(Icons.receipt_long_rounded, - color: AppColors.primary), + color: AppColors.primary,), title: Text(bill.displayLabel), subtitle: Text( '${bill.cart.lineCount} items · ' diff --git a/lib/presentation/pos/widgets/product_card.dart b/lib/presentation/pos/widgets/product_card.dart index 708d20e..16e6ec5 100644 --- a/lib/presentation/pos/widgets/product_card.dart +++ b/lib/presentation/pos/widgets/product_card.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; -import '../../../core/constants/app_constants.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_typography.dart'; @@ -178,7 +177,7 @@ class _ProductCardState extends State { left: AppSpacing.sm, child: Container( padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), + horizontal: 6, vertical: 2,), decoration: BoxDecoration( color: AppColors.success, borderRadius: BorderRadius.circular(5), @@ -199,7 +198,7 @@ class _ProductCardState extends State { top: AppSpacing.sm, right: AppSpacing.sm, child: Icon(Icons.warning_amber_rounded, - size: 15, color: AppColors.warning), + size: 15, color: AppColors.warning,), ), // Quantity badge once the item is on the bill. @@ -243,10 +242,10 @@ class _ProductCardState extends State { shape: BoxShape.circle, ), child: const Icon(Icons.add_rounded, - size: 18, color: Colors.white), + size: 18, color: Colors.white,), ), ), - ]), + ],), ), ), ), diff --git a/lib/presentation/pos/widgets/scan_toast.dart b/lib/presentation/pos/widgets/scan_toast.dart index eac682c..be19224 100644 --- a/lib/presentation/pos/widgets/scan_toast.dart +++ b/lib/presentation/pos/widgets/scan_toast.dart @@ -67,7 +67,7 @@ class _ScanToastState extends ConsumerState { horizontal: AppSpacing.xl, vertical: AppSpacing.md, ), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.textPrimary, borderRadius: AppRadius.brPill, boxShadow: AppColors.shadowLg, @@ -110,7 +110,7 @@ class _ScanToastState extends ConsumerState { fontSize: 13.5, ), ), - ]), + ],), ) .animate(key: ValueKey(feedback.stamp)) .fadeIn(duration: 140.ms) diff --git a/lib/presentation/pos/widgets/search_field.dart b/lib/presentation/pos/widgets/search_field.dart index 23c5576..1e6739d 100644 --- a/lib/presentation/pos/widgets/search_field.dart +++ b/lib/presentation/pos/widgets/search_field.dart @@ -44,6 +44,13 @@ class _PosSearchFieldState extends ConsumerState { Widget build(BuildContext context) { final query = ref.watch(searchQueryProvider); + // A scan clears the query from outside this widget. Mirror that into the + // controller so any stray character the scanner leaked into the field goes + // with it. + ref.listen(searchQueryProvider, (_, next) { + if (next.isEmpty && _controller.text.isNotEmpty) _controller.clear(); + }); + return TextField( controller: _controller, focusNode: widget.focusNode, @@ -80,23 +87,23 @@ class _PosSearchFieldState extends ConsumerState { horizontal: AppSpacing.md, vertical: AppSpacing.sm, ), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.primarySurface, borderRadius: AppRadius.brSm, ), child: const Row(mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.qr_code_scanner_rounded, - size: 18, color: AppColors.primary), + size: 18, color: AppColors.primary,), SizedBox(width: AppSpacing.xs + 2), Text('Scanner ready', style: TextStyle( color: AppColors.primary, fontSize: 12, fontWeight: FontWeight.w600, - )), - ]), + ),), + ],), ), - ]), + ],), ), ); } diff --git a/lib/presentation/receipt/screens/receipt_screen.dart b/lib/presentation/receipt/screens/receipt_screen.dart index aafcfc8..9e574af 100644 --- a/lib/presentation/receipt/screens/receipt_screen.dart +++ b/lib/presentation/receipt/screens/receipt_screen.dart @@ -58,7 +58,7 @@ class _ReceiptScreenState extends ConsumerState { content: Text( 'Printer did not respond — use Print to send it again.', ), - )); + ),); } }); @@ -112,7 +112,7 @@ class _ReceiptScreenState extends ConsumerState { height: 480, child: ReceiptPreview(transaction: txn), ), - ]), + ],), ) : Row( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -148,7 +148,7 @@ class _ReceiptScreenState extends ConsumerState { shape: BoxShape.circle, ), child: const Icon(Icons.check_rounded, - size: 44, color: AppColors.success), + size: 44, color: AppColors.success,), ) .animate() .scale( @@ -161,7 +161,7 @@ class _ReceiptScreenState extends ConsumerState { const SizedBox(height: AppSpacing.xl), Text('Sale complete', textAlign: TextAlign.center, - style: context.text.headlineMedium), + style: context.text.headlineMedium,), const SizedBox(height: AppSpacing.xs), Text( '${txn.invoiceNumber} · ${Formatters.dateTime(txn.createdAt)}', @@ -172,7 +172,7 @@ class _ReceiptScreenState extends ConsumerState { const SizedBox(height: AppSpacing.xxl), Container( padding: const EdgeInsets.all(AppSpacing.xl), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.primarySurface, borderRadius: AppRadius.brLg, ), @@ -188,12 +188,12 @@ class _ReceiptScreenState extends ConsumerState { _row('Paid via', txn.paymentSummary), if (txn.changeDue > 0) _row('Change returned', Formatters.money(txn.changeDue), - highlight: AppColors.success), + highlight: AppColors.success,), _row('Items', '${txn.cart.lineCount}'), if (txn.customer != null) ...[ _row('Customer', txn.customer!.name), _row('Points earned', '+${txn.pointsEarned}', - highlight: AppColors.success), + highlight: AppColors.success,), if (txn.pointsRedeemed > 0) _row('Points redeemed', '-${txn.pointsRedeemed}'), ], @@ -203,7 +203,7 @@ class _ReceiptScreenState extends ConsumerState { Formatters.money(txn.cart.totalSavings), highlight: AppColors.success, ), - ]), + ],), ), const SizedBox(height: AppSpacing.xxl), @@ -256,7 +256,7 @@ class _ReceiptScreenState extends ConsumerState { content: Text( 'Could not open WhatsApp on this device.', ), - )); + ),); }, ), ), @@ -314,7 +314,7 @@ class _ReceiptScreenState extends ConsumerState { style: const TextStyle( fontSize: 13.5, color: AppColors.textSecondary, - )), + ),), Text( value, style: TextStyle( diff --git a/lib/presentation/receipt/widgets/receipt_preview.dart b/lib/presentation/receipt/widgets/receipt_preview.dart index 783ea99..8c40581 100644 --- a/lib/presentation/receipt/widgets/receipt_preview.dart +++ b/lib/presentation/receipt/widgets/receipt_preview.dart @@ -43,7 +43,7 @@ class ReceiptPreview extends StatelessWidget { final discount = gross - cart.netAmount; return Container( - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.surface, borderRadius: AppRadius.brLg, boxShadow: AppColors.shadowMd, @@ -70,20 +70,20 @@ class ReceiptPreview extends StatelessWidget { .copyWith(fontWeight: FontWeight.w700), ), Text(AppConstants.storeLegalName, - style: AppTypography.mono(8.5)), + style: AppTypography.mono(8.5),), const SizedBox(height: 2), Text(AppConstants.storeAddress, textAlign: TextAlign.center, - style: AppTypography.mono(8.5)), + style: AppTypography.mono(8.5),), Text('Customer care : ${AppConstants.storePhone}', - style: AppTypography.mono(8.5)), + style: AppTypography.mono(8.5),), Text('CIN No : ${AppConstants.storeCin}', - style: AppTypography.mono(8.5)), + style: AppTypography.mono(8.5),), Text('GSTIN : ${AppConstants.storeGstin}', - style: AppTypography.mono(8.5)), + style: AppTypography.mono(8.5),), Text('FSSAI Lic No : ${AppConstants.storeFssai}', - style: AppTypography.mono(8.5)), - ]), + style: AppTypography.mono(8.5),), + ],), ), if (discount > 0) ...[ @@ -104,10 +104,10 @@ class ReceiptPreview extends StatelessWidget { child: Column(children: [ Text('TAX INVOICE', style: AppTypography.mono(11) - .copyWith(fontWeight: FontWeight.w700)), + .copyWith(fontWeight: FontWeight.w700),), Text('xxxxxx Original for Recipient xxxxxx', - style: AppTypography.mono(8)), - ]), + style: AppTypography.mono(8),), + ],), ), const SizedBox(height: AppSpacing.sm), @@ -117,7 +117,7 @@ class ReceiptPreview extends StatelessWidget { _plain('Customer Type: ' '${txn.customer == null ? 'URD' : 'REG'}'), _row('Date:${Formatters.receiptStamp(txn.createdAt)}', - 'Bill No:${txn.invoiceNumber.split('-').last}'), + 'Bill No:${txn.invoiceNumber.split('-').last}',), _row( 'Store:${AppConstants.storeCode} ' 'Cashier:${txn.cashierName}', @@ -135,13 +135,13 @@ class ReceiptPreview extends StatelessWidget { Expanded(flex: 34, child: _bold('Item Description')), Expanded( flex: 16, - child: _bold('Net Price', align: TextAlign.right)), + child: _bold('Net Price', align: TextAlign.right),), Expanded( - flex: 8, child: _bold('Qty', align: TextAlign.right)), + flex: 8, child: _bold('Qty', align: TextAlign.right),), Expanded( flex: 18, - child: _bold('Value', align: TextAlign.right)), - ]), + child: _bold('Value', align: TextAlign.right),), + ],), const SizedBox(height: 3), for (final slab in slabs) ...[ @@ -162,28 +162,28 @@ class ReceiptPreview extends StatelessWidget { child: Row(children: [ Expanded( flex: 12, - child: _cell(line.product.hsnCode ?? '-')), + child: _cell(line.product.hsnCode ?? '-'),), Expanded( flex: 34, child: - _cell(line.product.name.toUpperCase())), + _cell(line.product.name.toUpperCase()),), Expanded( flex: 16, child: _cell( line.product.price.toStringAsFixed(2), - align: TextAlign.right), + align: TextAlign.right,), ), Expanded( flex: 8, child: _cell(_qty(line.quantity), - align: TextAlign.right), + align: TextAlign.right,), ), Expanded( flex: 18, child: _cell(line.payable.toStringAsFixed(2), - align: TextAlign.right), + align: TextAlign.right,), ), - ]), + ],), ), ], @@ -197,7 +197,7 @@ class ReceiptPreview extends StatelessWidget { _amount('Gross Sales Value', gross), if (discount > 0) _amount('Total Discount', discount), _amount( - 'Net Sales Value (Inclusive of GST)', cart.netAmount), + 'Net Sales Value (Inclusive of GST)', cart.netAmount,), if (cart.roundOff != 0) _amount('Round Off', cart.roundOff), _amount('Total Amount Paid', txn.total, bold: true), @@ -206,34 +206,34 @@ class ReceiptPreview extends StatelessWidget { if (txn.changeDue > 0) _amount('Change Returned', txn.changeDue), Text('(AMOUNT INCLUSIVE OF APPLICABLE TAXES)', - style: AppTypography.mono(8)), + style: AppTypography.mono(8),), const SizedBox(height: AppSpacing.md), // ------------------------------------------- gst breakup Center( child: Text('------GST Breakup Details------ Amount (INR)', - style: AppTypography.mono(8.5)), + style: AppTypography.mono(8.5),), ), const SizedBox(height: 4), Row(children: [ Expanded(flex: 10, child: _bold('GST')), Expanded( flex: 20, - child: _bold('Taxable', align: TextAlign.right)), + child: _bold('Taxable', align: TextAlign.right),), Expanded( flex: 16, - child: _bold('CGST', align: TextAlign.right)), + child: _bold('CGST', align: TextAlign.right),), Expanded( flex: 16, - child: _bold('SGST', align: TextAlign.right)), + child: _bold('SGST', align: TextAlign.right),), Expanded( flex: 14, - child: _bold('CESS', align: TextAlign.right)), + child: _bold('CESS', align: TextAlign.right),), Expanded( flex: 20, - child: _bold('Total', align: TextAlign.right)), - ]), + child: _bold('Total', align: TextAlign.right),), + ],), const SizedBox(height: 2), for (final s in slabs) Padding( @@ -243,23 +243,23 @@ class ReceiptPreview extends StatelessWidget { Expanded( flex: 20, child: _cell(s.taxable.toStringAsFixed(2), - align: TextAlign.right)), + align: TextAlign.right,),), Expanded( flex: 16, child: _cell(s.cgst.toStringAsFixed(2), - align: TextAlign.right)), + align: TextAlign.right,),), Expanded( flex: 16, child: _cell(s.sgst.toStringAsFixed(2), - align: TextAlign.right)), + align: TextAlign.right,),), Expanded( flex: 14, - child: _cell('0.00', align: TextAlign.right)), + child: _cell('0.00', align: TextAlign.right),), Expanded( flex: 20, child: _cell(s.total.toStringAsFixed(2), - align: TextAlign.right)), - ]), + align: TextAlign.right,),), + ],), ), const _Dashes(), Row(children: [ @@ -270,32 +270,32 @@ class ReceiptPreview extends StatelessWidget { slabs .fold(0.0, (a, s) => a + s.taxable) .toStringAsFixed(2), - align: TextAlign.right)), + align: TextAlign.right,),), Expanded( flex: 16, child: _bold( slabs .fold(0.0, (a, s) => a + s.cgst) .toStringAsFixed(2), - align: TextAlign.right)), + align: TextAlign.right,),), Expanded( flex: 16, child: _bold( slabs .fold(0.0, (a, s) => a + s.sgst) .toStringAsFixed(2), - align: TextAlign.right)), + align: TextAlign.right,),), Expanded( flex: 14, - child: _bold('0.00', align: TextAlign.right)), + child: _bold('0.00', align: TextAlign.right),), Expanded( flex: 20, child: _bold( slabs .fold(0.0, (a, s) => a + s.total) .toStringAsFixed(2), - align: TextAlign.right)), - ]), + align: TextAlign.right,),), + ],), const _Dashes(), _plain('TaxInvoice# ${txn.invoiceNumber}'), @@ -311,10 +311,10 @@ class ReceiptPreview extends StatelessWidget { const SizedBox(height: AppSpacing.sm), Text('* Thank You for Shopping with us *', style: AppTypography.mono(10) - .copyWith(fontWeight: FontWeight.w700)), + .copyWith(fontWeight: FontWeight.w700),), Text('Powered by Nearle POS', - style: AppTypography.mono(8)), - ]), + style: AppTypography.mono(8),), + ],), ), ], ), @@ -322,7 +322,7 @@ class ReceiptPreview extends StatelessWidget { ), ), const _Perforation(top: false), - ]), + ],), ); } @@ -342,7 +342,7 @@ class ReceiptPreview extends StatelessWidget { Flexible( child: Text(left, overflow: TextOverflow.ellipsis, - style: AppTypography.mono(9)), + style: AppTypography.mono(9),), ), const SizedBox(width: AppSpacing.sm), Text(right, style: AppTypography.mono(9)), @@ -518,6 +518,6 @@ class _FakeBarcode extends StatelessWidget { ), const SizedBox(height: 3), Text(value, style: AppTypography.mono(9)), - ]); + ],); } } diff --git a/lib/presentation/sync/providers/sync_controller.dart b/lib/presentation/sync/providers/sync_controller.dart index 0a82fae..fa49bd3 100644 --- a/lib/presentation/sync/providers/sync_controller.dart +++ b/lib/presentation/sync/providers/sync_controller.dart @@ -97,7 +97,7 @@ final unsyncedCountProvider = FutureProvider((ref) { return ref.watch(syncRepositoryProvider).unsyncedCount(); }); -/// Today's totals, read back from SQLite. +/// Everything this terminal traded today, across every operator. final todayReportProvider = FutureProvider((ref) { ref.watch(orderVersionProvider); final session = ref.watch(cashierSessionProvider); @@ -109,6 +109,22 @@ final todayReportProvider = FutureProvider((ref) { ); }); +/// Only the bills the signed-in operator rang. +/// +/// This is the figure a cashier counts their drawer against at the end of a +/// shift, so it must not include anyone else's sales. +final myShiftReportProvider = FutureProvider((ref) { + ref.watch(orderVersionProvider); + final session = ref.watch(cashierSessionProvider); + final user = ref.watch(currentUserProvider); + + return ref.watch(syncRepositoryProvider).todayReport( + terminalId: session.terminalId, + cashierName: user?.name ?? session.name, + scopeToCashier: true, + ); +}); + /// Per-order sync state for the events log. final orderSyncRowsProvider = FutureProvider>((ref) { ref.watch(orderVersionProvider); diff --git a/lib/presentation/sync/widgets/sign_out_dialog.dart b/lib/presentation/sync/widgets/sign_out_dialog.dart index 15cd66e..44e93ab 100644 --- a/lib/presentation/sync/widgets/sign_out_dialog.dart +++ b/lib/presentation/sync/widgets/sign_out_dialog.dart @@ -56,7 +56,8 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> { @override Widget build(BuildContext context) { - final report = ref.watch(todayReportProvider).value; + // The operator is settling their own till, so this covers only their bills. + final report = ref.watch(myShiftReportProvider).value; final pending = ref.watch(unsyncedCountProvider).value ?? 0; final cart = ref.watch(cartControllerProvider); final pushing = ref.watch(orderSyncProvider) is SyncRunning; @@ -111,7 +112,7 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> { _row('Items sold', report.itemCount.toStringAsFixed(0)), _row('Gross sales', Formatters.money(report.grossSales)), _row('GST collected', - Formatters.money(report.taxCollected)), + Formatters.money(report.taxCollected),), ], ], diff --git a/test/unit/barcode_service_test.dart b/test/unit/barcode_service_test.dart new file mode 100644 index 0000000..386709d --- /dev/null +++ b/test/unit/barcode_service_test.dart @@ -0,0 +1,169 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nearle_pos/core/services/barcode_service.dart'; + +/// The scanner is a keyboard, so the only thing separating a scan from the +/// cashier typing is timing. These drive the handler with a controlled clock +/// rather than real delays, so the boundary is exact. +void main() { + late List scans; + late int manualKeys; + late DateTime now; + late bool editingText; + + setUp(() { + scans = []; + manualKeys = 0; + now = DateTime(2026, 7, 31, 10); + editingText = false; + }); + + BarcodeService build() => BarcodeService( + onScan: scans.add, + onManualKey: () => manualKeys++, + clock: () => now, + isEditingText: () => editingText, + ); + + KeyEvent char(String c) => KeyDownEvent( + physicalKey: PhysicalKeyboardKey.keyA, + logicalKey: LogicalKeyboardKey.keyA, + character: c, + timeStamp: Duration.zero, + ); + + KeyEvent enterKey() => const KeyDownEvent( + physicalKey: PhysicalKeyboardKey.enter, + logicalKey: LogicalKeyboardKey.enter, + timeStamp: Duration.zero, + ); + + /// Feeds [text] with a fixed gap between keystrokes, returning which + /// keystrokes the service swallowed. + List type( + BarcodeService service, + String text, { + required int gapMs, + }) { + return [ + for (final c in text.split('')) ...[ + () { + now = now.add(Duration(milliseconds: gapMs)); + return service.handleKey(char(c)); + }(), + ], + ]; + } + + group('scanner input', () { + test('a fast burst terminated by Enter is billed as a scan', () { + final service = build(); + + type(service, '8901234500011', gapMs: 5); + now = now.add(const Duration(milliseconds: 5)); + final enterConsumed = service.handleKey(enterKey()); + + expect(scans, ['8901234500011']); + expect(enterConsumed, isTrue, + reason: 'the scanner terminator must not reach the focused field',); + expect(manualKeys, 0); + }); + + test('the burst is swallowed so it cannot also land in a text field', () { + final service = build(); + + final consumed = type(service, '8901234500011', gapMs: 5); + + // The first keystroke cannot be judged yet — nothing has arrived to + // measure a gap against — so exactly one character escapes. Everything + // after it is recognised as machine-paced and consumed. + expect(consumed.first, isFalse); + expect(consumed.skip(1), everyElement(isTrue)); + }); + + test('a scanner that sends no Enter still flushes on idle', () async { + final service = build(); + type(service, '8901234500011', gapMs: 5); + + expect(scans, isEmpty, reason: 'nothing has flushed yet'); + await Future.delayed(const Duration(milliseconds: 300)); + expect(scans, ['8901234500011']); + }); + }); + + group('human input', () { + test('typing at human speed is never treated as a scan', () { + final service = build(); + + final consumed = type(service, '9876543210', gapMs: 200); + now = now.add(const Duration(milliseconds: 200)); + final enterConsumed = service.handleKey(enterKey()); + + expect(scans, isEmpty); + expect(consumed, everyElement(isFalse), + reason: 'hand-typed characters must reach the field',); + expect(enterConsumed, isFalse, + reason: 'swallowing Enter would break form submission',); + }); + + test('a fast typist in a text field does not trigger a phantom scan', () { + editingText = true; + final service = build(); + + // ~100ms per character is a quick typist and used to clear the bar. + type(service, '9876543210', gapMs: 100); + now = now.add(const Duration(milliseconds: 100)); + final enterConsumed = service.handleKey(enterKey()); + + expect(scans, isEmpty, + reason: 'entering a mobile number must not jump to billing',); + expect(enterConsumed, isFalse); + }); + + test('the same speed does count as a scan when no field has focus', () { + editingText = false; + final service = build(); + + type(service, '9876543210', gapMs: 100); + now = now.add(const Duration(milliseconds: 100)); + service.handleKey(enterKey()); + + expect(scans, ['9876543210']); + }); + + test('a pause mid-burst starts a new entry', () { + final service = build(); + + type(service, '890123', gapMs: 5); + now = now.add(const Duration(milliseconds: 500)); // cashier hesitates + type(service, '4500011', gapMs: 5); + now = now.add(const Duration(milliseconds: 5)); + service.handleKey(enterKey()); + + expect(scans, ['4500011'], + reason: 'only the characters after the pause form the code',); + }); + }); + + group('rejected input', () { + test('a buffer shorter than a barcode is reported as manual input', () { + final service = build(); + + type(service, 'abc', gapMs: 5); + now = now.add(const Duration(milliseconds: 5)); + final enterConsumed = service.handleKey(enterKey()); + + expect(scans, isEmpty); + expect(manualKeys, 1); + expect(enterConsumed, isFalse); + }); + + test('non-alphanumeric characters are ignored entirely', () { + final service = build(); + + now = now.add(const Duration(milliseconds: 5)); + expect(service.handleKey(char(r'$')), isFalse); + expect(scans, isEmpty); + }); + }); +} diff --git a/test/unit/cart_test.dart b/test/unit/cart_test.dart index 942ecff..8fe7abf 100644 --- a/test/unit/cart_test.dart +++ b/test/unit/cart_test.dart @@ -15,7 +15,7 @@ const _item = Product( gstRate: 0.18, ); -Customer _silver() => Customer( +Customer _silver() => const Customer( id: 'cust-1', name: 'Silver Shopper', mobile: '9876543210', @@ -169,10 +169,10 @@ void main() { const cart = Cart(lines: [ CartLine(product: _item, quantity: 1), CartLine(product: zeroRated, quantity: 1), - ]); + ],); final breakdown = cart.taxBreakdown; - expect(breakdown.keys, containsAll([0.18, 0.0])); + expect(breakdown.keys, containsAll([0.18, 0.0])); expect(breakdown[0.0], 0); expect(breakdown[0.18]!, greaterThan(0)); }); diff --git a/test/unit/checkout_test.dart b/test/unit/checkout_test.dart index f68bd72..1436d06 100644 --- a/test/unit/checkout_test.dart +++ b/test/unit/checkout_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:nearle_pos/data/datasources/local_store.dart'; +import 'package:nearle_pos/data/datasources/seed_data.dart'; import 'package:nearle_pos/data/repositories/customer_repository_impl.dart'; import 'package:nearle_pos/data/repositories/product_repository_impl.dart'; import 'package:nearle_pos/data/repositories/transaction_repository_impl.dart'; @@ -15,10 +16,19 @@ void main() { late TransactionRepositoryImpl transactions; late CheckoutSale checkout; + setUpAll(() { + // The store only reaches for demo rows through these hooks, so the seed has + // to be registered before any test asks for a catalogue. + LocalStore.registerSeed( + products: SeedData.products, + customers: SeedData.customers, + ); + }); + setUp(() async { store = LocalStore.instance; // Fresh seed for every test so stock and invoice sequence never leak. - await store.reset(); + await store.reset(withCatalogue: true); products = ProductRepositoryImpl(store); customers = CustomerRepositoryImpl(store); diff --git a/test/unit/migration_test.dart b/test/unit/migration_test.dart new file mode 100644 index 0000000..5ecde58 --- /dev/null +++ b/test/unit/migration_test.dart @@ -0,0 +1,204 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:nearle_pos/data/local/app_database.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// A terminal already in the field is running the v3 schema. Upgrading it must +/// carry the day's archived takings across rather than dropping them, so this +/// builds a genuine v3 database and opens it with the current code. +void main() { + late Directory dir; + late String dbPath; + + setUpAll(sqfliteFfiInit); + + setUp(() async { + dir = await Directory.systemTemp.createTemp('nearle_migration'); + dbPath = '${dir.path}/nearle_pos.db'; + databaseFactory = databaseFactoryFfi; + }); + + tearDown(() async { + await AppDatabase.instance.close(); + if (dir.existsSync()) dir.deleteSync(recursive: true); + }); + + /// The schema exactly as v3 shipped it. + Future createV3Database() async { + final db = await databaseFactory.openDatabase( + dbPath, + options: OpenDatabaseOptions( + version: 3, + onCreate: (db, _) async { + await db.execute(''' + CREATE TABLE products ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, barcode TEXT NOT NULL, + sku TEXT NOT NULL, category TEXT NOT NULL, price REAL NOT NULL, + mrp REAL, stock REAL NOT NULL DEFAULT 0, emoji TEXT, + image_url TEXT, unit TEXT NOT NULL DEFAULT 'piece', + gst_rate REAL NOT NULL DEFAULT 0.18, hsn_code TEXT, brand TEXT, + is_active INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL) + '''); + await db.execute(''' + CREATE TABLE customers ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, mobile TEXT NOT NULL, + email TEXT, gender TEXT NOT NULL DEFAULT 'unspecified', + date_of_birth INTEGER, loyalty_points INTEGER NOT NULL DEFAULT 0, + lifetime_spend REAL NOT NULL DEFAULT 0, + visit_count INTEGER NOT NULL DEFAULT 0, created_at INTEGER, + last_visit_at INTEGER) + '''); + await db.execute(''' + CREATE TABLE orders ( + id TEXT PRIMARY KEY, invoice_number TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, business_date TEXT NOT NULL, + cashier_name TEXT NOT NULL, terminal_id TEXT NOT NULL, + customer_id TEXT, customer_mobile TEXT, customer_name TEXT, + subtotal REAL NOT NULL, line_discount REAL NOT NULL DEFAULT 0, + bill_discount REAL NOT NULL DEFAULT 0, + loyalty_value REAL NOT NULL DEFAULT 0, + taxable_amount REAL NOT NULL DEFAULT 0, + tax_amount REAL NOT NULL DEFAULT 0, + round_off REAL NOT NULL DEFAULT 0, total REAL NOT NULL, + points_earned INTEGER NOT NULL DEFAULT 0, + points_redeemed INTEGER NOT NULL DEFAULT 0, + payments_json TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'completed', + sync_status INTEGER NOT NULL DEFAULT 0, synced_at INTEGER, + sync_attempts INTEGER NOT NULL DEFAULT 0, sync_error TEXT) + '''); + await db.execute(''' + CREATE TABLE order_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, order_id TEXT NOT NULL, + product_id TEXT NOT NULL, name TEXT NOT NULL, + barcode TEXT NOT NULL, sku TEXT NOT NULL, unit TEXT NOT NULL, + unit_price REAL NOT NULL, quantity REAL NOT NULL, + discount REAL NOT NULL DEFAULT 0, gst_rate REAL NOT NULL DEFAULT 0, + tax_amount REAL NOT NULL DEFAULT 0, line_total REAL NOT NULL, + FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE) + '''); + await db.execute(''' + CREATE TABLE parked_bills ( + id TEXT PRIMARY KEY, label TEXT, parked_at INTEGER NOT NULL, + cart_json TEXT NOT NULL) + '''); + // v3 sync_log: no payload column. + await db.execute(''' + CREATE TABLE sync_log ( + id TEXT PRIMARY KEY, type TEXT NOT NULL, status TEXT NOT NULL, + created_at INTEGER NOT NULL, synced_at INTEGER, + summary TEXT NOT NULL, error TEXT, + attempts INTEGER NOT NULL DEFAULT 0) + '''); + // v3 day_archive: keyed by date alone, no cashier. + await db.execute(''' + CREATE TABLE day_archive ( + business_date TEXT PRIMARY KEY, + bill_count INTEGER NOT NULL DEFAULT 0, + item_count REAL NOT NULL DEFAULT 0, + gross_sales REAL NOT NULL DEFAULT 0, + tax_collected REAL NOT NULL DEFAULT 0, + discount_given REAL NOT NULL DEFAULT 0, + round_off REAL NOT NULL DEFAULT 0, + points_issued INTEGER NOT NULL DEFAULT 0, + points_redeemed INTEGER NOT NULL DEFAULT 0, + payments_json TEXT NOT NULL DEFAULT '{}', + first_bill_at INTEGER, last_bill_at INTEGER, + synced_bills INTEGER NOT NULL DEFAULT 0) + '''); + await db.execute( + 'CREATE TABLE app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)', + ); + }, + ), + ); + + await db.insert('day_archive', { + 'business_date': '2026-07-30', + 'bill_count': 12, + 'item_count': 40.0, + 'gross_sales': 8450.0, + 'tax_collected': 620.5, + 'discount_given': 130.0, + 'round_off': 1.5, + 'points_issued': 84, + 'points_redeemed': 20, + 'payments_json': '{"cash":5000.0,"upi":3450.0}', + 'first_bill_at': 1000, + 'last_bill_at': 2000, + 'synced_bills': 12, + }); + await db.insert('app_meta', {'key': 'invoice_sequence', 'value': '12'}); + await db.close(); + } + + test('a v3 terminal upgrades without losing its archived takings', () async { + await createV3Database(); + + await AppDatabase.instance.open(overridePath: dbPath); + final db = AppDatabase.instance.db; + + expect(await db.getVersion(), 4); + + final rows = await db.query('day_archive'); + expect(rows, hasLength(1)); + + final row = rows.single; + expect(row['business_date'], '2026-07-30'); + expect(row['cashier_name'], '', + reason: 'rows from before per-cashier attribution get an empty name',); + expect(row['bill_count'], 12); + expect(row['gross_sales'], 8450.0); + expect(row['tax_collected'], 620.5); + expect(row['payments_json'], '{"cash":5000.0,"upi":3450.0}'); + expect(row['synced_bills'], 12); + + // Unrelated state must be untouched by the migration. + final meta = await db.query('app_meta', where: "key = 'invoice_sequence'"); + expect(meta.single['value'], '12'); + }); + + test('the upgraded sync log accepts a payload', () async { + await createV3Database(); + await AppDatabase.instance.open(overridePath: dbPath); + final db = AppDatabase.instance.db; + + await db.insert('sync_log', { + 'id': 'e1', + 'type': 'shiftReport', + 'status': 'synced', + 'created_at': 1, + 'summary': '3 bills uploaded', + 'attempts': 1, + 'payload_json': '{"invoices":["INV-1"]}', + }); + + final row = (await db.query('sync_log')).single; + expect(row['payload_json'], '{"invoices":["INV-1"]}'); + }); + + test('the day archive now holds one row per cashier', () async { + await createV3Database(); + await AppDatabase.instance.open(overridePath: dbPath); + final db = AppDatabase.instance.db; + + for (final cashier in ['Divya', 'Rahul']) { + await db.insert('day_archive', { + 'business_date': '2026-07-31', + 'cashier_name': cashier, + 'bill_count': 1, + 'gross_sales': 100.0, + 'payments_json': '{}', + }); + } + + final rows = await db.query( + 'day_archive', + where: 'business_date = ?', + whereArgs: ['2026-07-31'], + ); + expect(rows, hasLength(2), + reason: 'two cashiers on the same day must not collide',); + }); +} diff --git a/test/unit/persistence_test.dart b/test/unit/persistence_test.dart new file mode 100644 index 0000000..3dd602e --- /dev/null +++ b/test/unit/persistence_test.dart @@ -0,0 +1,463 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:nearle_pos/data/datasources/local_store.dart'; +import 'package:nearle_pos/data/datasources/remote_catalogue_source.dart'; +import 'package:nearle_pos/data/datasources/seed_data.dart'; +import 'package:nearle_pos/data/repositories/customer_repository_impl.dart'; +import 'package:nearle_pos/data/repositories/product_repository_impl.dart'; +import 'package:nearle_pos/data/repositories/sync_repository_impl.dart'; +import 'package:nearle_pos/data/repositories/transaction_repository_impl.dart'; +import 'package:nearle_pos/domain/entities/cart.dart'; +import 'package:nearle_pos/domain/entities/customer.dart'; +import 'package:nearle_pos/domain/entities/shift_report.dart'; +import 'package:nearle_pos/domain/entities/sync_event.dart'; +import 'package:nearle_pos/domain/entities/transaction.dart'; +import 'package:nearle_pos/domain/usecases/checkout_sale.dart'; + +/// Guards the boundary between a live bill and a stored one. +/// +/// Every figure a shop is paid on survives a write to SQLite and a read back +/// out of it. These are regressions: the read path used to rebuild a bill from +/// its lines alone, silently dropping bill-level discounts and loyalty. +void main() { + late LocalStore store; + late ProductRepositoryImpl products; + late CustomerRepositoryImpl customers; + late TransactionRepositoryImpl transactions; + late CheckoutSale checkout; + + setUpAll(() { + LocalStore.registerSeed( + products: SeedData.products, + customers: SeedData.customers, + ); + }); + + setUp(() async { + store = LocalStore.instance; + await store.reset(withCatalogue: true); + + products = ProductRepositoryImpl(store); + customers = CustomerRepositoryImpl(store); + transactions = TransactionRepositoryImpl(store); + checkout = CheckoutSale( + productRepository: products, + customerRepository: customers, + transactionRepository: transactions, + ); + }); + + /// A Gold member (5% tier discount) holding 400 points. + Future goldMember() async { + final created = await customers.create(const Customer( + id: 'ignored', + name: 'Gold Shopper', + mobile: '9000000001', + ),); + return customers.update( + created.copyWith(loyaltyPoints: 400, lifetimeSpend: 60000), + ); + } + + group('order round trip', () { + test('bill discount, loyalty and total survive a write and read back', + () async { + final milk = (await products.findByBarcode('8901234500011'))!; + final member = await goldMember(); + + // 10 x 62.00 = 620 subtotal, less 5% tier (31) and a flat 50 = 81, + // less 40 points at 0.25 = 10. Payable 529. + final cart = Cart( + lines: [CartLine(product: milk, quantity: 10)], + customer: member, + billDiscount: const Discount(type: DiscountType.flat, value: 50), + pointsRedeemed: 40, + ); + expect(cart.grandTotal, 529); + expect(cart.billDiscountTotal, 81); + + final result = await checkout( + cart: cart, + payments: const [ + PaymentSplit(method: PaymentMethod.cash, amount: 529, tendered: 529), + ], + cashierName: 'Divya', + ); + + final stored = (await transactions.history()).single; + + expect(stored.total, result.transaction.total, + reason: 'the figure charged must not move in storage',); + expect(stored.total, 529); + expect(stored.cart.billDiscountTotal, 81); + expect(stored.cart.loyaltyRedemptionValue, 10); + expect(stored.cart.pointsRedeemed, 40); + expect(stored.cart.taxAmount, cart.taxAmount); + expect(stored.cart.subtotal, 620); + }); + + test('a rebuilt bill does not re-apply the tier discount', () async { + final milk = (await products.findByBarcode('8901234500011'))!; + final member = await goldMember(); + + final cart = Cart( + lines: [CartLine(product: milk, quantity: 10)], + customer: member, + ); + // 620 less the 5% tier discount only. + expect(cart.grandTotal, 589); + + await checkout( + cart: cart, + payments: const [ + PaymentSplit(method: PaymentMethod.cash, amount: 589, tendered: 600), + ], + cashierName: 'Divya', + ); + + final stored = (await transactions.history()).single; + expect(stored.cart.billDiscountTotal, 31); + expect(stored.total, 589); + }); + + test('the shift report reads back the amount actually charged', () async { + final milk = (await products.findByBarcode('8901234500011'))!; + final member = await goldMember(); + + await checkout( + cart: Cart( + lines: [CartLine(product: milk, quantity: 10)], + customer: member, + billDiscount: const Discount(type: DiscountType.flat, value: 50), + pointsRedeemed: 40, + ), + payments: const [ + PaymentSplit(method: PaymentMethod.cash, amount: 529, tendered: 529), + ], + cashierName: 'Divya', + ); + + final report = ShiftReport.fromTransactions( + transactions: await transactions.history(), + businessDate: DateTime.now(), + terminalId: 'TERM-01', + cashierName: 'Divya', + ); + + expect(report.grossSales, 529); + expect(report.discountGiven, 81); + expect(report.loyaltyPointsRedeemed, 40); + }); + + test('the bill is stamped with the operator who rang it', () async { + final milk = (await products.findByBarcode('8901234500011'))!; + + await checkout( + cart: Cart(lines: [CartLine(product: milk, quantity: 1)]), + payments: const [ + PaymentSplit(method: PaymentMethod.cash, amount: 62, tendered: 100), + ], + cashierName: 'Rahul', + terminalId: 'TERM-07', + ); + + final stored = (await transactions.history()).single; + expect(stored.cashierName, 'Rahul'); + expect(stored.terminalId, 'TERM-07'); + }); + }); + + group('stock safety', () { + test('a resumed parked bill is re-checked against live stock', () async { + final milk = (await products.findByBarcode('8901234500011'))!; + + await transactions.park(ParkedBill( + id: 'p-1', + cart: Cart(lines: [CartLine(product: milk, quantity: milk.stock)]), + parkedAt: DateTime.now(), + ),); + + // The same stock is sold on another bill in the meantime. + await products.decrementStock({milk.id: milk.stock}); + + final resumed = (await transactions.parkedBills()).single; + + await expectLater( + checkout( + cart: resumed.cart, + payments: [ + PaymentSplit( + method: PaymentMethod.cash, + amount: resumed.cart.grandTotal, + tendered: resumed.cart.grandTotal, + ), + ], + cashierName: 'Divya', + ), + throwsA(isA()), + ); + + expect(await transactions.history(), isEmpty); + }); + + test('a re-import does not restore stock already sold', () async { + final milk = (await products.findByBarcode('8901234500011'))!; + final opening = milk.stock; + + await checkout( + cart: Cart(lines: [CartLine(product: milk, quantity: 4)]), + payments: const [ + PaymentSplit(method: PaymentMethod.cash, amount: 248, tendered: 250), + ], + cashierName: 'Divya', + ); + expect((await products.findByBarcode('8901234500011'))!.stock, + opening - 4,); + + // The morning catalogue is pulled again before the bills went up. + await store.importCatalogue( + products: SeedData.products(), + customers: SeedData.customers(), + revision: 'seed-2', + at: DateTime.now(), + ); + + expect( + (await products.findByBarcode('8901234500011'))!.stock, + opening - 4, + reason: 'units sold on unsynced bills must not reappear on the shelf', + ); + }); + }); + + group('atomicity', () { + test('nothing is persisted when the sale cannot be completed', () async { + final milk = (await products.findByBarcode('8901234500011'))!; + final opening = milk.stock; + + // A customer that was never written to the database. + const ghost = Customer(id: 'missing', name: 'Ghost', mobile: '9999999999'); + + await expectLater( + checkout( + cart: Cart( + lines: [CartLine(product: milk, quantity: 2)], + customer: ghost, + ), + payments: const [ + PaymentSplit(method: PaymentMethod.cash, amount: 124, tendered: 124), + ], + cashierName: 'Divya', + ), + throwsA(isA()), + ); + + expect(await transactions.history(), isEmpty, + reason: 'a failed sale must not leave a bill behind',); + expect((await products.findByBarcode('8901234500011'))!.stock, opening, + reason: 'a failed sale must not consume stock',); + }); + + test('concurrent checkouts never share an invoice sequence', () async { + final numbers = await Future.wait( + List.generate(25, (_) => transactions.nextInvoiceSequence()), + ); + expect(numbers.toSet().length, numbers.length); + }); + }); + + group('end of day', () { + test('the archived day totals match what was charged', () async { + final sync = SyncRepositoryImpl( + store, + RemoteCatalogueSource(isOffline: () => false), + RemoteOrderSink(isOffline: () => false), + ); + + final milk = (await products.findByBarcode('8901234500011'))!; + final member = await goldMember(); + + await checkout( + cart: Cart( + lines: [CartLine(product: milk, quantity: 10)], + customer: member, + billDiscount: const Discount(type: DiscountType.flat, value: 50), + pointsRedeemed: 40, + ), + payments: const [ + PaymentSplit(method: PaymentMethod.cash, amount: 529, tendered: 529), + ], + cashierName: 'Divya', + ); + + final outcome = await sync.syncOrders(); + expect(outcome.uploaded, 1); + expect(await sync.unsyncedCount(), 0); + + // Synced bills are deleted, so today's figures now come from the archive. + // A wrong row here is permanent — there is nothing left to recompute from. + final report = await sync.todayReport( + terminalId: 'TERM-01', + cashierName: 'Divya', + ); + + expect(report.billCount, 1); + expect(report.grossSales, 529); + expect(report.discountGiven, 81); + expect(report.loyaltyPointsRedeemed, 40); + expect(report.paymentBreakdown[PaymentMethod.cash], 529); + }); + }); + + group('sync log', () { + test('survives a restart, because it outlives the bills it describes', + () async { + final sync = SyncRepositoryImpl( + store, + RemoteCatalogueSource(isOffline: () => false), + RemoteOrderSink(isOffline: () => false), + ); + + final milk = (await products.findByBarcode('8901234500011'))!; + await checkout( + cart: Cart(lines: [CartLine(product: milk, quantity: 1)]), + payments: const [ + PaymentSplit(method: PaymentMethod.cash, amount: 62, tendered: 62), + ], + cashierName: 'Divya', + ); + await sync.syncOrders(); + + expect(sync.events, isNotEmpty); + final summary = sync.events.first.summary; + + // The cache is dropped and rebuilt from disk, as it is on a cold start. + await store.hydrate(); + + expect(sync.events, isNotEmpty, + reason: 'the only record that those bills went up must persist',); + expect(sync.events.first.summary, summary); + expect(sync.events.first.payload['invoices'], isNotEmpty); + }); + + test('a failed import is recorded with its error', () async { + final sync = SyncRepositoryImpl( + store, + RemoteCatalogueSource(isOffline: () => true), + RemoteOrderSink(isOffline: () => true), + ); + + await sync.importCatalogue(); + await store.hydrate(); + + expect(sync.events.first.status, SyncStatus.failed); + expect(sync.events.first.error, isNotNull); + }); + }); + + group('shift report scoping', () { + Future sell(String cashier, double qty) async { + final milk = (await products.findByBarcode('8901234500011'))!; + final due = Cart(lines: [CartLine(product: milk, quantity: qty)]).grandTotal; + await checkout( + cart: Cart(lines: [CartLine(product: milk, quantity: qty)]), + payments: [ + PaymentSplit(method: PaymentMethod.cash, amount: due, tendered: due), + ], + cashierName: cashier, + ); + } + + test('a cashier settles their own till, not the terminal', () async { + final sync = SyncRepositoryImpl( + store, + RemoteCatalogueSource(isOffline: () => false), + RemoteOrderSink(isOffline: () => false), + ); + + await sell('Divya', 2); // 124 + await sell('Rahul', 3); // 186 + + final divya = await sync.todayReport( + terminalId: 'TERM-01', + cashierName: 'Divya', + scopeToCashier: true, + ); + final terminal = await sync.todayReport( + terminalId: 'TERM-01', + cashierName: 'Divya', + ); + + expect(divya.billCount, 1); + expect(divya.grossSales, 124); + expect(terminal.billCount, 2); + expect(terminal.grossSales, 310); + }); + + test('scoping holds after the bills are uploaded and deleted', () async { + final sync = SyncRepositoryImpl( + store, + RemoteCatalogueSource(isOffline: () => false), + RemoteOrderSink(isOffline: () => false), + ); + + await sell('Divya', 2); + await sell('Rahul', 3); + await sync.syncOrders(); + expect(await sync.unsyncedCount(), 0); + + final divya = await sync.todayReport( + terminalId: 'TERM-01', + cashierName: 'Divya', + scopeToCashier: true, + ); + final terminal = await sync.todayReport( + terminalId: 'TERM-01', + cashierName: 'Divya', + ); + + expect(divya.grossSales, 124, + reason: 'the archive must keep each cashier separable',); + expect(divya.billCount, 1); + expect(terminal.grossSales, 310); + expect(terminal.billCount, 2); + }); + }); + + group('customer lookup', () { + test('a mobile number matches however the cashier punctuates it', () async { + await customers.create(const Customer( + id: 'ignored', + name: 'Punctuation Test', + mobile: '9000000042', + ),); + + for (final query in ['9000000042', '90000 00042', '90-000-00042']) { + expect( + (await customers.search(query)).map((c) => c.name), + contains('Punctuation Test'), + reason: 'searching "$query" should find the customer', + ); + } + }); + }); + + group('tax invoice', () { + test('the slab breakdown sums to the GST charged', () async { + // Two different slabs plus a bill discount, so apportioning is in play. + final milk = (await products.findByBarcode('8901234500011'))!; // 5% + final all = await products.getAll(); + final eighteen = all.firstWhere((p) => p.gstRate == 0.18); + + final cart = Cart( + lines: [ + CartLine(product: milk, quantity: 3), + CartLine(product: eighteen, quantity: 7), + ], + billDiscount: const Discount(type: DiscountType.percentage, value: 13), + ); + + final summed = cart.taxBreakdown.values.fold(0.0, (a, b) => a + b); + expect(summed, closeTo(cart.taxAmount, 0.0001)); + }); + }); +} diff --git a/test/widget/app_smoke_test.dart b/test/widget/app_smoke_test.dart new file mode 100644 index 0000000..346d2bd --- /dev/null +++ b/test/widget/app_smoke_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:nearle_pos/app/app.dart'; +import 'package:nearle_pos/data/datasources/local_store.dart'; +import 'package:nearle_pos/data/datasources/seed_data.dart'; +import 'package:nearle_pos/domain/entities/shift_report.dart'; +import 'package:nearle_pos/presentation/auth/providers/auth_controller.dart'; +import 'package:nearle_pos/presentation/pos/providers/cart_controller.dart'; +import 'package:nearle_pos/presentation/pos/screens/pos_dashboard_screen.dart'; +import 'package:nearle_pos/presentation/sync/providers/sync_controller.dart'; + +/// Boots the real application widget. +/// +/// The unit suite proves the money is right; this proves the thing actually +/// assembles — router guard, provider graph, theme and shell — which type +/// checking alone cannot tell you. +void main() { + setUpAll(() { + // Tests have no network, and an attempted font fetch throws. + GoogleFonts.config.allowRuntimeFetching = false; + LocalStore.registerSeed( + products: SeedData.products, + customers: SeedData.customers, + ); + }); + + setUp(() async { + await LocalStore.instance.reset(withCatalogue: true); + }); + + ShiftReport blankReport() => ShiftReport.blank( + businessDate: DateTime(2026, 7, 31), + terminalId: 'TERM-01', + cashierName: 'Suriya', + ); + + Future bootApp(WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + // Catalogue reads come from the in-memory cache and resolve on the + // spot, but these four go to SQLite. Real disk I/O cannot be driven + // by the fake clock a widget test runs on: sqflite's own lock-warning + // timer is left pending and trips the binding's leak check. Stubbed + // so this test measures rendering, which is what it is for. + unsyncedCountProvider.overrideWith((ref) async => 0), + parkedBillsProvider.overrideWith((ref) async => []), + orderSyncRowsProvider.overrideWith((ref) async => []), + todayReportProvider.overrideWith((ref) async => blankReport()), + myShiftReportProvider.overrideWith((ref) async => blankReport()), + ], + child: const NearlePosApp(), + ), + ); + await tester.pumpAndSettle(); + } + + /// Pumps a fixed number of frames instead of settling. + /// + /// Once past login the header subscribes to a periodic clock, so there is + /// always another frame pending and `pumpAndSettle` never returns. + Future settle(WidgetTester tester, {int frames = 15}) async { + for (var i = 0; i < frames; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + } + + Future signIn(WidgetTester tester) async { + final fields = find.byType(TextFormField); + await tester.enterText(fields.first, DemoCredentials.email); + await tester.enterText(fields.at(1), DemoCredentials.password); + await tester.pump(); + + await tester.tap(find.text('Sign in').last); + await settle(tester); + } + + testWidgets('the terminal starts on the login screen', (tester) async { + await bootApp(tester); + + expect(find.byType(TextFormField), findsWidgets); + expect(find.text('Sign in'), findsWidgets); + expect(tester.takeException(), isNull); + }); + + testWidgets('the auth guard keeps an unauthenticated terminal out', + (tester) async { + await bootApp(tester); + + expect(find.byType(PosDashboardScreen), findsNothing); + }); + + testWidgets('signing in reaches the billing terminal', (tester) async { + await bootApp(tester); + await signIn(tester); + + expect(find.byType(PosDashboardScreen), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('the shell renders every module without throwing', + (tester) async { + // Wide enough that the sidebar shows labels and the bill stays docked — + // the layout where a button label and its total compete for width. + tester.view.physicalSize = const Size(1800, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await bootApp(tester); + await signIn(tester); + + for (final label in [ + 'Customers', + 'Product Import', + 'Promo', + 'Events', + 'Settings', + 'POS', + ]) { + final target = find.text(label); + expect(target, findsWidgets, reason: 'no sidebar entry for "$label"'); + + await tester.tap(target.first); + await settle(tester); + expect(tester.takeException(), isNull, reason: 'opening "$label" threw'); + } + }); +} diff --git a/test/widget/primary_button_test.dart b/test/widget/primary_button_test.dart index b65311f..b57282a 100644 --- a/test/widget/primary_button_test.dart +++ b/test/widget/primary_button_test.dart @@ -58,7 +58,7 @@ void main() { label: 'CHARGE', onPressed: () {}, trailing: const Text('\u20B9384.00'), - )), + ),), ); expect(find.text('CHARGE'), findsOneWidget); diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index d76d2a7..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:nearle_pos/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -}