second commit
This commit is contained in:
91
lib/core/services/barcode_service.dart
Normal file
91
lib/core/services/barcode_service.dart
Normal 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();
|
||||
}
|
||||
335
lib/core/services/receipt_service.dart
Normal file
335
lib/core/services/receipt_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
62
lib/core/services/sound_service.dart
Normal file
62
lib/core/services/sound_service.dart
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user