third commit
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user