import 'package:flutter/material.dart'; import '../../../core/constants/app_constants.dart'; 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. class ReceiptPreview extends StatelessWidget { const ReceiptPreview({super.key, required this.transaction}); 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: const BoxDecoration( color: AppColors.surface, borderRadius: AppRadius.brLg, boxShadow: AppColors.shadowMd, ), child: Column(children: [ const _Perforation(top: true), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.xl, vertical: AppSpacing.lg, ), child: DefaultTextStyle( style: AppTypography.mono(10, color: AppColors.textPrimary), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // ------------------------------------------------ header Center( child: Column(children: [ Text( AppConstants.storeName.toUpperCase(), style: AppTypography.mono(15) .copyWith(fontWeight: FontWeight.w700), ), Text(AppConstants.storeLegalName, style: AppTypography.mono(8.5),), const SizedBox(height: 2), Text(AppConstants.storeAddress, textAlign: TextAlign.center, 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),), ],), ), 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(), 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), // -------------------------------------------------- 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(10) .copyWith(fontWeight: FontWeight.w700),), Text('Powered by Nearle POS', style: AppTypography.mono(8),), ],), ), ], ), ), ), ), const _Perforation(top: false), ],), ); } 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: [ 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, 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 { const _Dashes(); @override Widget build(BuildContext context) { return const Padding( padding: EdgeInsets.symmetric(vertical: AppSpacing.sm), child: Text( '- - - - - - - - - - - - - - - - - - - - - - - - - -', maxLines: 1, overflow: TextOverflow.clip, style: TextStyle(color: AppColors.textTertiary, fontSize: 10), ), ); } } /// Zig-zag torn-paper edge. class _Perforation extends StatelessWidget { const _Perforation({required this.top}); final bool top; @override Widget build(BuildContext context) { return SizedBox( height: 10, child: CustomPaint( size: const Size(double.infinity, 10), painter: _PerforationPainter(top: top), ), ); } } class _PerforationPainter extends CustomPainter { const _PerforationPainter({required this.top}); final bool top; @override void paint(Canvas canvas, Size size) { const tooth = 12.0; final path = Path(); if (top) { path.moveTo(0, size.height); for (var x = 0.0; x < size.width; x += tooth) { path.lineTo(x + tooth / 2, 0); path.lineTo(x + tooth, size.height); } path.lineTo(size.width, size.height); } else { path.moveTo(0, 0); for (var x = 0.0; x < size.width; x += tooth) { path.lineTo(x + tooth / 2, size.height); path.lineTo(x + tooth, 0); } path.lineTo(size.width, 0); } path.close(); canvas.drawPath(path, Paint()..color = AppColors.surface); } @override bool shouldRepaint(covariant _PerforationPainter oldDelegate) => oldDelegate.top != top; } /// Decorative Code-128-style bar rendering for the preview only. class _FakeBarcode extends StatelessWidget { const _FakeBarcode({required this.value}); final String value; @override Widget build(BuildContext context) { final bars = value.codeUnits; return Column(children: [ SizedBox( height: 38, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ for (var i = 0; i < bars.length * 2; i++) Container( width: (bars[i ~/ 2] % 3 == 0) ? 3 : 1.5, margin: const EdgeInsets.symmetric(horizontal: 0.7), color: i.isEven ? AppColors.textPrimary : Colors.transparent, ), ], ), ), const SizedBox(height: 3), Text(value, style: AppTypography.mono(9)), ],); } }