second commit

This commit is contained in:
2026-07-29 11:41:53 +05:30
parent fcccf22bac
commit d72522e737
211 changed files with 19260 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
import 'dart:async';
import 'package:flutter/services.dart';
import '../constants/app_constants.dart';
/// Detects hardware barcode scanners that emulate a keyboard.
///
/// Such scanners emit an entire code in a few milliseconds and terminate it
/// with Enter. We buffer raw key events and only treat the buffer as a scan
/// when the characters arrived faster than a human could type — that way the
/// cashier can still type into the same field by hand.
class BarcodeService {
BarcodeService({this.onScan, this.onManualKey});
final void Function(String code)? onScan;
final VoidCallback? onManualKey;
final StringBuffer _buffer = StringBuffer();
DateTime? _lastKeyAt;
Timer? _flushTimer;
bool _attached = false;
void attach() {
if (_attached) return;
HardwareKeyboard.instance.addHandler(_handleKey);
_attached = true;
}
void detach() {
if (!_attached) return;
HardwareKeyboard.instance.removeHandler(_handleKey);
_flushTimer?.cancel();
_attached = false;
}
bool _handleKey(KeyEvent event) {
if (event is! KeyDownEvent) return false;
final now = DateTime.now();
final gap = _lastKeyAt == null
? Duration.zero
: now.difference(_lastKeyAt!);
_lastKeyAt = now;
// A long pause means a new entry started; discard whatever was buffered.
if (gap > AppConstants.barcodeScanTimeout) {
_buffer.clear();
}
if (event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey == LogicalKeyboardKey.numpadEnter) {
return _flush();
}
final char = event.character;
if (char == null || char.trim().isEmpty) return false;
if (!RegExp(r'^[0-9A-Za-z\-]$').hasMatch(char)) return false;
_buffer.write(char);
_scheduleFlush();
return false;
}
/// Some scanners are not configured to send a terminating Enter, so we also
/// flush on a short idle window.
void _scheduleFlush() {
_flushTimer?.cancel();
_flushTimer = Timer(
AppConstants.barcodeScanTimeout * 2,
() => _flush(),
);
}
bool _flush() {
_flushTimer?.cancel();
final code = _buffer.toString().trim();
_buffer.clear();
if (code.length >= AppConstants.minBarcodeLength) {
onScan?.call(code);
return true;
}
if (code.isNotEmpty) onManualKey?.call();
return false;
}
void dispose() => detach();
}

View File

@@ -0,0 +1,335 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import '../../domain/entities/transaction.dart';
import '../constants/app_constants.dart';
import '../utils/formatters.dart';
/// Builds and prints an 80mm thermal GST invoice.
class ReceiptService {
ReceiptService._();
static final ReceiptService instance = ReceiptService._();
/// 80mm roll with a small safety margin.
static const double _rollWidth = 78 * PdfPageFormat.mm;
Future<Uint8List> build(SaleTransaction txn) async {
final doc = pw.Document(title: txn.invoiceNumber);
final font = await PdfGoogleFonts.interRegular();
final bold = await PdfGoogleFonts.interSemiBold();
final cart = txn.cart;
doc.addPage(
pw.Page(
pageFormat: PdfPageFormat(
_rollWidth,
double.infinity,
marginAll: 6 * PdfPageFormat.mm,
),
theme: pw.ThemeData.withFont(base: font, bold: bold),
build: (context) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
_header(txn),
_divider(),
_meta(txn),
_divider(),
_itemsTable(txn),
_divider(),
_totals(txn),
_divider(),
_taxSummary(txn),
_divider(),
_payments(txn),
if (cart.customer != null) ...[
_divider(),
_loyalty(txn),
],
pw.SizedBox(height: 8),
_footer(txn),
],
),
),
);
return doc.save();
}
// ------------------------------------------------------------- Sections
pw.Widget _header(SaleTransaction txn) => pw.Column(children: [
pw.Text(
AppConstants.storeName.toUpperCase(),
style: pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold),
textAlign: pw.TextAlign.center,
),
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.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)),
]);
}
pw.Widget _itemsTable(SaleTransaction txn) {
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.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,
),
),
]),
]),
)),
],
);
}
pw.Widget _totals(SaleTransaction txn) {
final cart = txn.cart;
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),
),
],
),
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.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,
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),
textAlign: pw.TextAlign.center),
pw.SizedBox(height: 2),
pw.Text('Powered by Nearle POS', style: const pw.TextStyle(fontSize: 6)),
]);
// -------------------------------------------------------------- Helpers
String _qty(double q) =>
q % 1 == 0 ? q.toStringAsFixed(0) : q.toStringAsFixed(3);
pw.Widget _divider() => 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 _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),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(label, style: pw.TextStyle(fontSize: size)),
pw.Text(value, 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 {
try {
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,
name: txn.invoiceNumber,
);
} catch (e) {
debugPrint('Direct print failed: $e');
return false;
}
}
/// 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,
);
}
Future<void> share(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');
}
}

View File

@@ -0,0 +1,62 @@
import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../constants/asset_paths.dart';
/// Audible feedback for scanner billing.
///
/// A cashier scanning at speed watches the customer, not the screen, so the
/// beep is the primary confirmation that an item registered.
class SoundService {
SoundService._();
static final SoundService instance = SoundService._();
final AudioPlayer _player = AudioPlayer(playerId: 'nearle_pos_sfx');
bool _enabled = true;
bool get enabled => _enabled;
set enabled(bool value) => _enabled = value;
Future<void> preload() async {
try {
await _player.setReleaseMode(ReleaseMode.stop);
} catch (e) {
debugPrint('SoundService preload failed: $e');
}
}
Future<void> scanSuccess() => _play(AssetPaths.beepSuccess, haptic: true);
Future<void> scanError() => _play(AssetPaths.beepError, heavy: true);
Future<void> saleComplete() => _play(AssetPaths.chargeComplete);
Future<void> _play(
String asset, {
bool haptic = false,
bool heavy = false,
}) async {
if (!_enabled) return;
// Haptics matter on tablets where the speaker may be muted on the floor.
if (heavy) {
unawaited(HapticFeedback.heavyImpact());
} else if (haptic) {
unawaited(HapticFeedback.selectionClick());
}
try {
await _player.stop();
await _player.play(AssetSource(asset.replaceFirst('assets/', '')));
} catch (e) {
// Never let a missing sound file break the billing flow.
debugPrint('SoundService play failed for $asset: $e');
}
}
Future<void> dispose() => _player.dispose();
}