Settings > Connectivity > Configure now points a terminal at a back office. Until this existed a store was wired up by editing syncConfigProvider and rebuilding, which made every terminal in a fleet its own build. - Transport picker (offline demo / HTTP / MQTT) with only the relevant fields shown, validated: an MQTT route with no host is refused rather than silently saved, because a terminal pointed at nothing looks exactly like one that is merely offline. - Terminal name and store id are editable and persist to the database. The device id and terminal code are shown but not editable, with a copy button — they are what a support call needs, and re-coding a till must not orphan the bills already written under the old code. - TLS defaults on, with a note that bills carry customer names and numbers. - About card now shows the real terminal, device id and store instead of the literal TERM-01, and the dead "Check for updates" button is now the entry point to this dialog. Lints cleared, analyzer now reports zero issues: - SoundService wrapped a plain bool in a getter and setter that did nothing. - Two post-await guards used context.mounted inside a State, which the analyzer cannot relate to the State's own lifetime. Both are now `mounted`. Tests: 140 -> 141. The new widget test drives the dialog end to end and asserts that saving an MQTT route with no host keeps the dialog open with the error visible. Suite run three times clean. Known gap, documented in docs/sync-contract.md: broker credentials live in memory and must be re-entered after a restart. Persisting them means encrypting at rest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
330 lines
11 KiB
Dart
330 lines
11 KiB
Dart
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 '../../modules/providers/printer_settings.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();
|
|
|
|
// Auto-print is opt-in and only fires when a printer was actually
|
|
// selected, so it can never fail invisibly on a terminal with none.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
final settings = ref.read(printerSettingsProvider);
|
|
if (!settings.autoPrint || !settings.hasPrinter) return;
|
|
|
|
final ok = await ref.read(receiptServiceProvider).printDirect(
|
|
widget.transaction,
|
|
printerUrl: settings.printerUrl,
|
|
);
|
|
if (!ok && mounted) {
|
|
_cancelAutoReturn();
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(const SnackBar(
|
|
backgroundColor: AppColors.danger,
|
|
content: Text(
|
|
'Printer did not respond — use Print to send it again.',
|
|
),
|
|
),);
|
|
}
|
|
});
|
|
|
|
_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.pos);
|
|
}
|
|
|
|
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: const 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),
|
|
// Wrap, so three actions never overflow a narrow terminal.
|
|
Wrap(
|
|
spacing: AppSpacing.md,
|
|
runSpacing: AppSpacing.md,
|
|
children: [
|
|
SizedBox(
|
|
width: 150,
|
|
child: PrimaryButton(
|
|
label: 'Print',
|
|
icon: Icons.print_outlined,
|
|
tone: ButtonTone.neutral,
|
|
onPressed: () async {
|
|
_cancelAutoReturn();
|
|
final settings = ref.read(printerSettingsProvider);
|
|
final service = ref.read(receiptServiceProvider);
|
|
|
|
// Straight to the roll when one is configured; otherwise
|
|
// the system preview, which works with no hardware.
|
|
final printed = settings.hasPrinter &&
|
|
await service.printDirect(
|
|
txn,
|
|
printerUrl: settings.printerUrl,
|
|
);
|
|
if (!printed) await service.printPreview(txn);
|
|
},
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 170,
|
|
child: PrimaryButton(
|
|
label: 'WhatsApp',
|
|
icon: Icons.chat_rounded,
|
|
tone: txn.customer == null
|
|
? ButtonTone.neutral
|
|
: ButtonTone.ghost,
|
|
onPressed: txn.customer == null
|
|
? null
|
|
: () async {
|
|
_cancelAutoReturn();
|
|
final sent = await ref
|
|
.read(receiptServiceProvider)
|
|
.sendToWhatsApp(txn);
|
|
if (!mounted || sent) return;
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(const SnackBar(
|
|
content: Text(
|
|
'Could not open WhatsApp on this device.',
|
|
),
|
|
),);
|
|
},
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 170,
|
|
child: PrimaryButton(
|
|
label: 'Share PDF',
|
|
icon: Icons.ios_share_rounded,
|
|
tone: ButtonTone.neutral,
|
|
onPressed: () {
|
|
_cancelAutoReturn();
|
|
ref.read(receiptServiceProvider).sharePdf(txn);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (txn.customer == null)
|
|
const Padding(
|
|
padding: EdgeInsets.only(top: AppSpacing.sm),
|
|
child: Text(
|
|
'No mobile number captured, so WhatsApp is unavailable for '
|
|
'this bill.',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: AppColors.textTertiary,
|
|
),
|
|
),
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|