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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user