diff --git a/lib/app/providers.dart b/lib/app/providers.dart index a9a3000..503d6d0 100644 --- a/lib/app/providers.dart +++ b/lib/app/providers.dart @@ -30,13 +30,25 @@ final transactionRepositoryProvider = Provider( (ref) => TransactionRepositoryImpl(ref.watch(localStoreProvider)), ); +/// Mirrors the Settings "Simulate offline" switch so the header can show it. +/// +/// Without this the terminal claims LIVE while every call is being failed on +/// purpose, which reads as a real network fault. +final simulateOfflineProvider = StateProvider((ref) => false); + /// Simulated back-office endpoints. Held as singletons so the offline toggle /// in Settings affects every call. -final remoteCatalogueProvider = - Provider((ref) => RemoteCatalogueSource()); +final remoteCatalogueProvider = Provider( + (ref) => RemoteCatalogueSource( + isOffline: () => ref.read(simulateOfflineProvider), + ), +); -final remoteOrderSinkProvider = - Provider((ref) => RemoteOrderSink()); +final remoteOrderSinkProvider = Provider( + (ref) => RemoteOrderSink( + isOffline: () => ref.read(simulateOfflineProvider), + ), +); final syncRepositoryProvider = Provider( (ref) => SyncRepositoryImpl( diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index 4fbe5ff..b0c3d51 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -6,6 +6,16 @@ class AppConstants { static const String storeName = 'Nearle Daily'; static const String storeAddress = '12 Gandhipuram Main Rd, Coimbatore 641012'; static const String storeGstin = '33ABCDE1234F1Z5'; + static const String storeCin = 'U52190TZ2024PTC012345'; + static const String storeFssai = '12417812000447'; + static const String storeLegalName = 'Nearle Retail Private Limited'; + + /// Place of supply, printed on the tax invoice. + static const String stateCode = '33'; + static const String stateName = 'TN'; + + static const String storeCode = '6709'; + static const String posNumber = 'R120'; static const String storePhone = '+91 90000 12345'; static const String currencySymbol = '\u20B9'; static const String locale = 'en_IN'; diff --git a/lib/core/services/receipt_service.dart b/lib/core/services/receipt_service.dart index 05c27a4..731179e 100644 --- a/lib/core/services/receipt_service.dart +++ b/lib/core/services/receipt_service.dart @@ -4,7 +4,10 @@ import 'package:flutter/foundation.dart'; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; import 'package:printing/printing.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../domain/entities/cart.dart'; +import '../utils/extensions.dart'; import '../../domain/entities/transaction.dart'; import '../constants/app_constants.dart'; import '../utils/formatters.dart'; @@ -18,40 +21,66 @@ class ReceiptService { /// 80mm roll with a small safety margin. static const double _rollWidth = 78 * PdfPageFormat.mm; + /// One GST slab's worth of the bill, as the invoice groups it. + /// + /// A compliant Indian tax invoice lists items under their rate and then + /// repeats the arithmetic in a breakup table, so both are derived once here. + static List<_Slab> _slabs(SaleTransaction txn) { + final cart = txn.cart; + + // Bill-level discounts are spread across lines, so the slab figures still + // add up to the amount actually charged. + final factor = cart.subtotal <= 0 ? 1.0 : cart.netAmount / cart.subtotal; + + final grouped = >{}; + for (final line in cart.lines) { + grouped.putIfAbsent(line.product.gstRate, () => []).add(line); + } + + final rates = grouped.keys.toList()..sort(); + return [ + for (var i = 0; i < rates.length; i++) + _Slab( + index: i + 1, + rate: rates[i], + lines: grouped[rates[i]]!, + factor: factor, + ), + ]; + } + Future build(SaleTransaction txn) async { final doc = pw.Document(title: txn.invoiceNumber); - final font = await PdfGoogleFonts.interRegular(); - final bold = await PdfGoogleFonts.interSemiBold(); + final font = await PdfGoogleFonts.robotoMonoRegular(); + final bold = await PdfGoogleFonts.robotoMonoBold(); - final cart = txn.cart; + final slabs = _slabs(txn); doc.addPage( pw.Page( pageFormat: PdfPageFormat( _rollWidth, double.infinity, - marginAll: 6 * PdfPageFormat.mm, + marginAll: 5 * PdfPageFormat.mm, ), theme: pw.ThemeData.withFont(base: font, bold: bold), build: (context) => pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.stretch, children: [ _header(txn), - _divider(), + _savingsLine(txn), + _rule(), + _invoiceTitle(), _meta(txn), - _divider(), - _itemsTable(txn), - _divider(), + _rule(), + _itemsBySlab(slabs), + _rule(), _totals(txn), - _divider(), - _taxSummary(txn), - _divider(), - _payments(txn), - if (cart.customer != null) ...[ - _divider(), - _loyalty(txn), - ], - pw.SizedBox(height: 8), + pw.SizedBox(height: 4), + _gstBreakup(txn, slabs), + _rule(), + _references(txn), + pw.SizedBox(height: 6), _footer(txn), ], ), @@ -66,241 +95,370 @@ class ReceiptService { pw.Text( AppConstants.storeName.toUpperCase(), style: pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold), - textAlign: pw.TextAlign.center, ), + pw.Text(AppConstants.storeLegalName, + style: const pw.TextStyle(fontSize: 7)), pw.SizedBox(height: 2), - pw.Text( - AppConstants.storeAddress, - style: const pw.TextStyle(fontSize: 7), - textAlign: pw.TextAlign.center, - ), - pw.Text( - 'GSTIN: ${AppConstants.storeGstin} | ${AppConstants.storePhone}', - style: const pw.TextStyle(fontSize: 7), - textAlign: pw.TextAlign.center, - ), - pw.SizedBox(height: 4), - pw.Text( - 'TAX INVOICE', - style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold), - ), + pw.Text(AppConstants.storeAddress, + style: const pw.TextStyle(fontSize: 6.5), + textAlign: pw.TextAlign.center), + pw.Text('Customer care : ${AppConstants.storePhone}', + style: const pw.TextStyle(fontSize: 6.5)), + pw.Text('CIN No : ${AppConstants.storeCin}', + style: const pw.TextStyle(fontSize: 6.5)), + pw.Text('GSTIN : ${AppConstants.storeGstin}', + style: const pw.TextStyle(fontSize: 6.5)), + pw.Text('FSSAI Lic No : ${AppConstants.storeFssai}', + style: const pw.TextStyle(fontSize: 6.5)), + ]); + + pw.Widget _savingsLine(SaleTransaction txn) { + final saved = _grossSalesValue(txn) - txn.cart.netAmount; + if (saved <= 0) return pw.SizedBox(height: 4); + + return pw.Padding( + 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), + textAlign: pw.TextAlign.center, + ), + ); + } + + pw.Widget _invoiceTitle() => pw.Column(children: [ + pw.Text('TAX INVOICE', + style: 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)), ]); pw.Widget _meta(SaleTransaction txn) { final c = txn.customer; - return pw.Column(children: [ - _row('Invoice', txn.invoiceNumber), - _row('Date', Formatters.receiptStamp(txn.createdAt)), - _row('Cashier', txn.cashierName), - _row('Terminal', txn.terminalId), - _row('Customer', c == null ? 'Walk-in' : c.name), - if (c != null) _row('Mobile', Formatters.mobile(c.mobile)), - ]); + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.SizedBox(height: 2), + _line('Place of Supply & State Code: ' + '${AppConstants.stateCode} ${AppConstants.stateName}'), + // URD = unregistered dealer, the correct marking for retail walk-ins. + _line('Customer Type: ${c == null ? 'URD' : 'REG'}'), + _split( + 'Date:${Formatters.receiptStamp(txn.createdAt)}', + 'Bill No:${txn.invoiceNumber.split('-').last}', + ), + _split( + 'Store:${AppConstants.storeCode} Cashier:${txn.cashierName}', + 'Pos No:${AppConstants.posNumber}', + ), + if (c != null) _line('Customer: ${c.name} ${c.mobile}'), + ], + ); } - pw.Widget _itemsTable(SaleTransaction txn) { + pw.Widget _itemsBySlab(List<_Slab> slabs) { return pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.stretch, children: [ pw.Row(children: [ - pw.Expanded(flex: 5, child: _th('Item')), - pw.Expanded(flex: 2, child: _th('Qty', align: pw.TextAlign.center)), - pw.Expanded(flex: 3, child: _th('Rate', align: pw.TextAlign.right)), - pw.Expanded(flex: 3, child: _th('Amt', align: pw.TextAlign.right)), + pw.Expanded(flex: 12, child: _th('HSN')), + pw.Expanded(flex: 34, child: _th('Item Description')), + 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), - ...txn.cart.lines.map((line) => pw.Padding( - padding: const pw.EdgeInsets.symmetric(vertical: 1.5), - child: pw.Column(children: [ - pw.Row(children: [ - pw.Expanded(flex: 5, child: _td(line.product.name)), - pw.Expanded( - flex: 2, - child: _td( - _qty(line.quantity), - align: pw.TextAlign.center, - ), - ), - pw.Expanded( - flex: 3, - child: _td( - line.product.price.toStringAsFixed(2), - align: pw.TextAlign.right, - ), - ), - pw.Expanded( - flex: 3, - child: _td( - line.payable.toStringAsFixed(2), - align: pw.TextAlign.right, - ), - ), - ]), - if (line.discount.isActive) - pw.Row(children: [ - pw.Expanded( - child: _td( - ' ${line.discount.label} ' - '-${line.discountAmount.toStringAsFixed(2)}', - size: 6.5, - ), - ), - ]), + for (final slab in slabs) ...[ + pw.Padding( + padding: const pw.EdgeInsets.only(top: 3, bottom: 1), + child: pw.Text( + '${slab.index}) CGST @ ${slab.halfPercent} ' + 'SGST @ ${slab.halfPercent}', + style: pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold), + ), + ), + for (final line in slab.lines) + pw.Padding( + padding: const pw.EdgeInsets.symmetric(vertical: 0.6), + child: pw.Row(children: [ + pw.Expanded( + flex: 12, + child: _td(line.product.hsnCode ?? '-', size: 6.5), + ), + pw.Expanded( + flex: 34, + child: _td(_description(line), size: 6.5), + ), + pw.Expanded( + flex: 16, + child: _td(line.product.price.toStringAsFixed(2), + right: true, size: 6.5), + ), + pw.Expanded( + flex: 8, + child: _td(_qty(line.quantity), right: true, size: 6.5), + ), + pw.Expanded( + flex: 18, + child: _td(line.payable.toStringAsFixed(2), + right: true, size: 6.5), + ), ]), - )), + ), + ], ], ); } pw.Widget _totals(SaleTransaction txn) { final cart = txn.cart; + final gross = _grossSalesValue(txn); + final discount = gross - cart.netAmount; + return pw.Column(children: [ - _row('Items', '${cart.lineCount} (Qty ${_qty(cart.totalQuantity)})'), - _row('Subtotal', cart.subtotal.toStringAsFixed(2)), - if (cart.membershipDiscountAmount > 0) - _row( - '${cart.customer!.tier.label} discount', - '-${cart.membershipDiscountAmount.toStringAsFixed(2)}', - ), - if (cart.manualBillDiscountAmount > 0) - _row('Discount', '-${cart.manualBillDiscountAmount.toStringAsFixed(2)}'), - if (cart.loyaltyRedemptionValue > 0) - _row( - 'Points redeemed (${cart.pointsRedeemed})', - '-${cart.loyaltyRedemptionValue.toStringAsFixed(2)}', - ), - _row('Taxable value', cart.taxableAmount.toStringAsFixed(2)), - _row('CGST', cart.cgst.toStringAsFixed(2)), - _row('SGST', cart.sgst.toStringAsFixed(2)), - if (cart.roundOff != 0) - _row('Round off', cart.roundOff.toStringAsFixed(2)), - pw.SizedBox(height: 3), - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text('TOTAL', - style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)), - pw.Text( - '${AppConstants.currencySymbol}${txn.total.toStringAsFixed(2)}', - style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold), - ), - ], + _split( + 'Items:${cart.lineCount}', + 'Qty:${_qty(cart.totalQuantity)} ' + '${cart.netAmount.toStringAsFixed(2)}', ), - if (cart.totalSavings > 0) ...[ - pw.SizedBox(height: 2), - pw.Text( - 'You saved ${AppConstants.currencySymbol}' - '${cart.totalSavings.toStringAsFixed(2)} on this bill', - style: pw.TextStyle(fontSize: 7.5, fontWeight: pw.FontWeight.bold), - textAlign: pw.TextAlign.center, + pw.SizedBox(height: 2), + _amount('Gross Sales Value', gross), + if (discount > 0) _amount('Total Discount', discount), + _amount('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), + for (final p in txn.payments) + _amount( + p.method.label.toUpperCase() + + (p.reference != null ? ' ${p.reference}' : ''), + p.amount, ), + 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)), + ]); + } + + 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)), + pw.SizedBox(height: 3), + pw.Row(children: [ + pw.Expanded(flex: 10, child: _th('GST\nIND', size: 6)), + pw.Expanded(flex: 20, child: _th('Taxable\nAmount', right: true, size: 6)), + pw.Expanded(flex: 16, child: _th('CGST', right: true, size: 6)), + 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( + padding: const pw.EdgeInsets.symmetric(vertical: 0.8), + child: pw.Row(children: [ + pw.Expanded(flex: 10, child: _td('${s.index}', size: 6.5)), + pw.Expanded( + flex: 20, + child: _td(s.taxable.toStringAsFixed(2), + right: true, size: 6.5)), + pw.Expanded( + flex: 16, + 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)), + 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)), + ]), + ), + pw.Divider(height: 4, borderStyle: pw.BorderStyle.dashed), + pw.Row(children: [ + pw.Expanded(flex: 10, child: _th('Total', size: 6.5)), + pw.Expanded( + flex: 20, + child: _th( + slabs.fold(0.0, (a, s) => a + s.taxable).toStringAsFixed(2), + right: true, + 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)), + pw.Expanded( + flex: 16, + child: _th(slabs.fold(0.0, (a, s) => a + s.sgst).toStringAsFixed(2), + 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)), + ]), + ]); + } + + pw.Widget _references(SaleTransaction txn) { + final c = txn.customer; + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + _line('TaxInvoice# ${txn.invoiceNumber}'), + _line('PaymentRefNo# ${txn.id.replaceAll('-', '').substring(0, 16)}'), + if (c != null) + _line('Loyalty Pts: earned ${txn.pointsEarned}' + '${txn.pointsRedeemed > 0 ? ', used ${txn.pointsRedeemed}' : ''}' + ' Bal: ${c.loyaltyPoints - txn.pointsRedeemed + txn.pointsEarned}'), + _line('Terms & Conditions Apply'), ], - ]); - } - - pw.Widget _taxSummary(SaleTransaction txn) { - final breakdown = txn.cart.taxBreakdown.entries - .where((e) => e.value > 0) - .toList() - ..sort((a, b) => a.key.compareTo(b.key)); - - if (breakdown.isEmpty) { - return _td('All items zero-rated', size: 7); - } - - return pw.Column(children: [ - _th('GST Summary'), - ...breakdown.map((e) => _row( - 'GST @ ${(e.key * 100).toStringAsFixed(0)}%', - e.value.toStringAsFixed(2), - size: 7, - )), - ]); - } - - pw.Widget _payments(SaleTransaction txn) => pw.Column(children: [ - ...txn.payments.map((p) => _row( - p.method.label + - (p.reference != null ? ' (${p.reference})' : ''), - p.amount.toStringAsFixed(2), - )), - if (txn.changeDue > 0) - _row('Change returned', txn.changeDue.toStringAsFixed(2)), - ]); - - pw.Widget _loyalty(SaleTransaction txn) { - final c = txn.customer!; - final balance = c.loyaltyPoints - txn.pointsRedeemed + txn.pointsEarned; - return pw.Column(children: [ - _row('Points earned', '+${txn.pointsEarned}'), - if (txn.pointsRedeemed > 0) - _row('Points redeemed', '-${txn.pointsRedeemed}'), - _row('Points balance', '$balance'), - _row('Membership', c.tier.label), - ]); + ); } pw.Widget _footer(SaleTransaction txn) => pw.Column(children: [ pw.BarcodeWidget( barcode: pw.Barcode.code128(), data: txn.invoiceNumber, - width: 140, - height: 34, + width: 150, + height: 32, drawText: false, ), - pw.SizedBox(height: 4), - pw.Text(txn.invoiceNumber, style: const pw.TextStyle(fontSize: 7)), - pw.SizedBox(height: 4), - pw.Text('Thank you for shopping with us!', - style: pw.TextStyle(fontSize: 8, fontWeight: pw.FontWeight.bold)), - pw.Text('Goods once sold are exchangeable within 7 days with this bill.', - style: const pw.TextStyle(fontSize: 6), + pw.SizedBox(height: 2), + pw.Text(txn.invoiceNumber, style: const pw.TextStyle(fontSize: 6.5)), + pw.SizedBox(height: 5), + pw.Text( + 'I/We hereby certify that food/foods mentioned in this invoice ' + 'is/are warranted to be of the nature and quality which it/these ' + 'purports/purported to be.', + style: const pw.TextStyle(fontSize: 5.5), + textAlign: pw.TextAlign.center, + ), + pw.SizedBox(height: 3), + pw.Text('* Thank You for Shopping with us *', + style: 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), pw.SizedBox(height: 2), - pw.Text('Powered by Nearle POS', style: const pw.TextStyle(fontSize: 6)), + pw.Text('Powered by Nearle POS', + style: const pw.TextStyle(fontSize: 5.5)), ]); // -------------------------------------------------------------- Helpers + /// Pre-discount value, using printed MRP where one is known. + static double _grossSalesValue(SaleTransaction txn) => txn.cart.lines + .fold(0.0, (s, l) => s + (l.product.mrp ?? l.product.price) * l.quantity); + + String _description(CartLine line) => line.product.name.toUpperCase(); + String _qty(double q) => q % 1 == 0 ? q.toStringAsFixed(0) : q.toStringAsFixed(3); - pw.Widget _divider() => pw.Padding( + pw.Widget _rule() => pw.Padding( padding: const pw.EdgeInsets.symmetric(vertical: 3), child: pw.Divider(height: 0.5, borderStyle: pw.BorderStyle.dashed), ); - pw.Widget _th(String text, {pw.TextAlign align = pw.TextAlign.left}) => - pw.Text(text, - textAlign: align, - style: pw.TextStyle(fontSize: 7.5, fontWeight: pw.FontWeight.bold)); + pw.Widget _line(String text) => pw.Padding( + padding: const pw.EdgeInsets.symmetric(vertical: 0.5), + child: pw.Text(text, style: const pw.TextStyle(fontSize: 6.5)), + ); - pw.Widget _td(String text, - {pw.TextAlign align = pw.TextAlign.left, double size = 7.5}) => - pw.Text(text, textAlign: align, style: pw.TextStyle(fontSize: size)); - - pw.Widget _row(String label, String value, {double size = 7.5}) => pw.Padding( - padding: const pw.EdgeInsets.symmetric(vertical: 0.8), + pw.Widget _split(String left, String right) => pw.Padding( + padding: const pw.EdgeInsets.symmetric(vertical: 0.5), child: pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ - pw.Text(label, style: pw.TextStyle(fontSize: size)), - pw.Text(value, style: pw.TextStyle(fontSize: size)), + pw.Text(left, style: const pw.TextStyle(fontSize: 6.5)), + pw.Text(right, style: const pw.TextStyle(fontSize: 6.5)), ], ), ); + pw.Widget _amount(String label, double value, {bool bold = false}) => + pw.Padding( + padding: const pw.EdgeInsets.symmetric(vertical: 0.6), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text(label, + 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, + )), + ], + ), + ); + + 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)); + + 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)); + // --------------------------------------------------------- Printing / IO - /// Silent print to the default roll printer — no OS dialog, so the cashier - /// is never blocked between sales. - Future printDirect(SaleTransaction txn) async { + /// Opens the system print preview. + /// + /// This is the on-terminal path: it renders the receipt on screen and works + /// with no printer attached. When a roll printer is wired up, switch the + /// receipt screen to [printDirect] and this becomes the fallback. + Future printPreview(SaleTransaction txn) async { + final bytes = await build(txn); + await Printing.layoutPdf( + onLayout: (_) async => bytes, + name: txn.invoiceNumber, + ); + } + + /// Printers the operating system can see. + /// + /// A USB or network thermal printer shows up here once its driver is + /// installed — the terminal does not talk to the hardware itself. + Future> availablePrinters() async { try { + return await Printing.listPrinters(); + } catch (e) { + debugPrint('Printer discovery failed: $e'); + return const []; + } + } + + /// Silent print with no OS dialog, so the cashier is never blocked. + /// + /// [printerUrl] is the value saved from Settings. When it is null or the + /// printer has gone away, this falls back to the system default; when there + /// is no printer at all it returns false so the caller can show the preview + /// instead of pretending the bill was printed. + Future printDirect( + SaleTransaction txn, { + String? printerUrl, + }) async { + try { + final printers = await availablePrinters(); + if (printers.isEmpty) return false; + + final target = printers.where((p) => p.url == printerUrl).firstOrNull ?? + printers.where((p) => p.isDefault).firstOrNull ?? + printers.first; + final bytes = await build(txn); - final printers = await Printing.listPrinters(); - final target = printers.where((p) => p.isDefault).firstOrNull ?? - (printers.isNotEmpty ? printers.first : null); - - if (target == null) return false; - return await Printing.directPrintPdf( printer: target, onLayout: (_) async => bytes, @@ -312,24 +470,187 @@ class ReceiptService { } } - /// Falls back to the system print preview. - Future printWithDialog(SaleTransaction txn) async { - final bytes = await build(txn); - await Printing.layoutPdf( - onLayout: (_) async => bytes, - name: txn.invoiceNumber, - ); + /// Sends a one-page test slip to confirm the printer is wired up. + Future printTestPage({String? printerUrl}) async { + try { + final printers = await availablePrinters(); + if (printers.isEmpty) return false; + + final target = printers.where((p) => p.url == printerUrl).firstOrNull ?? + printers.first; + + final doc = pw.Document(); + final font = await PdfGoogleFonts.robotoMonoRegular(); + doc.addPage( + pw.Page( + pageFormat: PdfPageFormat( + _rollWidth, + double.infinity, + marginAll: 5 * PdfPageFormat.mm, + ), + theme: pw.ThemeData.withFont(base: font), + build: (_) => pw.Column(children: [ + pw.Text(AppConstants.storeName.toUpperCase(), + style: 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)), + pw.SizedBox(height: 6), + pw.Text('1234567890 ABCDEFGHIJ', + style: const pw.TextStyle(fontSize: 8)), + pw.Text('₹ 1,234.56', style: const pw.TextStyle(fontSize: 8)), + pw.SizedBox(height: 6), + pw.BarcodeWidget( + barcode: pw.Barcode.code128(), + data: 'NEARLE-TEST', + width: 140, + height: 30, + drawText: false, + ), + ]), + ), + ); + + return await Printing.directPrintPdf( + printer: target, + onLayout: (_) async => doc.save(), + name: 'Nearle printer test', + ); + } catch (e) { + debugPrint('Test print failed: $e'); + return false; + } } - Future share(SaleTransaction txn) async { + /// Shares the receipt PDF through the OS share sheet. + Future sharePdf(SaleTransaction txn) async { final bytes = await build(txn); await Printing.sharePdf(bytes: bytes, filename: '${txn.invoiceNumber}.pdf'); } - /// Opens the cash drawer via the ESC/POS kick pulse on pin 2. - Future openCashDrawer() async { - // ESC p m t1 t2 — sent to the receipt printer's serial passthrough. - // Wired up here as a no-op placeholder for the concrete driver. - debugPrint('Cash drawer kick: ESC p 0 25 250'); + /// Opens WhatsApp with the bill addressed to the customer's number. + /// + /// WhatsApp cannot accept an attachment through a deep link, so the bill is + /// sent as formatted text. Returns false when there is no number to send to + /// or WhatsApp is not installed. + Future sendToWhatsApp(SaleTransaction txn) async { + final mobile = txn.customer?.mobile.replaceAll(RegExp(r'\D'), ''); + if (mobile == null || mobile.length < 10) return false; + + final uri = Uri.parse( + 'https://wa.me/91$mobile?text=${Uri.encodeComponent(whatsAppText(txn))}', + ); + + try { + return await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (e) { + debugPrint('WhatsApp launch failed: $e'); + return false; + } } + + /// Plain-text bill for messaging. + String whatsAppText(SaleTransaction txn) { + final cart = txn.cart; + final b = StringBuffer() + ..writeln('*${AppConstants.storeName}*') + ..writeln(AppConstants.storeAddress) + ..writeln('GSTIN: ${AppConstants.storeGstin}') + ..writeln() + ..writeln('Invoice: ${txn.invoiceNumber}') + ..writeln('Date: ${Formatters.dateTime(txn.createdAt)}') + ..writeln() + ..writeln('--------------------'); + + for (final line in cart.lines) { + final qty = line.quantity % 1 == 0 + ? line.quantity.toStringAsFixed(0) + : line.quantity.toStringAsFixed(2); + b.writeln('$qty x ${line.product.name}' + ' ${Formatters.money(line.payable)}'); + } + + b + ..writeln('--------------------') + ..writeln('Subtotal: ${Formatters.money(cart.subtotal)}'); + + if (cart.billDiscountTotal > 0) { + b.writeln('Discount: -${Formatters.money(cart.billDiscountTotal)}'); + } + + b + ..writeln('GST: ${Formatters.money(cart.taxAmount)}') + ..writeln('*TOTAL: ${Formatters.money(txn.total)}*') + ..writeln() + ..writeln('Paid by ${txn.paymentSummary}'); + + if (txn.pointsEarned > 0) { + b.writeln('Points earned: +${txn.pointsEarned}'); + } + + b + ..writeln() + ..writeln('Thank you for shopping with us!'); + + return b.toString(); + } + + /// ESC/POS drawer kick: `ESC p m t1 t2` on pin 2. + /// + /// Most drawers are wired to the printer's RJ11 port and open when the + /// printer receives this. It cannot be sent through the PDF pipeline — a + /// PDF is rendered by the driver, not passed through as bytes — so this + /// needs a raw channel to the printer. + /// + /// On desktop with a driver-installed printer there is no raw path from + /// Flutter, so this stays a no-op. Wire it up when you move to ESC/POS: + /// send [drawerKickCommand] over the same socket or Bluetooth link that + /// carries the receipt. + Future openCashDrawer() async { + debugPrint( + 'Cash drawer kick requested — needs a raw ESC/POS channel, ' + 'not the PDF driver. Command: ${drawerKickCommand.join(' ')}', + ); + } + + /// `ESC p 0 25 250` — pin 2, 50ms on, 500ms off. + static const List drawerKickCommand = [27, 112, 0, 25, 250]; +} + +/// One GST rate's slice of a bill. +class _Slab { + _Slab({ + required this.index, + required this.rate, + required this.lines, + required this.factor, + }); + + /// 1-based, printed both above the item group and in the breakup table. + final int index; + + final double rate; + final List lines; + + /// Share of the bill left after bill-level discounts. + final double factor; + + /// e.g. "2.50%" — half the slab, since CGST and SGST each take half. + String get halfPercent => '${(rate * 100 / 2).toStringAsFixed(2)}%'; + + /// GST-inclusive value charged for this slab. + double get total => + lines.fold(0.0, (s, l) => s + l.payable * factor).asMoney; + + double get taxAmount => + lines.fold(0.0, (s, l) => s + l.taxAmount * factor).asMoney; + + double get taxable => (total - taxAmount).asMoney; + + double get cgst => (taxAmount / 2).asMoney; + + double get sgst => (taxAmount - cgst).asMoney; } diff --git a/lib/data/datasources/remote_catalogue_source.dart b/lib/data/datasources/remote_catalogue_source.dart index 7285269..54a1b44 100644 --- a/lib/data/datasources/remote_catalogue_source.dart +++ b/lib/data/datasources/remote_catalogue_source.dart @@ -36,15 +36,19 @@ class CatalogueSyncException implements Exception { /// The real implementation would issue an HTTP request; the contract is the /// same, so only this class changes. class RemoteCatalogueSource { - RemoteCatalogueSource() { + RemoteCatalogueSource({required this.isOffline}) { LocalStore.registerSeed( products: SeedData.products, customers: SeedData.customers, ); } - /// Flipped from Settings to exercise the offline path. - bool simulateOffline = false; + /// Reads the Settings switch on every call. + /// + /// Deliberately a callback rather than a stored bool: a copied flag can fall + /// out of step with the switch, which makes the terminal behave as offline + /// while showing that it is not. + final bool Function() isOffline; /// Streams progress so the import screen can show a real bar rather than an /// indeterminate spinner. @@ -62,10 +66,10 @@ class RemoteCatalogueSource { for (final (progress, stage) in stages) { await Future.delayed(const Duration(milliseconds: 320)); - if (simulateOffline) { + if (isOffline()) { throw const CatalogueSyncException( - 'No connection to the catalogue server. ' - 'Check the network and try again.', + 'Simulate offline is ON in Settings, so the catalogue pull was ' + 'failed on purpose. Turn it off to import.', ); } @@ -83,9 +87,9 @@ class RemoteCatalogueSource { /// Stands in for the back-office order intake API. class RemoteOrderSink { - RemoteOrderSink(); + RemoteOrderSink({required this.isOffline}); - bool simulateOffline = false; + final bool Function() isOffline; /// Uploads a batch of orders and returns the ids the server accepted. /// @@ -96,10 +100,10 @@ class RemoteOrderSink { Duration(milliseconds: 400 + orders.length * 60), ); - if (simulateOffline) { + if (isOffline()) { throw const CatalogueSyncException( - 'Could not reach the order server. Every bill is still stored on ' - 'this terminal and will upload on the next attempt.', + 'Simulate offline is ON in Settings, so the upload was failed on ' + 'purpose. Every bill is still stored on this terminal.', ); } diff --git a/lib/data/datasources/seed_data.dart b/lib/data/datasources/seed_data.dart index 11d51c5..fe1c182 100644 --- a/lib/data/datasources/seed_data.dart +++ b/lib/data/datasources/seed_data.dart @@ -22,6 +22,7 @@ class SeedData { emoji: '🥛', unit: UnitOfMeasure.litre, gstRate: 0.05, + hsnCode: '0401', brand: 'Amul', ), Product( @@ -35,6 +36,7 @@ class SeedData { stock: 30, emoji: '🧈', gstRate: 0.12, + hsnCode: '0405', brand: 'Amul', ), Product( @@ -48,6 +50,7 @@ class SeedData { stock: 40, emoji: '🥣', gstRate: 0.05, + hsnCode: '0403', brand: 'Nandini', ), Product( @@ -60,6 +63,7 @@ class SeedData { stock: 20, emoji: '🧀', gstRate: 0.05, + hsnCode: '0406', brand: 'Milky Mist', ), Product( @@ -73,6 +77,7 @@ class SeedData { stock: 25, emoji: '🧀', gstRate: 0.12, + hsnCode: '0406', brand: 'Britannia', ), @@ -89,6 +94,7 @@ class SeedData { emoji: '🍚', unit: UnitOfMeasure.kilogram, gstRate: 0.05, + hsnCode: '1006', brand: 'India Gate', ), Product( @@ -103,6 +109,7 @@ class SeedData { emoji: '🛢️', unit: UnitOfMeasure.litre, gstRate: 0.05, + hsnCode: '1507', brand: 'Fortune', ), Product( @@ -115,6 +122,7 @@ class SeedData { stock: 45, emoji: '🫘', gstRate: 0.05, + hsnCode: '0713', brand: 'Tata Sampann', ), Product( @@ -129,6 +137,7 @@ class SeedData { emoji: '🌾', unit: UnitOfMeasure.kilogram, gstRate: 0.05, + hsnCode: '1101', brand: 'Aashirvaad', ), Product( @@ -142,6 +151,7 @@ class SeedData { emoji: '🍬', unit: UnitOfMeasure.kilogram, gstRate: 0.05, + hsnCode: '1701', ), Product( id: 'p015', @@ -153,6 +163,7 @@ class SeedData { stock: 120, emoji: '🍜', gstRate: 0.12, + hsnCode: '1902', brand: 'Nestlé', ), Product( @@ -166,6 +177,7 @@ class SeedData { emoji: '🧂', unit: UnitOfMeasure.kilogram, gstRate: 0.05, + hsnCode: '2501', brand: 'Tata', ), @@ -181,6 +193,7 @@ class SeedData { emoji: '🥤', unit: UnitOfMeasure.millilitre, gstRate: 0.28, + hsnCode: '2202', brand: 'Coca-Cola', ), Product( @@ -193,6 +206,7 @@ class SeedData { stock: 100, emoji: '🥭', gstRate: 0.12, + hsnCode: '2202', brand: 'Parle Agro', ), Product( @@ -206,6 +220,7 @@ class SeedData { emoji: '💧', unit: UnitOfMeasure.litre, gstRate: 0.18, + hsnCode: '2201', brand: 'Bisleri', ), Product( @@ -218,6 +233,7 @@ class SeedData { stock: 40, emoji: '🔋', gstRate: 0.28, + hsnCode: '2202', brand: 'Red Bull', ), Product( @@ -231,6 +247,7 @@ class SeedData { stock: 30, emoji: '☕', gstRate: 0.18, + hsnCode: '2101', brand: 'Bru', ), @@ -245,6 +262,7 @@ class SeedData { stock: 90, emoji: '🍪', gstRate: 0.18, + hsnCode: '1905', brand: 'Parle', ), Product( @@ -257,6 +275,7 @@ class SeedData { stock: 110, emoji: '🥔', gstRate: 0.18, + hsnCode: '2005', brand: "Lay's", ), Product( @@ -269,6 +288,7 @@ class SeedData { stock: 75, emoji: '🍫', gstRate: 0.18, + hsnCode: '1806', brand: 'Cadbury', ), Product( @@ -281,6 +301,7 @@ class SeedData { stock: 85, emoji: '🍪', gstRate: 0.18, + hsnCode: '1905', brand: 'Britannia', ), Product( @@ -293,6 +314,7 @@ class SeedData { stock: 60, emoji: '🥜', gstRate: 0.12, + hsnCode: '2106', brand: 'Haldiram', ), @@ -308,6 +330,7 @@ class SeedData { stock: 55, emoji: '🪥', gstRate: 0.18, + hsnCode: '3306', brand: 'Colgate', ), Product( @@ -320,6 +343,7 @@ class SeedData { stock: 70, emoji: '🧼', gstRate: 0.18, + hsnCode: '3401', brand: 'Dove', ), Product( @@ -333,6 +357,7 @@ class SeedData { stock: 25, emoji: '🧴', gstRate: 0.18, + hsnCode: '3305', brand: 'P&G', ), Product( @@ -345,6 +370,7 @@ class SeedData { stock: 30, emoji: '🧴', gstRate: 0.18, + hsnCode: '3304', brand: 'Nivea', ), @@ -361,6 +387,7 @@ class SeedData { emoji: '🧺', unit: UnitOfMeasure.kilogram, gstRate: 0.18, + hsnCode: '3402', brand: 'Surf Excel', ), Product( @@ -373,6 +400,7 @@ class SeedData { stock: 90, emoji: '🧽', gstRate: 0.18, + hsnCode: '3401', brand: 'Vim', ), Product( @@ -385,6 +413,7 @@ class SeedData { stock: 40, emoji: '🧴', gstRate: 0.18, + hsnCode: '3402', brand: 'Harpic', ), Product( @@ -397,6 +426,7 @@ class SeedData { stock: 35, emoji: '🗑️', gstRate: 0.18, + hsnCode: '3923', ), // ----------------------------------------------------------- Fruits @@ -411,6 +441,7 @@ class SeedData { emoji: '🍌', unit: UnitOfMeasure.kilogram, gstRate: 0, + hsnCode: '0803', ), Product( id: 'p061', @@ -423,6 +454,7 @@ class SeedData { emoji: '🍎', unit: UnitOfMeasure.kilogram, gstRate: 0, + hsnCode: '0808', ), Product( id: 'p062', @@ -435,6 +467,7 @@ class SeedData { emoji: '🥭', unit: UnitOfMeasure.kilogram, gstRate: 0, + hsnCode: '0804', ), // ------------------------------------------------------- Vegetables @@ -449,6 +482,7 @@ class SeedData { emoji: '🍅', unit: UnitOfMeasure.kilogram, gstRate: 0, + hsnCode: '0702', ), Product( id: 'p071', @@ -461,6 +495,7 @@ class SeedData { emoji: '🧅', unit: UnitOfMeasure.kilogram, gstRate: 0, + hsnCode: '0703', ), Product( id: 'p072', @@ -473,6 +508,7 @@ class SeedData { emoji: '🥔', unit: UnitOfMeasure.kilogram, gstRate: 0, + hsnCode: '0701', ), Product( id: 'p073', @@ -484,6 +520,7 @@ class SeedData { stock: 8, emoji: '🥕', gstRate: 0, + hsnCode: '0706', ), ]; diff --git a/lib/data/local/app_database.dart b/lib/data/local/app_database.dart index 79d4ae3..f94492a 100644 --- a/lib/data/local/app_database.dart +++ b/lib/data/local/app_database.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:sqflite/sqflite.dart'; @@ -16,7 +14,7 @@ class AppDatabase { static final AppDatabase instance = AppDatabase._(); static const String _fileName = 'nearle_pos.db'; - static const int _version = 1; + static const int _version = 3; Database? _db; @@ -37,7 +35,21 @@ class AppDatabase { Future open({String? overridePath}) async { if (_db != null) return; - if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { + if (kIsWeb) { + // sqflite has no web implementation. Fail loudly here rather than + // letting databaseFactory throw something unreadable further down. + throw UnsupportedError( + 'Nearle POS stores bills in SQLite, which has no web implementation. ' + 'Run the app on macOS, Windows, Linux or an Android tablet.', + ); + } + + const desktop = { + TargetPlatform.windows, + TargetPlatform.linux, + TargetPlatform.macOS, + }; + if (desktop.contains(defaultTargetPlatform)) { sqfliteFfiInit(); databaseFactory = databaseFactoryFfi; } @@ -52,7 +64,12 @@ class AppDatabase { onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, version) async => _createSchema(db), onUpgrade: (db, from, to) async { - // Single version so far; migrations land here as the schema grows. + if (from < 2) await db.execute(_createDayArchive); + if (from < 3) { + await db.execute( + 'ALTER TABLE ${Tables.products} ADD COLUMN hsn_code TEXT', + ); + } }, ), ); @@ -84,6 +101,7 @@ class AppDatabase { for (final t in const [ Tables.orderItems, Tables.orders, + Tables.dayArchive, Tables.products, Tables.customers, Tables.parkedBills, @@ -111,6 +129,7 @@ class AppDatabase { 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 @@ -232,6 +251,9 @@ class AppDatabase { ) '''); + // ------------------------------------------------------------- archive + await db.execute(_createDayArchive); + // ----------------------------------------------------------------- meta await db.execute(''' CREATE TABLE ${Tables.meta} ( @@ -242,9 +264,34 @@ class AppDatabase { } } +/// Running totals per business day. +/// +/// 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. +const String _createDayArchive = ''' + 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 + ) +'''; + class Tables { const Tables._(); + static const String dayArchive = 'day_archive'; + static const String products = 'products'; static const String customers = 'customers'; static const String orders = 'orders'; @@ -260,4 +307,11 @@ class MetaKeys { static const String lastImportAt = 'last_import_at'; static const String catalogueRevision = 'catalogue_revision'; static const String invoiceSequence = 'invoice_sequence'; + + /// Printer chosen in Settings. Stored as the printer's `url`, which is what + /// `Printing.directPrintPdf` needs to target it without a dialog. + static const String printerUrl = 'printer_url'; + static const String printerName = 'printer_name'; + static const String autoPrint = 'auto_print'; + static const String openDrawer = 'open_cash_drawer'; } diff --git a/lib/data/local/catalogue_dao.dart b/lib/data/local/catalogue_dao.dart index 5ead417..a608f1b 100644 --- a/lib/data/local/catalogue_dao.dart +++ b/lib/data/local/catalogue_dao.dart @@ -24,6 +24,7 @@ class CatalogueDao { 'image_url': p.imageUrl, 'unit': p.unit.name, 'gst_rate': p.gstRate, + 'hsn_code': p.hsnCode, 'brand': p.brand, 'is_active': p.isActive ? 1 : 0, 'updated_at': DateTime.now().millisecondsSinceEpoch, @@ -42,6 +43,7 @@ class CatalogueDao { imageUrl: r['image_url'] as String?, unit: UnitOfMeasure.values.byName((r['unit'] as String?) ?? 'piece'), gstRate: (r['gst_rate']! as num).toDouble(), + hsnCode: r['hsn_code'] as String?, brand: r['brand'] as String?, isActive: (r['is_active']! as int) == 1, ); @@ -125,27 +127,34 @@ class CatalogueDao { /// Writes the pulled catalogue in one transaction. /// - /// Stock already decremented by local sales is preserved for products that - /// survive the import, so re-importing mid-shift cannot resurrect sold units. + /// The server's stock figure wins outright. Local sales have already been + /// uploaded and deleted by the time a re-import happens, so the server count + /// is the corrected one — keeping the local number would double-count the + /// units that were sold. Future replaceCatalogue({ required List products, required List customers, }) async { await _db.transaction((txn) async { - final existing = await txn.query( - Tables.products, - columns: ['id', 'stock'], - ); - final heldStock = { - for (final r in existing) r['id']! as String: (r['stock']! as num).toDouble(), - }; + final incoming = products.map((p) => p.id).toSet(); + + // Products the server no longer lists are withdrawn from sale. + final existing = await txn.query(Tables.products, columns: ['id']); + final stale = existing + .map((r) => r['id']! as String) + .where((id) => !incoming.contains(id)) + .toList(); final batch = txn.batch(); + + for (final id in stale) { + batch.delete(Tables.products, where: 'id = ?', whereArgs: [id]); + } + for (final p in products) { - final held = heldStock[p.id]; batch.insert( Tables.products, - productToRow(held == null ? p : p.copyWith(stock: held)), + productToRow(p), conflictAlgorithm: ConflictAlgorithm.replace, ); } diff --git a/lib/data/local/order_dao.dart b/lib/data/local/order_dao.dart index c6ce57e..4dccba5 100644 --- a/lib/data/local/order_dao.dart +++ b/lib/data/local/order_dao.dart @@ -171,17 +171,111 @@ class OrderDao { ); // ----------------------------------------------------------------- Sync - /// Flips accepted orders to `sync_status = 1`. - Future markSynced(List orderIds) async { - if (orderIds.isEmpty) return; - final now = DateTime.now().millisecondsSinceEpoch; - final placeholders = List.filled(orderIds.length, '?').join(','); + /// Folds accepted orders into the day archive, then deletes them. + /// + /// Once the server holds a bill the terminal has no reason to keep it, so + /// the rows go. Their figures are added to [Tables.dayArchive] first, so the + /// shift totals a cashier sees do not collapse after a mid-shift sync. + /// Both steps run in one transaction: if the delete fails the archive is + /// rolled back with it, and nothing is counted twice. + Future archiveAndDelete(List orders) async { + if (orders.isEmpty) return; - await _db.rawUpdate( - 'UPDATE ${Tables.orders} SET sync_status = ?, synced_at = ?, ' - 'sync_error = NULL WHERE id IN ($placeholders)', - [synced, now, ...orderIds], + await _db.transaction((txn) async { + final byDate = >{}; + for (final o in orders) { + byDate.putIfAbsent(businessDateOf(o.createdAt), () => []).add(o); + } + + for (final entry in byDate.entries) { + final date = entry.key; + final batchOrders = entry.value; + + final prior = await txn.query( + Tables.dayArchive, + where: 'business_date = ?', + whereArgs: [date], + limit: 1, + ); + final existing = prior.isEmpty ? null : prior.first; + + final priorPayments = existing == null + ? {} + : (jsonDecode(existing['payments_json']! as String) + as Map) + .map((k, v) => MapEntry(k, (v! as num).toDouble())); + + for (final o in batchOrders) { + for (final p in o.payments) { + priorPayments[p.method.name] = + (priorPayments[p.method.name] ?? 0) + p.amount; + } + } + + final firsts = batchOrders + .map((o) => o.createdAt.millisecondsSinceEpoch) + .toList() + ..sort(); + + await txn.insert( + Tables.dayArchive, + { + 'business_date': date, + 'bill_count': + ((existing?['bill_count'] as int?) ?? 0) + batchOrders.length, + 'item_count': ((existing?['item_count'] as num?)?.toDouble() ?? 0) + + batchOrders.fold(0.0, (s, o) => s + o.cart.totalQuantity), + 'gross_sales': + ((existing?['gross_sales'] as num?)?.toDouble() ?? 0) + + batchOrders.fold(0.0, (s, o) => s + o.total), + 'tax_collected': + ((existing?['tax_collected'] as num?)?.toDouble() ?? 0) + + batchOrders.fold(0.0, (s, o) => s + o.cart.taxAmount), + 'discount_given': + ((existing?['discount_given'] as num?)?.toDouble() ?? 0) + + batchOrders.fold( + 0.0, + (s, o) => + s + + o.cart.billDiscountTotal + + o.cart.lineDiscountTotal, + ), + 'round_off': ((existing?['round_off'] as num?)?.toDouble() ?? 0) + + batchOrders.fold(0.0, (s, o) => s + o.cart.roundOff), + 'points_issued': ((existing?['points_issued'] as int?) ?? 0) + + batchOrders.fold(0, (s, o) => s + o.pointsEarned), + 'points_redeemed': ((existing?['points_redeemed'] as int?) ?? 0) + + batchOrders.fold(0, (s, o) => s + o.pointsRedeemed), + 'payments_json': jsonEncode(priorPayments), + 'first_bill_at': existing?['first_bill_at'] ?? firsts.first, + 'last_bill_at': firsts.last, + 'synced_bills': + ((existing?['synced_bills'] as int?) ?? 0) + batchOrders.length, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + // order_items goes with it via ON DELETE CASCADE. + final ids = orders.map((o) => o.id).toList(); + final placeholders = List.filled(ids.length, '?').join(','); + await txn.delete( + Tables.orders, + where: 'id IN ($placeholders)', + whereArgs: ids, + ); + }); + } + + /// 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; } /// Records a failed attempt. The rows stay at `sync_status = 0`. diff --git a/lib/data/repositories/sync_repository_impl.dart b/lib/data/repositories/sync_repository_impl.dart index bd9bb97..4b1d9c4 100644 --- a/lib/data/repositories/sync_repository_impl.dart +++ b/lib/data/repositories/sync_repository_impl.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:uuid/uuid.dart'; import '../../core/utils/formatters.dart'; @@ -99,14 +101,37 @@ class SyncRepositoryImpl implements SyncRepository { required String cashierName, }) async { final today = DateTime.now(); - final orders = await _store.orders.forBusinessDate(today); - return ShiftReport.fromTransactions( - transactions: orders, + // Bills still held locally. + final live = ShiftReport.fromTransactions( + transactions: await _store.orders.forBusinessDate(today), 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; + + 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, + ); + + return archived + live; } @override @@ -152,10 +177,13 @@ class SyncRepositoryImpl implements SyncRepository { pending.map(_orderToPayload).toList(), ); - onProgress?.call(0.85, 'Marking bills as synced…'); + onProgress?.call(0.85, 'Clearing uploaded bills from this terminal…'); - // Only what the server confirmed is flipped to 1. - await _store.orders.markSynced(accepted); + // 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(); final rejected = ids.where((id) => !accepted.contains(id)).toList(); diff --git a/lib/domain/entities/product.dart b/lib/domain/entities/product.dart index 76ca024..16df760 100644 --- a/lib/domain/entities/product.dart +++ b/lib/domain/entities/product.dart @@ -51,6 +51,7 @@ class Product extends Equatable { this.imageUrl, this.unit = UnitOfMeasure.piece, this.gstRate = AppConstants.defaultGstRate, + this.hsnCode, this.brand, this.isActive = true, }); @@ -72,6 +73,11 @@ class Product extends Equatable { final String? imageUrl; final UnitOfMeasure unit; final double gstRate; + + /// HSN/SAC code. Printed on the tax invoice — legally required on a GST + /// invoice above the turnover threshold. Blank when the catalogue omits it. + final String? hsnCode; + final String? brand; final bool isActive; @@ -124,6 +130,7 @@ class Product extends Equatable { imageUrl: imageUrl, unit: unit, gstRate: gstRate, + hsnCode: hsnCode, brand: brand, isActive: isActive ?? this.isActive, ); diff --git a/lib/domain/entities/shift_report.dart b/lib/domain/entities/shift_report.dart index d5afb0a..4f4e03c 100644 --- a/lib/domain/entities/shift_report.dart +++ b/lib/domain/entities/shift_report.dart @@ -124,6 +124,76 @@ class ShiftReport extends Equatable { ); } + /// Rebuilds a report from an archived day row. + factory ShiftReport.fromArchive({ + required Map row, + required Map payments, + required DateTime businessDate, + required String terminalId, + required String cashierName, + }) { + DateTime? at(Object? v) => + v == null ? null : DateTime.fromMillisecondsSinceEpoch(v as int); + + return ShiftReport( + businessDate: businessDate, + terminalId: terminalId, + cashierName: cashierName, + billCount: (row['bill_count'] as int?) ?? 0, + itemCount: (row['item_count'] as num?)?.toDouble() ?? 0, + grossSales: (row['gross_sales'] as num?)?.toDouble() ?? 0, + taxCollected: (row['tax_collected'] as num?)?.toDouble() ?? 0, + discountGiven: (row['discount_given'] as num?)?.toDouble() ?? 0, + roundOff: (row['round_off'] as num?)?.toDouble() ?? 0, + paymentBreakdown: payments, + loyaltyPointsIssued: (row['points_issued'] as int?) ?? 0, + loyaltyPointsRedeemed: (row['points_redeemed'] as int?) ?? 0, + firstBillAt: at(row['first_bill_at']), + lastBillAt: at(row['last_bill_at']), + ); + } + + /// Adds two reports for the same day. + /// + /// Needed because synced bills are deleted from the terminal: the day's true + /// figures are the archived totals plus whatever is still held locally. + ShiftReport operator +(ShiftReport other) { + final payments = {...paymentBreakdown}; + other.paymentBreakdown.forEach((k, v) { + payments[k] = ((payments[k] ?? 0) + v).asMoney; + }); + + DateTime? earliest(DateTime? a, DateTime? b) { + if (a == null) return b; + if (b == null) return a; + return a.isBefore(b) ? a : b; + } + + DateTime? latest(DateTime? a, DateTime? b) { + if (a == null) return b; + if (b == null) return a; + return a.isAfter(b) ? a : b; + } + + return ShiftReport( + businessDate: businessDate, + terminalId: terminalId, + cashierName: cashierName, + billCount: billCount + other.billCount, + itemCount: itemCount + other.itemCount, + grossSales: (grossSales + other.grossSales).asMoney, + taxCollected: (taxCollected + other.taxCollected).asMoney, + discountGiven: (discountGiven + other.discountGiven).asMoney, + roundOff: (roundOff + other.roundOff).asMoney, + paymentBreakdown: payments, + loyaltyPointsIssued: loyaltyPointsIssued + other.loyaltyPointsIssued, + loyaltyPointsRedeemed: + loyaltyPointsRedeemed + other.loyaltyPointsRedeemed, + firstBillAt: earliest(firstBillAt, other.firstBillAt), + lastBillAt: latest(lastBillAt, other.lastBillAt), + ); + } + /// The JSON body that would be sent to the back office. Map toPayload() => { 'business_date': businessDate.toIso8601String().substring(0, 10), diff --git a/lib/main.dart b/lib/main.dart index 0155b97..04bfdca 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'app/app.dart'; import 'core/services/sound_service.dart'; +import 'core/theme/app_colors.dart'; +import 'core/theme/app_dimens.dart'; import 'data/datasources/local_store.dart'; Future main() async { @@ -12,10 +14,19 @@ Future main() async { await _configureChrome(); - // Opens SQLite and loads the catalogue into memory. Any bill written on a - // previous run is still on disk and still counted as unsynced. - await LocalStore.instance.init(); - await SoundService.instance.preload(); + // Startup work must never be able to prevent runApp from being reached. + // Awaiting it unguarded means one thrown exception leaves a blank window + // with nothing on screen to explain why. + try { + // Opens SQLite and loads the catalogue into memory. Bills written on a + // previous run are still on disk and still counted as unsynced. + await LocalStore.instance.init(); + await SoundService.instance.preload(); + } catch (error, stack) { + debugPrint('Startup failed: $error\n$stack'); + runApp(_StartupFailureApp(error: error)); + return; + } runApp(const ProviderScope(child: NearlePosApp())); } @@ -37,3 +48,84 @@ Future _configureChrome() async { await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); } + +/// Shown when the terminal cannot start. +/// +/// A cashier staring at a white window has nothing to report to support, so +/// the failure is put on screen verbatim. +class _StartupFailureApp extends StatelessWidget { + const _StartupFailureApp({required this.error}); + + final Object error; + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + backgroundColor: AppColors.background, + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.xxl), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: AppColors.dangerSurface, + borderRadius: AppRadius.brMd, + ), + child: const Icon(Icons.error_outline_rounded, + color: AppColors.danger, size: 28), + ), + const SizedBox(height: AppSpacing.xl), + const Text( + 'Nearle POS could not start', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: AppSpacing.sm), + const Text( + 'The terminal failed while opening its local database.', + style: TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + const SizedBox(height: AppSpacing.xl), + Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: AppRadius.brMd, + border: Border.all(color: AppColors.border), + ), + child: SelectableText( + '$error', + style: const TextStyle( + fontSize: 12.5, + height: 1.6, + fontFamily: 'monospace', + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/customer/widgets/customer_capture_sheet.dart b/lib/presentation/customer/widgets/customer_capture_sheet.dart index a53795e..7cf6f71 100644 --- a/lib/presentation/customer/widgets/customer_capture_sheet.dart +++ b/lib/presentation/customer/widgets/customer_capture_sheet.dart @@ -78,12 +78,13 @@ class _CustomerCaptureSheetState Navigator.of(context).pop(); } + /// Saves with whatever was given. Both fields are optional: a bare number + /// is still worth keeping, because it is what the WhatsApp bill is sent to. Future _quickRegister() async { - final name = _name.text.trim(); - if (name.length < 2) { - setState(() => _error = 'Enter a name to save this customer'); - return; - } + final typed = _name.text.trim(); + final name = typed.isEmpty + ? 'Customer ${_digits.substring(_digits.length - 4)}' + : typed; setState(() { _saving = true; @@ -320,8 +321,11 @@ class _CustomerCaptureSheetState _message( Icons.dialpad_rounded, AppColors.textTertiary, - 'Key in a 10-digit mobile number. The lookup runs automatically.', + 'Key in a 10-digit mobile number — the lookup runs automatically. ' + 'Both fields are optional.', ), + const SizedBox(height: AppSpacing.lg), + _nameField(), if (recent.isNotEmpty) ...[ const SizedBox(height: AppSpacing.xl), const Align( @@ -466,22 +470,11 @@ class _CustomerCaptureSheetState _message( Icons.person_search_rounded, AppColors.warning, - 'Not registered yet. Add a name to save them, or carry on ' - 'without.', + 'New number. Add a name if you have it — the bill can be sent to ' + 'this number on WhatsApp either way.', ), const SizedBox(height: AppSpacing.lg), - TextField( - controller: _name, - textCapitalization: TextCapitalization.words, - enabled: !_saving, - onSubmitted: (_) => _quickRegister(), - inputFormatters: [LengthLimitingTextInputFormatter(60)], - decoration: const InputDecoration( - labelText: 'Customer name', - hintText: 'Full name', - prefixIcon: Icon(Icons.person_outline_rounded), - ), - ), + _nameField(), if (_error != null) ...[ const SizedBox(height: AppSpacing.sm), Text( @@ -506,6 +499,19 @@ class _CustomerCaptureSheetState ], ); + Widget _nameField() => TextField( + controller: _name, + textCapitalization: TextCapitalization.words, + enabled: !_saving, + onSubmitted: (_) => _quickRegister(), + inputFormatters: [LengthLimitingTextInputFormatter(60)], + decoration: const InputDecoration( + labelText: 'Customer name', + hintText: 'Optional', + prefixIcon: Icon(Icons.person_outline_rounded), + ), + ); + Widget _miniStat(String value, String label) => Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, diff --git a/lib/presentation/modules/providers/printer_settings.dart b/lib/presentation/modules/providers/printer_settings.dart new file mode 100644 index 0000000..7eff66a --- /dev/null +++ b/lib/presentation/modules/providers/printer_settings.dart @@ -0,0 +1,118 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:printing/printing.dart'; + +import '../../../app/providers.dart'; +import '../../../data/local/app_database.dart'; + +/// Hardware preferences for this terminal. +/// +/// Persisted in `app_meta` rather than held in widget state, so the choice +/// survives a restart — a cashier should not have to reselect the printer +/// every morning. +class PrinterSettings { + const PrinterSettings({ + this.printerUrl, + this.printerName, + this.autoPrint = false, + this.openDrawer = true, + }); + + /// Target passed to `directPrintPdf`. Null means "use the system default". + final String? printerUrl; + + /// Shown in Settings. Kept alongside the url because the url is opaque. + final String? printerName; + + /// Print silently the moment a sale completes. + /// + /// Defaults to **off**: with no printer attached, auto-printing fails + /// invisibly and looks like a bug. + final bool autoPrint; + + final bool openDrawer; + + bool get hasPrinter => printerUrl != null; + + PrinterSettings copyWith({ + String? printerUrl, + String? printerName, + bool clearPrinter = false, + bool? autoPrint, + bool? openDrawer, + }) { + return PrinterSettings( + printerUrl: clearPrinter ? null : (printerUrl ?? this.printerUrl), + printerName: clearPrinter ? null : (printerName ?? this.printerName), + autoPrint: autoPrint ?? this.autoPrint, + openDrawer: openDrawer ?? this.openDrawer, + ); + } +} + +class PrinterSettingsController extends StateNotifier { + PrinterSettingsController(this._ref) : super(const PrinterSettings()) { + _load(); + } + + final Ref _ref; + + Future _load() async { + final store = _ref.read(localStoreProvider); + if (!store.isReady) return; + + final dao = store.catalogue; + state = PrinterSettings( + printerUrl: await dao.meta(MetaKeys.printerUrl), + printerName: await dao.meta(MetaKeys.printerName), + autoPrint: (await dao.meta(MetaKeys.autoPrint)) == '1', + openDrawer: (await dao.meta(MetaKeys.openDrawer)) != '0', + ); + } + + Future selectPrinter(Printer? printer) async { + final dao = _ref.read(localStoreProvider).catalogue; + + if (printer == null) { + await dao.setMeta(MetaKeys.printerUrl, ''); + await dao.setMeta(MetaKeys.printerName, ''); + state = state.copyWith(clearPrinter: true, autoPrint: false); + return; + } + + await dao.setMeta(MetaKeys.printerUrl, printer.url); + await dao.setMeta(MetaKeys.printerName, printer.name); + state = state.copyWith( + printerUrl: printer.url, + printerName: printer.name, + ); + } + + Future setAutoPrint(bool value) async { + // Auto-print with nothing selected would fail silently on every sale. + if (value && !state.hasPrinter) return; + + await _ref + .read(localStoreProvider) + .catalogue + .setMeta(MetaKeys.autoPrint, value ? '1' : '0'); + state = state.copyWith(autoPrint: value); + } + + Future setOpenDrawer(bool value) async { + await _ref + .read(localStoreProvider) + .catalogue + .setMeta(MetaKeys.openDrawer, value ? '1' : '0'); + state = state.copyWith(openDrawer: value); + } +} + +final printerSettingsProvider = + StateNotifierProvider( + (ref) => PrinterSettingsController(ref), +); + +/// Printers the OS can currently see. Re-read whenever Settings is opened. +final availablePrintersProvider = FutureProvider>( + (ref) => ref.watch(receiptServiceProvider).availablePrinters(), +); diff --git a/lib/presentation/modules/screens/settings_view.dart b/lib/presentation/modules/screens/settings_view.dart index c98792a..06774d2 100644 --- a/lib/presentation/modules/screens/settings_view.dart +++ b/lib/presentation/modules/screens/settings_view.dart @@ -8,6 +8,7 @@ import '../../../core/theme/app_dimens.dart'; import '../../../core/utils/formatters.dart'; import '../../../domain/entities/store_account.dart'; import '../../auth/providers/auth_controller.dart'; +import '../providers/printer_settings.dart'; import '../../sync/providers/sync_controller.dart'; import '../widgets/module_widgets.dart'; @@ -21,11 +22,8 @@ class SettingsView extends ConsumerStatefulWidget { class _SettingsViewState extends ConsumerState { bool _scannerSound = true; - bool _autoPrint = true; - bool _openDrawer = true; bool _roundOff = true; bool _autoLoyalty = true; - bool _offline = false; @override Widget build(BuildContext context) { @@ -138,36 +136,160 @@ class _SettingsViewState extends ConsumerState { ), ); - Widget _hardwareCard() => PanelCard( - title: 'Hardware & peripherals', - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _toggle( - 'Scanner beep', - 'Audible confirmation on every scan', - _scannerSound, - (v) => setState(() => _scannerSound = v), + Widget _hardwareCard() { + final settings = ref.watch(printerSettingsProvider); + final controller = ref.read(printerSettingsProvider.notifier); + final printers = ref.watch(availablePrintersProvider); + + return PanelCard( + title: 'Printer & peripherals', + subtitle: 'Install the printer in the operating system first — it then ' + 'appears in this list.', + action: TextButton.icon( + onPressed: () => ref.invalidate(availablePrintersProvider), + icon: const Icon(Icons.refresh_rounded, size: 16), + label: const Text('Rescan'), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + printers.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: AppSpacing.lg), + child: Center(child: CircularProgressIndicator()), ), - _toggle( - 'Print receipt automatically', - 'Sends to the default roll printer with no dialog', - _autoPrint, - (v) => setState(() => _autoPrint = v), + error: (e, _) => Text( + 'Could not list printers: $e', + style: const TextStyle(color: AppColors.danger, fontSize: 12.5), ), - _toggle( - 'Open cash drawer on cash sales', - 'Sends the ESC/POS kick pulse', - _openDrawer, - (v) => setState(() => _openDrawer = v), - ), - const Divider(height: AppSpacing.xxl), - _row('Receipt printer', 'EPSON TM-T82 (default)'), - _row('Barcode scanner', 'Keyboard wedge · detected'), - _row('Cash drawer', 'Connected via printer'), - ], - ), - ); + data: (list) { + if (list.isEmpty) { + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.warningSurface, + borderRadius: AppRadius.brSm, + ), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.print_disabled_rounded, + size: 18, color: AppColors.warning), + SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + 'No printers found. Add the thermal printer in your ' + 'operating system settings, then tap Rescan. Bills ' + 'still print to screen in the meantime.', + style: TextStyle( + fontSize: 12.5, + color: AppColors.warning, + height: 1.45, + ), + ), + ), + ], + ), + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonFormField( + initialValue: list.any((p) => p.url == settings.printerUrl) + ? settings.printerUrl + : null, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Receipt printer', + prefixIcon: Icon(Icons.print_outlined), + isDense: true, + ), + hint: const Text('Print to screen only'), + items: [ + for (final p in list) + DropdownMenuItem( + value: p.url, + child: Text( + p.isDefault ? '${p.name} (system default)' : p.name, + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: (url) => controller.selectPrinter( + url == null + ? null + : list.firstWhere((p) => p.url == url), + ), + ), + const SizedBox(height: AppSpacing.md), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + OutlinedButton.icon( + onPressed: () async { + final ok = await ref + .read(receiptServiceProvider) + .printTestPage( + printerUrl: settings.printerUrl); + if (!context.mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar( + backgroundColor: ok + ? AppColors.success + : AppColors.danger, + content: Text(ok + ? 'Test slip sent to the printer.' + : 'Could not reach the printer.'), + )); + }, + icon: const Icon(Icons.receipt_long_rounded, size: 17), + label: const Text('Print test slip'), + ), + if (settings.hasPrinter) + TextButton( + onPressed: () => controller.selectPrinter(null), + style: TextButton.styleFrom( + foregroundColor: AppColors.textSecondary, + ), + child: const Text('Use screen only'), + ), + ], + ), + ], + ); + }, + ), + const Divider(height: AppSpacing.xxl), + _toggle( + 'Scanner beep', + 'Audible confirmation on every scan', + _scannerSound, + (v) => setState(() => _scannerSound = v), + ), + _toggle( + 'Print receipt automatically', + settings.hasPrinter + ? 'Sends to the selected printer the moment a sale completes' + : 'Select a printer above to enable this', + settings.autoPrint, + settings.hasPrinter ? controller.setAutoPrint : null, + ), + _toggle( + 'Open cash drawer on cash sales', + 'Needs a raw ESC/POS link — see the notes in ReceiptService', + settings.openDrawer, + controller.setOpenDrawer, + ), + ], + ), + ); + } Widget _staffCard(StoreAccount? store, StaffUser? current) => PanelCard( title: 'Users & roles', @@ -212,13 +334,15 @@ class _SettingsViewState extends ConsumerState { _row('Unsynced bills', '$outstanding'), _toggle( 'Simulate offline', - 'Forces import and push to fail, so you can confirm nothing is ' + 'Forces import and sync to fail, so you can confirm nothing is ' 'lost when the network drops', - _offline, + // Read straight from the provider — no local copy to drift. + ref.watch(simulateOfflineProvider), (v) { - setState(() => _offline = v); - ref.read(remoteCatalogueProvider).simulateOffline = v; - ref.read(remoteOrderSinkProvider).simulateOffline = v; + ref.read(simulateOfflineProvider.notifier).state = v; + // Clear any stale failure banner left by the previous setting. + ref.read(catalogueImportProvider.notifier).reset(); + ref.read(orderSyncProvider.notifier).reset(); }, ), ], @@ -286,7 +410,7 @@ class _SettingsViewState extends ConsumerState { String title, String subtitle, bool value, - ValueChanged onChanged, + ValueChanged? onChanged, ) => Padding( padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), diff --git a/lib/presentation/payment/providers/payment_controller.dart b/lib/presentation/payment/providers/payment_controller.dart index c3e796c..77665a3 100644 --- a/lib/presentation/payment/providers/payment_controller.dart +++ b/lib/presentation/payment/providers/payment_controller.dart @@ -74,11 +74,31 @@ class PaymentController extends StateNotifier { return diff > 0 ? diff.asMoney : 0; } + /// Whether Complete Sale should be enabled. + /// + /// Cash is the strict case: the drawer cannot be reconciled and no change can + /// be calculated unless the cashier states what was handed over. Card, UPI + /// and wallet settle on the external terminal, so they need no amount here. bool get canConfirm { + if (_billTotal <= 0) return false; + + // Staged splits already cover the bill. + if (balanceDue <= 0.01) return true; + if (state.activeMethod.needsChange) { - return state.cashTendered >= balanceDue && balanceDue > 0; + return state.cashTendered >= balanceDue; } - return balanceDue > 0; + return true; + } + + /// Why the button is disabled, for display next to it. + String? get blockedReason { + if (_billTotal <= 0) return 'Add at least one item before charging.'; + if (canConfirm) return null; + if (state.activeMethod.needsChange) { + return 'Enter the cash received, or tap Exact.'; + } + return null; } void selectMethod(PaymentMethod method) { @@ -156,10 +176,10 @@ class PaymentController extends StateNotifier { state = state.copyWith(isProcessing: false, result: result); - // Fire and forget — printing must never block the next sale. - final receipts = _ref.read(receiptServiceProvider); - unawaited(receipts.printDirect(result.transaction)); - unawaited(receipts.openCashDrawer()); + // No auto-print: with no roll printer attached this silently failed and + // looked like a bug. The receipt screen shows the bill on the terminal + // and offers Print, WhatsApp and Share explicitly. + unawaited(_ref.read(receiptServiceProvider).openCashDrawer()); unawaited(_ref.read(soundServiceProvider).saleComplete()); // Stock changed, so the grid must refresh; the new order changes the diff --git a/lib/presentation/payment/screens/payment_screen.dart b/lib/presentation/payment/screens/payment_screen.dart index 4222b4b..1f5c98d 100644 --- a/lib/presentation/payment/screens/payment_screen.dart +++ b/lib/presentation/payment/screens/payment_screen.dart @@ -125,7 +125,7 @@ class _PaymentScreenState extends ConsumerState { ); }, ), - bottomNavigationBar: _bottomBar(cart.grandTotal, state, cart.isEmpty), + bottomNavigationBar: _bottomBar(controller, state, cart.grandTotal), ); } @@ -557,7 +557,15 @@ class _PaymentScreenState extends ConsumerState { } // ------------------------------------------------------------ Bottom bar - Widget _bottomBar(double total, PaymentState state, bool cartEmpty) { + Widget _bottomBar( + PaymentController controller, + PaymentState state, + double total, + ) { + // A cash sale cannot complete until the cashier says what was handed over. + final canComplete = controller.canConfirm; + final reason = controller.blockedReason; + return SafeArea( child: Container( padding: const EdgeInsets.fromLTRB( @@ -599,13 +607,45 @@ class _PaymentScreenState extends ConsumerState { ], ), ).animate().shake(duration: 300.ms, hz: 3), + if (reason != null && state.error == null) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Row( + children: [ + const Icon(Icons.info_outline_rounded, + size: 15, color: AppColors.textTertiary), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + reason, + style: const TextStyle( + fontSize: 12.5, + color: AppColors.textTertiary, + ), + ), + ), + if (state.activeMethod.needsChange) + TextButton( + onPressed: () => _setCash(controller.balanceDue), + style: TextButton.styleFrom( + minimumSize: const Size(0, 30), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + ), + ), + child: const Text('Exact', + style: TextStyle(fontSize: 12.5)), + ), + ], + ), + ), PrimaryButton( label: 'Complete Sale', icon: Icons.check_circle_outline_rounded, large: true, tone: ButtonTone.success, busy: state.isProcessing, - onPressed: cartEmpty ? null : _confirm, + onPressed: canComplete ? _confirm : null, trailing: Text( Formatters.money(total), style: AppTypography.money(19, color: Colors.white), diff --git a/lib/presentation/pos/widgets/billing_panel.dart b/lib/presentation/pos/widgets/billing_panel.dart index 018dc75..6d4771e 100644 --- a/lib/presentation/pos/widgets/billing_panel.dart +++ b/lib/presentation/pos/widgets/billing_panel.dart @@ -12,6 +12,7 @@ import '../../../core/utils/formatters.dart'; import '../../../core/widgets/empty_state.dart'; import '../../../core/widgets/primary_button.dart'; import '../../../domain/entities/cart.dart'; +import '../../customer/widgets/customer_capture_sheet.dart'; import '../providers/cart_controller.dart'; import 'cart_line_tile.dart'; import 'discount_sheet.dart'; @@ -404,7 +405,16 @@ class _Actions extends ConsumerWidget { child: PrimaryButton( label: 'CHARGE', large: true, - onPressed: enabled ? () => context.push(AppRoutes.payment) : null, + onPressed: enabled + ? () async { + // Ask once per bill, before payment. Skipping is one tap and + // leaves the sale as walk-in. + if (ref.read(cartControllerProvider).customer == null) { + await showCustomerCaptureSheet(context); + } + if (context.mounted) context.push(AppRoutes.payment); + } + : null, trailing: enabled ? Text( Formatters.money(cart.grandTotal), diff --git a/lib/presentation/pos/widgets/customer_bar.dart b/lib/presentation/pos/widgets/customer_bar.dart index 24d5cd6..c3f5055 100644 --- a/lib/presentation/pos/widgets/customer_bar.dart +++ b/lib/presentation/pos/widgets/customer_bar.dart @@ -19,9 +19,16 @@ class CustomerBar extends ConsumerWidget { ); return Container( - height: AppSizes.customerBarHeight, + // A hard height clipped the subtitle once it wrapped. Minimum height + // keeps the strip its usual size but lets it grow if it must. + constraints: const BoxConstraints( + minHeight: AppSizes.customerBarHeight, + ), color: AppColors.surface, - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xxl), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xl, + vertical: AppSpacing.sm, + ), child: Row(children: [ CircleAvatar( radius: 20, @@ -63,11 +70,17 @@ class CustomerBar extends ConsumerWidget { Text( '${Formatters.mobile(customer.mobile)} · ' '${customer.loyaltyPoints} pts', + maxLines: 1, + overflow: TextOverflow.ellipsis, style: context.text.bodySmall, ) else - Text('No loyalty tracking for this sale', - style: context.text.bodySmall), + Text( + 'No loyalty tracking for this sale', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.text.bodySmall, + ), ], ), ), diff --git a/lib/presentation/pos/widgets/page_header.dart b/lib/presentation/pos/widgets/page_header.dart index 9c6d265..8f2267e 100644 --- a/lib/presentation/pos/widgets/page_header.dart +++ b/lib/presentation/pos/widgets/page_header.dart @@ -24,11 +24,27 @@ class PageHeader extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final compact = layout.sidebarIsDrawer; + + // Measured from the header's own box, not the window. The window can be + // wide while this column is narrow — the sidebar and the docked bill both + // take from it — which is how the status chrome ended up overflowing. + return LayoutBuilder( + builder: (context, box) { + final showStatus = box.maxWidth >= 720; + return _bar(context, ref, compact, showStatus); + }, + ); + } + + Widget _bar( + BuildContext context, + WidgetRef ref, + bool compact, + bool showStatus, + ) { final module = ref.watch(activeModuleProvider); final now = ref.watch(clockProvider).value ?? DateTime.now(); - final compact = layout.sidebarIsDrawer; - // Status chrome is the first thing dropped when width gets tight. - final showStatus = MediaQuery.sizeOf(context).width >= 1180; return Container( constraints: const BoxConstraints(minHeight: AppSizes.headerHeight), @@ -76,7 +92,7 @@ class PageHeader extends ConsumerWidget { const Spacer(), if (showStatus) ...[ - const _LivePill(), + _LivePill(offline: ref.watch(simulateOfflineProvider)), const SizedBox(width: AppSpacing.lg), Text( Formatters.time(now), @@ -139,7 +155,9 @@ class _Breadcrumb extends StatelessWidget { } class _LivePill extends StatefulWidget { - const _LivePill(); + const _LivePill({required this.offline}); + + final bool offline; @override State<_LivePill> createState() => _LivePillState(); @@ -160,40 +178,48 @@ class _LivePillState extends State<_LivePill> @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.xs + 2, - ), - decoration: BoxDecoration( - color: AppColors.successSurface, - borderRadius: AppRadius.brPill, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FadeTransition( - opacity: _c, - child: Container( - width: 7, - height: 7, - decoration: const BoxDecoration( - color: AppColors.success, - shape: BoxShape.circle, + final offline = widget.offline; + final tone = offline ? AppColors.warning : AppColors.success; + final surface = + offline ? AppColors.warningSurface : AppColors.successSurface; + + return Tooltip( + message: offline + ? 'Simulate offline is ON in Settings — imports and syncs are being ' + 'failed deliberately.' + : 'Terminal is operating normally.', + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.xs + 2, + ), + decoration: BoxDecoration( + color: surface, + borderRadius: AppRadius.brPill, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FadeTransition( + opacity: _c, + child: Container( + width: 7, + height: 7, + decoration: BoxDecoration(color: tone, shape: BoxShape.circle), ), ), - ), - const SizedBox(width: AppSpacing.xs + 2), - const Text( - 'LIVE', - style: TextStyle( - color: AppColors.success, - fontSize: 10.5, - fontWeight: FontWeight.w700, - letterSpacing: 0.8, + const SizedBox(width: AppSpacing.xs + 2), + Text( + offline ? 'OFFLINE (SIM)' : 'LIVE', + style: TextStyle( + color: tone, + fontSize: 10.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/presentation/pos/widgets/product_card.dart b/lib/presentation/pos/widgets/product_card.dart index 7214a67..708d20e 100644 --- a/lib/presentation/pos/widgets/product_card.dart +++ b/lib/presentation/pos/widgets/product_card.dart @@ -62,80 +62,114 @@ class _ProductCardState extends State { : AppColors.shadowSm, ), child: Stack(children: [ - Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Opacity( - opacity: disabled ? 0.4 : 1, - child: Text(p.emoji, - style: const TextStyle(fontSize: 40)), + // The grid gives tiles a width derived from the viewport, so + // on narrow screens they can be far shorter than the content's + // natural height. Everything is sized from the box we actually + // get rather than from fixed constants. + LayoutBuilder( + builder: (context, box) { + final h = box.maxHeight; + final tight = h < 170; + + final emoji = (h * 0.22).clamp(20.0, 38.0); + final nameSize = tight ? 12.0 : 13.5; + final priceSize = tight ? 14.0 : 16.0; + + return Padding( + padding: EdgeInsets.all( + tight ? AppSpacing.sm : AppSpacing.md, ), - const SizedBox(height: AppSpacing.sm), - Text( - p.name, - maxLines: 2, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - height: 1.25, - color: disabled - ? AppColors.textTertiary - : AppColors.textPrimary, - ), - ), - const SizedBox(height: AppSpacing.xs + 2), - // Scales down rather than overflowing on small tiles. - FittedBox( - fit: BoxFit.scaleDown, - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - Formatters.money(p.price), - style: AppTypography.money(17, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Opacity( + opacity: disabled ? 0.4 : 1, + child: Text( + p.emoji, + style: TextStyle(fontSize: emoji), + ), + ), + SizedBox(height: tight ? 2 : AppSpacing.sm), + + // Flexible so a long name gives way instead of + // pushing the price and stock line out of the tile. + Flexible( + child: Text( + p.name, + maxLines: tight ? 1 : 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: nameSize, + fontWeight: FontWeight.w600, + height: 1.2, color: disabled ? AppColors.textTertiary - : AppColors.primary), - ), - if (p.hasDiscount) ...[ - const SizedBox(width: AppSpacing.xs + 2), - Padding( - padding: const EdgeInsets.only(bottom: 1.5), - child: Text( - Formatters.money(p.mrp!), - style: const TextStyle( - fontSize: 11.5, - color: AppColors.textTertiary, - decoration: TextDecoration.lineThrough, - ), + : AppColors.textPrimary, + ), + ), + ), + SizedBox(height: tight ? 2 : AppSpacing.xs), + + FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + Formatters.money(p.price), + style: AppTypography.money( + priceSize, + color: disabled + ? AppColors.textTertiary + : AppColors.primary, + ), + ), + if (p.hasDiscount && !tight) ...[ + const SizedBox(width: AppSpacing.xs), + Padding( + padding: const EdgeInsets.only(bottom: 1.5), + child: Text( + Formatters.money(p.mrp!), + style: const TextStyle( + fontSize: 11, + color: AppColors.textTertiary, + decoration: + TextDecoration.lineThrough, + ), + ), + ), + ], + ], + ), + ), + + // Dropped first when the tile is too short for it. + if (h >= 140) ...[ + const SizedBox(height: 2), + Text( + disabled + ? 'Out of stock' + : '${p.stock.toStringAsFixed(0)} in stock', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: disabled + ? AppColors.danger + : (p.isLowStock + ? AppColors.warning + : AppColors.textTertiary), ), ), - ], ], - ), + ], ), - const SizedBox(height: AppSpacing.xs), - Text( - disabled - ? 'Out of stock' - : '${p.stock.toStringAsFixed(0)} in stock', - style: TextStyle( - fontSize: 11.5, - fontWeight: FontWeight.w500, - color: disabled - ? AppColors.danger - : (p.isLowStock - ? AppColors.warning - : AppColors.textTertiary), - ), - ), - ], - ), + ); + }, ), if (p.hasDiscount && !disabled) diff --git a/lib/presentation/pos/widgets/product_grid.dart b/lib/presentation/pos/widgets/product_grid.dart index 2bf3731..722682e 100644 --- a/lib/presentation/pos/widgets/product_grid.dart +++ b/lib/presentation/pos/widgets/product_grid.dart @@ -61,7 +61,10 @@ class ProductGrid extends ConsumerWidget { maxCrossAxisExtent: tileExtent, mainAxisSpacing: AppSpacing.md, crossAxisSpacing: AppSpacing.md, - childAspectRatio: AppSizes.productCardAspect, + // A fixed height, not a ratio. With childAspectRatio the tile grew + // taller as the column got wider, leaving a large empty band under + // every card on a full-screen window. + mainAxisExtent: 186, ), itemCount: items.length, itemBuilder: (context, i) { diff --git a/lib/presentation/receipt/screens/receipt_screen.dart b/lib/presentation/receipt/screens/receipt_screen.dart index 7acb3cc..aafcfc8 100644 --- a/lib/presentation/receipt/screens/receipt_screen.dart +++ b/lib/presentation/receipt/screens/receipt_screen.dart @@ -16,6 +16,7 @@ import '../../../core/utils/formatters.dart'; import '../../../core/widgets/glass_card.dart'; import '../../../core/widgets/primary_button.dart'; import '../../../domain/entities/transaction.dart'; +import '../../modules/providers/printer_settings.dart'; import '../../pos/providers/cart_controller.dart'; import '../widgets/receipt_preview.dart'; @@ -37,6 +38,30 @@ class _ReceiptScreenState extends ConsumerState { @override void initState() { super.initState(); + + // Auto-print is opt-in and only fires when a printer was actually + // selected, so it can never fail invisibly on a terminal with none. + WidgetsBinding.instance.addPostFrameCallback((_) async { + final settings = ref.read(printerSettingsProvider); + if (!settings.autoPrint || !settings.hasPrinter) return; + + final ok = await ref.read(receiptServiceProvider).printDirect( + widget.transaction, + printerUrl: settings.printerUrl, + ); + if (!ok && mounted) { + _cancelAutoReturn(); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(const SnackBar( + backgroundColor: AppColors.danger, + content: Text( + 'Printer did not respond — use Print to send it again.', + ), + )); + } + }); + _timer = Timer.periodic(const Duration(seconds: 1), (t) { if (!mounted) return; setState(() => _seconds--); @@ -182,31 +207,85 @@ class _ReceiptScreenState extends ConsumerState { ), const SizedBox(height: AppSpacing.xxl), - Row(children: [ - Expanded( - child: PrimaryButton( - label: 'Reprint', - icon: Icons.print_outlined, - tone: ButtonTone.neutral, - onPressed: () { - _cancelAutoReturn(); - ref.read(receiptServiceProvider).printWithDialog(txn); - }, + // Wrap, so three actions never overflow a narrow terminal. + Wrap( + spacing: AppSpacing.md, + runSpacing: AppSpacing.md, + children: [ + SizedBox( + width: 150, + child: PrimaryButton( + label: 'Print', + icon: Icons.print_outlined, + tone: ButtonTone.neutral, + onPressed: () async { + _cancelAutoReturn(); + final settings = ref.read(printerSettingsProvider); + final service = ref.read(receiptServiceProvider); + + // Straight to the roll when one is configured; otherwise + // the system preview, which works with no hardware. + final printed = settings.hasPrinter && + await service.printDirect( + txn, + printerUrl: settings.printerUrl, + ); + if (!printed) await service.printPreview(txn); + }, + ), + ), + SizedBox( + width: 170, + child: PrimaryButton( + label: 'WhatsApp', + icon: Icons.chat_rounded, + tone: txn.customer == null + ? ButtonTone.neutral + : ButtonTone.ghost, + onPressed: txn.customer == null + ? null + : () async { + _cancelAutoReturn(); + final sent = await ref + .read(receiptServiceProvider) + .sendToWhatsApp(txn); + if (!context.mounted || sent) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(const SnackBar( + content: Text( + 'Could not open WhatsApp on this device.', + ), + )); + }, + ), + ), + SizedBox( + width: 170, + child: PrimaryButton( + label: 'Share PDF', + icon: Icons.ios_share_rounded, + tone: ButtonTone.neutral, + onPressed: () { + _cancelAutoReturn(); + ref.read(receiptServiceProvider).sharePdf(txn); + }, + ), + ), + ], + ), + if (txn.customer == null) + const Padding( + padding: EdgeInsets.only(top: AppSpacing.sm), + child: Text( + 'No mobile number captured, so WhatsApp is unavailable for ' + 'this bill.', + style: TextStyle( + fontSize: 12, + color: AppColors.textTertiary, + ), ), ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: PrimaryButton( - label: 'Share', - icon: Icons.ios_share_rounded, - tone: ButtonTone.neutral, - onPressed: () { - _cancelAutoReturn(); - ref.read(receiptServiceProvider).share(txn); - }, - ), - ), - ]), const SizedBox(height: AppSpacing.md), PrimaryButton( label: _seconds > 0 diff --git a/lib/presentation/receipt/widgets/receipt_preview.dart b/lib/presentation/receipt/widgets/receipt_preview.dart index fb9bbb4..783ea99 100644 --- a/lib/presentation/receipt/widgets/receipt_preview.dart +++ b/lib/presentation/receipt/widgets/receipt_preview.dart @@ -5,6 +5,7 @@ import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_typography.dart'; import '../../../core/utils/formatters.dart'; +import '../../../domain/entities/cart.dart'; import '../../../domain/entities/transaction.dart'; /// Paper-like preview of what the thermal printer produced. @@ -13,10 +14,33 @@ class ReceiptPreview extends StatelessWidget { final SaleTransaction transaction; + /// Groups the bill by GST rate, mirroring how the printed invoice lists it. + List<_PreviewSlab> _slabs(SaleTransaction txn) { + final cart = txn.cart; + final factor = cart.subtotal <= 0 ? 1.0 : cart.netAmount / cart.subtotal; + + final grouped = >{}; + for (final line in cart.lines) { + grouped.putIfAbsent(line.product.gstRate, () => []).add(line); + } + + final rates = grouped.keys.toList()..sort(); + return [ + for (var i = 0; i < rates.length; i++) + _PreviewSlab(i + 1, rates[i], grouped[rates[i]]!, factor), + ]; + } + + double _gross(SaleTransaction txn) => txn.cart.lines + .fold(0.0, (s, l) => s + (l.product.mrp ?? l.product.price) * l.quantity); + @override Widget build(BuildContext context) { final txn = transaction; final cart = txn.cart; + final slabs = _slabs(txn); + final gross = _gross(txn); + final discount = gross - cart.netAmount; return Container( decoration: BoxDecoration( @@ -29,14 +53,15 @@ class ReceiptPreview extends StatelessWidget { Expanded( child: SingleChildScrollView( padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.xxl, - vertical: AppSpacing.xl, + horizontal: AppSpacing.xl, + vertical: AppSpacing.lg, ), child: DefaultTextStyle( - style: AppTypography.mono(11.5, color: AppColors.textPrimary), + style: AppTypography.mono(10, color: AppColors.textPrimary), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // ------------------------------------------------ header Center( child: Column(children: [ Text( @@ -44,117 +69,251 @@ class ReceiptPreview extends StatelessWidget { style: AppTypography.mono(15) .copyWith(fontWeight: FontWeight.w700), ), - const SizedBox(height: 3), + Text(AppConstants.storeLegalName, + style: AppTypography.mono(8.5)), + const SizedBox(height: 2), Text(AppConstants.storeAddress, textAlign: TextAlign.center, - style: AppTypography.mono(9.5)), - Text('GSTIN: ${AppConstants.storeGstin}', - style: AppTypography.mono(9.5)), - const SizedBox(height: AppSpacing.sm), - Text('TAX INVOICE', - style: AppTypography.mono(11.5) - .copyWith(fontWeight: FontWeight.w700)), + style: AppTypography.mono(8.5)), + Text('Customer care : ${AppConstants.storePhone}', + style: AppTypography.mono(8.5)), + Text('CIN No : ${AppConstants.storeCin}', + style: AppTypography.mono(8.5)), + Text('GSTIN : ${AppConstants.storeGstin}', + style: AppTypography.mono(8.5)), + Text('FSSAI Lic No : ${AppConstants.storeFssai}', + style: AppTypography.mono(8.5)), ]), ), - const _Dashes(), - _row('Invoice', txn.invoiceNumber), - _row('Date', Formatters.receiptStamp(txn.createdAt)), - _row('Cashier', txn.cashierName), - _row('Customer', txn.customer?.name ?? 'Walk-in'), - - const _Dashes(), - Row(children: [ - Expanded(flex: 5, child: _bold('Item')), - Expanded( - flex: 2, - child: _bold('Qty', align: TextAlign.center), - ), - Expanded( - flex: 3, - child: _bold('Amount', align: TextAlign.right), - ), - ]), - const SizedBox(height: AppSpacing.xs), - - ...cart.lines.map((line) => Padding( - padding: const EdgeInsets.symmetric(vertical: 2.5), - child: Row(children: [ - Expanded(flex: 5, child: Text(line.product.name)), - Expanded( - flex: 2, - child: Text( - line.quantity % 1 == 0 - ? line.quantity.toStringAsFixed(0) - : line.quantity.toStringAsFixed(2), - textAlign: TextAlign.center, - ), - ), - Expanded( - flex: 3, - child: Text( - line.payable.toStringAsFixed(2), - textAlign: TextAlign.right, - ), - ), - ]), - )), - - const _Dashes(), - _row('Subtotal', cart.subtotal.toStringAsFixed(2)), - if (cart.billDiscountTotal > 0) - _row('Discount', - '-${cart.billDiscountTotal.toStringAsFixed(2)}'), - if (cart.loyaltyRedemptionValue > 0) - _row('Points redeemed', - '-${cart.loyaltyRedemptionValue.toStringAsFixed(2)}'), - _row('CGST', cart.cgst.toStringAsFixed(2)), - _row('SGST', cart.sgst.toStringAsFixed(2)), - if (cart.roundOff != 0) - _row('Round off', cart.roundOff.toStringAsFixed(2)), - - const _Dashes(), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('TOTAL', - style: AppTypography.mono(15).copyWith( - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - )), - Text( - Formatters.money(txn.total), - style: AppTypography.mono(15).copyWith( - fontWeight: FontWeight.w800, + if (discount > 0) ...[ + const SizedBox(height: AppSpacing.sm), + Center( + child: Text( + 'You have saved Rs.${discount.toStringAsFixed(2)}', + style: AppTypography.mono(11).copyWith( + fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), ), - ], - ), + ), + ], const _Dashes(), - ...txn.payments.map((p) => - _row(p.method.label, p.amount.toStringAsFixed(2))), - if (txn.changeDue > 0) - _row('Change', txn.changeDue.toStringAsFixed(2)), + Center( + child: Column(children: [ + Text('TAX INVOICE', + style: AppTypography.mono(11) + .copyWith(fontWeight: FontWeight.w700)), + Text('xxxxxx Original for Recipient xxxxxx', + style: AppTypography.mono(8)), + ]), + ), + const SizedBox(height: AppSpacing.sm), - if (txn.customer != null) ...[ - const _Dashes(), - _row('Points earned', '+${txn.pointsEarned}'), - _row('Membership', txn.customer!.tier.label), + // -------------------------------------------------- meta + _plain('Place of Supply & State Code: ' + '${AppConstants.stateCode} ${AppConstants.stateName}'), + _plain('Customer Type: ' + '${txn.customer == null ? 'URD' : 'REG'}'), + _row('Date:${Formatters.receiptStamp(txn.createdAt)}', + 'Bill No:${txn.invoiceNumber.split('-').last}'), + _row( + 'Store:${AppConstants.storeCode} ' + 'Cashier:${txn.cashierName}', + 'Pos:${AppConstants.posNumber}', + ), + if (txn.customer != null) + _plain('Customer: ${txn.customer!.name} ' + '${txn.customer!.mobile}'), + + const _Dashes(), + + // ------------------------------------------------- items + Row(children: [ + Expanded(flex: 12, child: _bold('HSN')), + Expanded(flex: 34, child: _bold('Item Description')), + Expanded( + flex: 16, + child: _bold('Net Price', align: TextAlign.right)), + Expanded( + flex: 8, child: _bold('Qty', align: TextAlign.right)), + Expanded( + flex: 18, + child: _bold('Value', align: TextAlign.right)), + ]), + const SizedBox(height: 3), + + for (final slab in slabs) ...[ + Padding( + padding: const EdgeInsets.only(top: 5, bottom: 2), + child: Text( + '${slab.index}) CGST @ ${slab.halfPercent} ' + 'SGST @ ${slab.halfPercent}', + style: AppTypography.mono(9).copyWith( + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + for (final line in slab.lines) + Padding( + padding: const EdgeInsets.symmetric(vertical: 1.5), + child: Row(children: [ + Expanded( + flex: 12, + child: _cell(line.product.hsnCode ?? '-')), + Expanded( + flex: 34, + child: + _cell(line.product.name.toUpperCase())), + Expanded( + flex: 16, + child: _cell( + line.product.price.toStringAsFixed(2), + align: TextAlign.right), + ), + Expanded( + flex: 8, + child: _cell(_qty(line.quantity), + align: TextAlign.right), + ), + Expanded( + flex: 18, + child: _cell(line.payable.toStringAsFixed(2), + align: TextAlign.right), + ), + ]), + ), ], + const _Dashes(), + + // ------------------------------------------------ totals + _row('Items:${cart.lineCount}', + 'Qty:${_qty(cart.totalQuantity)} ' + '${cart.netAmount.toStringAsFixed(2)}'), + const SizedBox(height: 3), + _amount('Gross Sales Value', gross), + if (discount > 0) _amount('Total Discount', discount), + _amount( + '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), + for (final p in txn.payments) + _amount(p.method.label.toUpperCase(), p.amount), + if (txn.changeDue > 0) + _amount('Change Returned', txn.changeDue), + Text('(AMOUNT INCLUSIVE OF APPLICABLE TAXES)', + style: AppTypography.mono(8)), + + const SizedBox(height: AppSpacing.md), + + // ------------------------------------------- gst breakup + Center( + child: Text('------GST Breakup Details------ Amount (INR)', + 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)), + Expanded( + flex: 16, + child: _bold('CGST', align: TextAlign.right)), + Expanded( + flex: 16, + child: _bold('SGST', align: TextAlign.right)), + Expanded( + flex: 14, + child: _bold('CESS', align: TextAlign.right)), + Expanded( + flex: 20, + child: _bold('Total', align: TextAlign.right)), + ]), + const SizedBox(height: 2), + for (final s in slabs) + Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Row(children: [ + Expanded(flex: 10, child: _cell('${s.index}')), + Expanded( + flex: 20, + child: _cell(s.taxable.toStringAsFixed(2), + align: TextAlign.right)), + Expanded( + flex: 16, + child: _cell(s.cgst.toStringAsFixed(2), + align: TextAlign.right)), + Expanded( + flex: 16, + child: _cell(s.sgst.toStringAsFixed(2), + align: TextAlign.right)), + Expanded( + flex: 14, + child: _cell('0.00', align: TextAlign.right)), + Expanded( + flex: 20, + child: _cell(s.total.toStringAsFixed(2), + align: TextAlign.right)), + ]), + ), + const _Dashes(), + Row(children: [ + Expanded(flex: 10, child: _bold('Total')), + Expanded( + flex: 20, + child: _bold( + slabs + .fold(0.0, (a, s) => a + s.taxable) + .toStringAsFixed(2), + align: TextAlign.right)), + Expanded( + flex: 16, + child: _bold( + slabs + .fold(0.0, (a, s) => a + s.cgst) + .toStringAsFixed(2), + align: TextAlign.right)), + Expanded( + flex: 16, + child: _bold( + slabs + .fold(0.0, (a, s) => a + s.sgst) + .toStringAsFixed(2), + align: TextAlign.right)), + Expanded( + flex: 14, + 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)), + ]), + + const _Dashes(), + _plain('TaxInvoice# ${txn.invoiceNumber}'), + if (txn.customer != null) + _plain('Loyalty Pts: earned ${txn.pointsEarned} ' + 'Bal: ${txn.customer!.loyaltyPoints - txn.pointsRedeemed + txn.pointsEarned}'), + _plain('Terms & Conditions Apply'), + const SizedBox(height: AppSpacing.lg), Center( child: Column(children: [ _FakeBarcode(value: txn.invoiceNumber), const SizedBox(height: AppSpacing.sm), - Text('Thank you for shopping with us!', - style: AppTypography.mono(11) + Text('* Thank You for Shopping with us *', + style: AppTypography.mono(10) .copyWith(fontWeight: FontWeight.w700)), - const SizedBox(height: 2), Text('Powered by Nearle POS', - style: AppTypography.mono(9)), + style: AppTypography.mono(8)), ]), ), ], @@ -167,22 +326,97 @@ class ReceiptPreview extends StatelessWidget { ); } - Widget _row(String label, String value) => Padding( - padding: const EdgeInsets.symmetric(vertical: 1.5), + String _qty(double q) => + q % 1 == 0 ? q.toStringAsFixed(0) : q.toStringAsFixed(3); + + Widget _plain(String text) => Padding( + padding: const EdgeInsets.symmetric(vertical: 0.8), + child: Text(text, style: AppTypography.mono(9)), + ); + + Widget _row(String left, String right) => Padding( + padding: const EdgeInsets.symmetric(vertical: 0.8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [Text(label), Text(value)], + children: [ + Flexible( + child: Text(left, + overflow: TextOverflow.ellipsis, + style: AppTypography.mono(9)), + ), + const SizedBox(width: AppSpacing.sm), + Text(right, style: AppTypography.mono(9)), + ], + ), + ); + + Widget _amount(String label, double value, {bool bold = false}) => Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: AppTypography.mono(9.5).copyWith( + fontWeight: bold ? FontWeight.w700 : FontWeight.w400, + color: AppColors.textPrimary, + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Text( + value.toStringAsFixed(2), + style: AppTypography.mono(9.5).copyWith( + fontWeight: bold ? FontWeight.w700 : FontWeight.w400, + color: AppColors.textPrimary, + ), + ), + ], ), ); Widget _bold(String text, {TextAlign align = TextAlign.left}) => Text( text, textAlign: align, - style: AppTypography.mono(11.5).copyWith( + maxLines: 1, + overflow: TextOverflow.clip, + style: AppTypography.mono(8.5).copyWith( fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), ); + + Widget _cell(String text, {TextAlign align = TextAlign.left}) => Text( + text, + textAlign: align, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.mono(8.5), + ); +} + +/// One GST rate's slice of the bill, for the preview. +class _PreviewSlab { + const _PreviewSlab(this.index, this.rate, this.lines, this.factor); + + final int index; + final double rate; + final List lines; + final double factor; + + String get halfPercent => '${(rate * 100 / 2).toStringAsFixed(2)}%'; + + double get total => lines.fold(0.0, (s, l) => s + l.payable * factor); + + double get taxAmount => lines.fold(0.0, (s, l) => s + l.taxAmount * factor); + + double get taxable => total - taxAmount; + + double get cgst => taxAmount / 2; + + double get sgst => taxAmount - cgst; } class _Dashes extends StatelessWidget { diff --git a/lib/presentation/sync/providers/sync_controller.dart b/lib/presentation/sync/providers/sync_controller.dart index e65e591..0a82fae 100644 --- a/lib/presentation/sync/providers/sync_controller.dart +++ b/lib/presentation/sync/providers/sync_controller.dart @@ -80,6 +80,9 @@ class CatalogueImportController extends StateNotifier { state = ImportFailed(event.error ?? 'Import failed.'); return false; } + + /// Drops a stale success or failure banner. + void reset() => state = const ImportIdle(); } final catalogueImportProvider = diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index eee4585..965d77d 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = @@ -16,4 +17,7 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) printing_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin"); printing_plugin_register_with_registrar(printing_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 8e3966a..eb58066 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux printing + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 1aaf9e8..8f62591 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,9 +8,11 @@ import Foundation import audioplayers_darwin import printing import sqflite_darwin +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 5bf88ba..8b86183 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -300,10 +300,10 @@ packages: dependency: transitive description: name: jni - sha256: "5bc9a9daac5ccfbd6a758377600b9f7fdce13d93f26e8012a8941014a5d978d1" + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.0.3" jni_flutter: dependency: transitive description: @@ -693,6 +693,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 1d5fbea..8de34f3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,6 +32,9 @@ dependencies: sqflite_common_ffi: ^2.3.3 path: ^1.9.0 + # Sharing the bill to WhatsApp + url_launcher: ^6.3.0 + # Peripherals: scanner beeps and thermal receipt printing audioplayers: ^6.0.0 pdf: ^3.11.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 9528a2a..87d9de7 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,10 +8,13 @@ #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { AudioplayersWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); PrintingPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PrintingPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 1150ab0..27a28a3 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_windows printing + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST