third commit

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

View File

@@ -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';

View File

@@ -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 = <double, List<CartLine>>{};
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<Uint8List> 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<bool> 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<void> 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<List<Printer>> 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<bool> 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<void> 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<bool> 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<void> share(SaleTransaction txn) async {
/// Shares the receipt PDF through the OS share sheet.
Future<void> 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<void> 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<bool> 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<void> 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<int> 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<CartLine> 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;
}