second commit
This commit is contained in:
250
lib/presentation/receipt/screens/receipt_screen.dart
Normal file
250
lib/presentation/receipt/screens/receipt_screen.dart
Normal file
@@ -0,0 +1,250 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
import '../../../core/router/app_router.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../core/utils/extensions.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../core/widgets/glass_card.dart';
|
||||
import '../../../core/widgets/primary_button.dart';
|
||||
import '../../../domain/entities/transaction.dart';
|
||||
import '../../pos/providers/cart_controller.dart';
|
||||
import '../widgets/receipt_preview.dart';
|
||||
|
||||
/// Confirmation screen. Counts down and starts the next sale on its own so an
|
||||
/// unattended terminal never sits on a finished bill.
|
||||
class ReceiptScreen extends ConsumerStatefulWidget {
|
||||
const ReceiptScreen({super.key, required this.transaction});
|
||||
|
||||
final SaleTransaction transaction;
|
||||
|
||||
@override
|
||||
ConsumerState<ReceiptScreen> createState() => _ReceiptScreenState();
|
||||
}
|
||||
|
||||
class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
||||
late int _seconds = AppConstants.postSaleResetDelay.inSeconds + 5;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
|
||||
if (!mounted) return;
|
||||
setState(() => _seconds--);
|
||||
if (_seconds <= 0) _newSale();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _cancelAutoReturn() {
|
||||
_timer?.cancel();
|
||||
if (mounted) setState(() => _seconds = -1);
|
||||
}
|
||||
|
||||
void _newSale() {
|
||||
_timer?.cancel();
|
||||
ref.read(cartControllerProvider.notifier).reset();
|
||||
if (mounted) context.go(AppRoutes.welcome);
|
||||
}
|
||||
|
||||
void _continueBilling() {
|
||||
_timer?.cancel();
|
||||
ref.read(cartControllerProvider.notifier).reset();
|
||||
if (mounted) context.go(AppRoutes.pos);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final txn = widget.transaction;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: SafeArea(
|
||||
child: Listener(
|
||||
onPointerDown: (_) => _cancelAutoReturn(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.xxl),
|
||||
child: context.isCompact
|
||||
? SingleChildScrollView(
|
||||
child: Column(children: [
|
||||
_summary(txn),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
SizedBox(
|
||||
height: 480,
|
||||
child: ReceiptPreview(transaction: txn),
|
||||
),
|
||||
]),
|
||||
)
|
||||
: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(flex: 5, child: _summary(txn)),
|
||||
const SizedBox(width: AppSpacing.xxl),
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: ReceiptPreview(transaction: txn),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _summary(SaleTransaction txn) {
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppSpacing.xxxl),
|
||||
radius: AppRadius.xxl,
|
||||
shadows: AppColors.shadowMd,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 84,
|
||||
height: 84,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.successSurface,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.check_rounded,
|
||||
size: 44, color: AppColors.success),
|
||||
)
|
||||
.animate()
|
||||
.scale(
|
||||
duration: 340.ms,
|
||||
curve: Curves.easeOutBack,
|
||||
begin: const Offset(0.5, 0.5),
|
||||
)
|
||||
.fadeIn(),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
Text('Sale complete',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.text.headlineMedium),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
'${txn.invoiceNumber} · ${Formatters.dateTime(txn.createdAt)}',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.text.bodySmall,
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primarySurface,
|
||||
borderRadius: AppRadius.brLg,
|
||||
),
|
||||
child: Column(children: [
|
||||
Text('Amount paid', style: context.text.labelMedium),
|
||||
Text(
|
||||
Formatters.money(txn.total),
|
||||
style: AppTypography.money(36, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
const Divider(),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_row('Paid via', txn.paymentSummary),
|
||||
if (txn.changeDue > 0)
|
||||
_row('Change returned', Formatters.money(txn.changeDue),
|
||||
highlight: AppColors.success),
|
||||
_row('Items', '${txn.cart.lineCount}'),
|
||||
if (txn.customer != null) ...[
|
||||
_row('Customer', txn.customer!.name),
|
||||
_row('Points earned', '+${txn.pointsEarned}',
|
||||
highlight: AppColors.success),
|
||||
if (txn.pointsRedeemed > 0)
|
||||
_row('Points redeemed', '-${txn.pointsRedeemed}'),
|
||||
],
|
||||
if (txn.cart.totalSavings > 0)
|
||||
_row(
|
||||
'Customer saved',
|
||||
Formatters.money(txn.cart.totalSavings),
|
||||
highlight: AppColors.success,
|
||||
),
|
||||
]),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: PrimaryButton(
|
||||
label: 'Reprint',
|
||||
icon: Icons.print_outlined,
|
||||
tone: ButtonTone.neutral,
|
||||
onPressed: () {
|
||||
_cancelAutoReturn();
|
||||
ref.read(receiptServiceProvider).printWithDialog(txn);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: PrimaryButton(
|
||||
label: 'Share',
|
||||
icon: Icons.ios_share_rounded,
|
||||
tone: ButtonTone.neutral,
|
||||
onPressed: () {
|
||||
_cancelAutoReturn();
|
||||
ref.read(receiptServiceProvider).share(txn);
|
||||
},
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
PrimaryButton(
|
||||
label: _seconds > 0
|
||||
? 'New Sale ($_seconds)'
|
||||
: 'New Sale',
|
||||
icon: Icons.add_shopping_cart_rounded,
|
||||
large: true,
|
||||
onPressed: _newSale,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
TextButton(
|
||||
onPressed: _continueBilling,
|
||||
child: const Text('Back to billing screen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(String label, String value, {Color? highlight}) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
color: AppColors.textSecondary,
|
||||
)),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: highlight ?? AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
289
lib/presentation/receipt/widgets/receipt_preview.dart
Normal file
289
lib/presentation/receipt/widgets/receipt_preview.dart
Normal file
@@ -0,0 +1,289 @@
|
||||
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/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;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final txn = transaction;
|
||||
final cart = txn.cart;
|
||||
|
||||
return Container(
|
||||
decoration: 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.xxl,
|
||||
vertical: AppSpacing.xl,
|
||||
),
|
||||
child: DefaultTextStyle(
|
||||
style: AppTypography.mono(11.5, color: AppColors.textPrimary),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Column(children: [
|
||||
Text(
|
||||
AppConstants.storeName.toUpperCase(),
|
||||
style: AppTypography.mono(15)
|
||||
.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(AppConstants.storeAddress,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.mono(9.5)),
|
||||
Text('GSTIN: ${AppConstants.storeGstin}',
|
||||
style: AppTypography.mono(9.5)),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text('TAX INVOICE',
|
||||
style: AppTypography.mono(11.5)
|
||||
.copyWith(fontWeight: FontWeight.w700)),
|
||||
]),
|
||||
),
|
||||
|
||||
const _Dashes(),
|
||||
_row('Invoice', txn.invoiceNumber),
|
||||
_row('Date', Formatters.receiptStamp(txn.createdAt)),
|
||||
_row('Cashier', txn.cashierName),
|
||||
_row('Customer', txn.customer?.name ?? 'Walk-in'),
|
||||
|
||||
const _Dashes(),
|
||||
Row(children: [
|
||||
Expanded(flex: 5, child: _bold('Item')),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _bold('Qty', align: TextAlign.center),
|
||||
),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: _bold('Amount', align: TextAlign.right),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
|
||||
...cart.lines.map((line) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.5),
|
||||
child: Row(children: [
|
||||
Expanded(flex: 5, child: Text(line.product.name)),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
line.quantity % 1 == 0
|
||||
? line.quantity.toStringAsFixed(0)
|
||||
: line.quantity.toStringAsFixed(2),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Text(
|
||||
line.payable.toStringAsFixed(2),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
]),
|
||||
)),
|
||||
|
||||
const _Dashes(),
|
||||
_row('Subtotal', cart.subtotal.toStringAsFixed(2)),
|
||||
if (cart.billDiscountTotal > 0)
|
||||
_row('Discount',
|
||||
'-${cart.billDiscountTotal.toStringAsFixed(2)}'),
|
||||
if (cart.loyaltyRedemptionValue > 0)
|
||||
_row('Points redeemed',
|
||||
'-${cart.loyaltyRedemptionValue.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)),
|
||||
|
||||
const _Dashes(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('TOTAL',
|
||||
style: AppTypography.mono(15).copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
)),
|
||||
Text(
|
||||
Formatters.money(txn.total),
|
||||
style: AppTypography.mono(15).copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const _Dashes(),
|
||||
...txn.payments.map((p) =>
|
||||
_row(p.method.label, p.amount.toStringAsFixed(2))),
|
||||
if (txn.changeDue > 0)
|
||||
_row('Change', txn.changeDue.toStringAsFixed(2)),
|
||||
|
||||
if (txn.customer != null) ...[
|
||||
const _Dashes(),
|
||||
_row('Points earned', '+${txn.pointsEarned}'),
|
||||
_row('Membership', txn.customer!.tier.label),
|
||||
],
|
||||
|
||||
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(11)
|
||||
.copyWith(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 2),
|
||||
Text('Powered by Nearle POS',
|
||||
style: AppTypography.mono(9)),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const _Perforation(top: false),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(String label, String value) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1.5),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [Text(label), Text(value)],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _bold(String text, {TextAlign align = TextAlign.left}) => Text(
|
||||
text,
|
||||
textAlign: align,
|
||||
style: AppTypography.mono(11.5).copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user