Merge pull request 'Fix billing data integrity, sale atomicity and stock safety' (#1) from fix/billing-data-integrity into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -68,6 +68,11 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
GoRoute(
|
||||
path: AppRoutes.receipt,
|
||||
name: 'receipt',
|
||||
// The bill travels in `extra`, which does not survive a reload, a deep
|
||||
// link or a restored session. Sending the cashier back to billing beats
|
||||
// crashing on a null cast.
|
||||
redirect: (context, state) =>
|
||||
state.extra is SaleTransaction ? null : AppRoutes.pos,
|
||||
pageBuilder: (context, state) => _fade(
|
||||
state,
|
||||
ReceiptScreen(transaction: state.extra! as SaleTransaction),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../constants/app_constants.dart';
|
||||
|
||||
@@ -11,17 +12,39 @@ import '../constants/app_constants.dart';
|
||||
/// 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});
|
||||
BarcodeService({
|
||||
this.onScan,
|
||||
this.onManualKey,
|
||||
DateTime Function()? clock,
|
||||
bool Function()? isEditingText,
|
||||
}) : _clock = clock ?? DateTime.now,
|
||||
_isEditingText = isEditingText ?? _primaryFocusIsTextField;
|
||||
|
||||
final void Function(String code)? onScan;
|
||||
final VoidCallback? onManualKey;
|
||||
|
||||
/// Injectable so tests can drive keystroke timing exactly rather than
|
||||
/// sleeping, which is what makes scanner-versus-human behaviour testable.
|
||||
final DateTime Function() _clock;
|
||||
final bool Function() _isEditingText;
|
||||
|
||||
final StringBuffer _buffer = StringBuffer();
|
||||
DateTime? _lastKeyAt;
|
||||
DateTime? _firstKeyAt;
|
||||
int _charCount = 0;
|
||||
|
||||
/// Cleared when any gap in the buffer is too slow to be a machine.
|
||||
bool _machinePaced = true;
|
||||
|
||||
Timer? _flushTimer;
|
||||
|
||||
bool _attached = false;
|
||||
|
||||
/// Sustained pace no typist holds. Applied when a text field has focus, where
|
||||
/// the cost of a false positive is a swallowed keystroke rather than nothing.
|
||||
static Duration get _strictGap =>
|
||||
Duration(microseconds: AppConstants.barcodeScanTimeout.inMicroseconds ~/ 2);
|
||||
|
||||
void attach() {
|
||||
if (_attached) return;
|
||||
HardwareKeyboard.instance.addHandler(_handleKey);
|
||||
@@ -35,22 +58,44 @@ class BarcodeService {
|
||||
_attached = false;
|
||||
}
|
||||
|
||||
/// Whether the cashier is typing into a field right now.
|
||||
///
|
||||
/// A scan still registers while a field has focus, but the bar for calling it
|
||||
/// a scan is raised, because getting it wrong means eating real keystrokes.
|
||||
static bool _primaryFocusIsTextField() {
|
||||
final context = FocusManager.instance.primaryFocus?.context;
|
||||
if (context == null) return false;
|
||||
return context.widget is EditableText ||
|
||||
context.findAncestorWidgetOfExactType<EditableText>() != null;
|
||||
}
|
||||
|
||||
void _resetBuffer() {
|
||||
_buffer.clear();
|
||||
_firstKeyAt = null;
|
||||
_charCount = 0;
|
||||
_machinePaced = true;
|
||||
}
|
||||
|
||||
/// The key handler, exposed so it can be driven directly in tests.
|
||||
@visibleForTesting
|
||||
bool handleKey(KeyEvent event) => _handleKey(event);
|
||||
|
||||
bool _handleKey(KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return false;
|
||||
|
||||
final now = DateTime.now();
|
||||
final gap = _lastKeyAt == null
|
||||
? Duration.zero
|
||||
: now.difference(_lastKeyAt!);
|
||||
final now = _clock();
|
||||
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();
|
||||
_resetBuffer();
|
||||
}
|
||||
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter ||
|
||||
event.logicalKey == LogicalKeyboardKey.numpadEnter) {
|
||||
// Only swallow Enter if the buffer actually resolves to a scan —
|
||||
// otherwise a form submit on a hand-typed field would be eaten.
|
||||
return _flush();
|
||||
}
|
||||
|
||||
@@ -58,9 +103,19 @@ class BarcodeService {
|
||||
if (char == null || char.trim().isEmpty) return false;
|
||||
if (!RegExp(r'^[0-9A-Za-z\-]$').hasMatch(char)) return false;
|
||||
|
||||
if (_charCount > 0 && gap > AppConstants.barcodeScanTimeout) {
|
||||
_machinePaced = false;
|
||||
}
|
||||
|
||||
_buffer.write(char);
|
||||
_firstKeyAt ??= now;
|
||||
_charCount++;
|
||||
_scheduleFlush();
|
||||
return false;
|
||||
|
||||
// Consume the keystroke once the burst is unmistakably machine-paced.
|
||||
// Without this the scanned digits also land in whatever field has focus,
|
||||
// so a scan would add the item *and* type the barcode into the search box.
|
||||
return _looksLikeScan(atLeast: 2);
|
||||
}
|
||||
|
||||
/// Some scanners are not configured to send a terminating Enter, so we also
|
||||
@@ -69,16 +124,31 @@ class BarcodeService {
|
||||
_flushTimer?.cancel();
|
||||
_flushTimer = Timer(
|
||||
AppConstants.barcodeScanTimeout * 2,
|
||||
() => _flush(),
|
||||
_flush,
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether the buffer so far has the shape and speed of a scanner burst.
|
||||
bool _looksLikeScan({required int atLeast}) {
|
||||
if (_charCount < atLeast || !_machinePaced) return false;
|
||||
|
||||
final first = _firstKeyAt;
|
||||
if (first == null || _charCount < 2) return false;
|
||||
|
||||
final span = _lastKeyAt!.difference(first);
|
||||
final averageGap = span ~/ (_charCount - 1);
|
||||
|
||||
return averageGap <=
|
||||
(_isEditingText() ? _strictGap : AppConstants.barcodeScanTimeout);
|
||||
}
|
||||
|
||||
bool _flush() {
|
||||
_flushTimer?.cancel();
|
||||
final code = _buffer.toString().trim();
|
||||
_buffer.clear();
|
||||
final wasScan = _looksLikeScan(atLeast: AppConstants.minBarcodeLength);
|
||||
_resetBuffer();
|
||||
|
||||
if (code.length >= AppConstants.minBarcodeLength) {
|
||||
if (wasScan && code.length >= AppConstants.minBarcodeLength) {
|
||||
onScan?.call(code);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
@@ -58,7 +57,7 @@ class ReceiptService {
|
||||
|
||||
doc.addPage(
|
||||
pw.Page(
|
||||
pageFormat: PdfPageFormat(
|
||||
pageFormat: const PdfPageFormat(
|
||||
_rollWidth,
|
||||
double.infinity,
|
||||
marginAll: 5 * PdfPageFormat.mm,
|
||||
@@ -94,23 +93,23 @@ class ReceiptService {
|
||||
pw.Widget _header(SaleTransaction txn) => pw.Column(children: [
|
||||
pw.Text(
|
||||
AppConstants.storeName.toUpperCase(),
|
||||
style: pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold),
|
||||
style: const pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold),
|
||||
),
|
||||
pw.Text(AppConstants.storeLegalName,
|
||||
style: const pw.TextStyle(fontSize: 7)),
|
||||
style: const pw.TextStyle(fontSize: 7),),
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(AppConstants.storeAddress,
|
||||
style: const pw.TextStyle(fontSize: 6.5),
|
||||
textAlign: pw.TextAlign.center),
|
||||
textAlign: pw.TextAlign.center,),
|
||||
pw.Text('Customer care : ${AppConstants.storePhone}',
|
||||
style: const pw.TextStyle(fontSize: 6.5)),
|
||||
style: const pw.TextStyle(fontSize: 6.5),),
|
||||
pw.Text('CIN No : ${AppConstants.storeCin}',
|
||||
style: const pw.TextStyle(fontSize: 6.5)),
|
||||
style: const pw.TextStyle(fontSize: 6.5),),
|
||||
pw.Text('GSTIN : ${AppConstants.storeGstin}',
|
||||
style: const pw.TextStyle(fontSize: 6.5)),
|
||||
style: const pw.TextStyle(fontSize: 6.5),),
|
||||
pw.Text('FSSAI Lic No : ${AppConstants.storeFssai}',
|
||||
style: const pw.TextStyle(fontSize: 6.5)),
|
||||
]);
|
||||
style: const pw.TextStyle(fontSize: 6.5),),
|
||||
],);
|
||||
|
||||
pw.Widget _savingsLine(SaleTransaction txn) {
|
||||
final saved = _grossSalesValue(txn) - txn.cart.netAmount;
|
||||
@@ -120,7 +119,7 @@ class ReceiptService {
|
||||
padding: const pw.EdgeInsets.symmetric(vertical: 4),
|
||||
child: pw.Text(
|
||||
'You have saved Rs.${saved.toStringAsFixed(2)}',
|
||||
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold),
|
||||
style: const pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold),
|
||||
textAlign: pw.TextAlign.center,
|
||||
),
|
||||
);
|
||||
@@ -128,11 +127,11 @@ class ReceiptService {
|
||||
|
||||
pw.Widget _invoiceTitle() => pw.Column(children: [
|
||||
pw.Text('TAX INVOICE',
|
||||
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold)),
|
||||
style: const pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold),),
|
||||
pw.SizedBox(height: 1),
|
||||
pw.Text('xxxxxx Original for Recipient xxxxxx',
|
||||
style: const pw.TextStyle(fontSize: 6)),
|
||||
]);
|
||||
style: const pw.TextStyle(fontSize: 6),),
|
||||
],);
|
||||
|
||||
pw.Widget _meta(SaleTransaction txn) {
|
||||
final c = txn.customer;
|
||||
@@ -167,7 +166,7 @@ class ReceiptService {
|
||||
pw.Expanded(flex: 16, child: _th('Net Price', right: true)),
|
||||
pw.Expanded(flex: 8, child: _th('Qty', right: true)),
|
||||
pw.Expanded(flex: 18, child: _th('Value', right: true)),
|
||||
]),
|
||||
],),
|
||||
pw.SizedBox(height: 2),
|
||||
for (final slab in slabs) ...[
|
||||
pw.Padding(
|
||||
@@ -175,7 +174,7 @@ class ReceiptService {
|
||||
child: pw.Text(
|
||||
'${slab.index}) CGST @ ${slab.halfPercent} '
|
||||
'SGST @ ${slab.halfPercent}',
|
||||
style: pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold),
|
||||
style: const pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold),
|
||||
),
|
||||
),
|
||||
for (final line in slab.lines)
|
||||
@@ -193,7 +192,7 @@ class ReceiptService {
|
||||
pw.Expanded(
|
||||
flex: 16,
|
||||
child: _td(line.product.price.toStringAsFixed(2),
|
||||
right: true, size: 6.5),
|
||||
right: true, size: 6.5,),
|
||||
),
|
||||
pw.Expanded(
|
||||
flex: 8,
|
||||
@@ -202,9 +201,9 @@ class ReceiptService {
|
||||
pw.Expanded(
|
||||
flex: 18,
|
||||
child: _td(line.payable.toStringAsFixed(2),
|
||||
right: true, size: 6.5),
|
||||
right: true, size: 6.5,),
|
||||
),
|
||||
]),
|
||||
],),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -237,14 +236,14 @@ class ReceiptService {
|
||||
if (txn.changeDue > 0) _amount('Change Returned', txn.changeDue),
|
||||
pw.SizedBox(height: 1),
|
||||
pw.Text('(AMOUNT INCLUSIVE OF APPLICABLE TAXES)',
|
||||
style: const pw.TextStyle(fontSize: 6)),
|
||||
]);
|
||||
style: const pw.TextStyle(fontSize: 6),),
|
||||
],);
|
||||
}
|
||||
|
||||
pw.Widget _gstBreakup(SaleTransaction txn, List<_Slab> slabs) {
|
||||
return pw.Column(children: [
|
||||
pw.Text('------GST Breakup Details------ Amount (INR)',
|
||||
style: const pw.TextStyle(fontSize: 6.5)),
|
||||
style: const pw.TextStyle(fontSize: 6.5),),
|
||||
pw.SizedBox(height: 3),
|
||||
pw.Row(children: [
|
||||
pw.Expanded(flex: 10, child: _th('GST\nIND', size: 6)),
|
||||
@@ -253,7 +252,7 @@ class ReceiptService {
|
||||
pw.Expanded(flex: 16, child: _th('SGST', right: true, size: 6)),
|
||||
pw.Expanded(flex: 14, child: _th('CESS', right: true, size: 6)),
|
||||
pw.Expanded(flex: 20, child: _th('Total\nAmount', right: true, size: 6)),
|
||||
]),
|
||||
],),
|
||||
pw.SizedBox(height: 2),
|
||||
for (final s in slabs)
|
||||
pw.Padding(
|
||||
@@ -263,19 +262,19 @@ class ReceiptService {
|
||||
pw.Expanded(
|
||||
flex: 20,
|
||||
child: _td(s.taxable.toStringAsFixed(2),
|
||||
right: true, size: 6.5)),
|
||||
right: true, size: 6.5,),),
|
||||
pw.Expanded(
|
||||
flex: 16,
|
||||
child: _td(s.cgst.toStringAsFixed(2), right: true, size: 6.5)),
|
||||
child: _td(s.cgst.toStringAsFixed(2), right: true, size: 6.5),),
|
||||
pw.Expanded(
|
||||
flex: 16,
|
||||
child: _td(s.sgst.toStringAsFixed(2), right: true, size: 6.5)),
|
||||
child: _td(s.sgst.toStringAsFixed(2), right: true, size: 6.5),),
|
||||
pw.Expanded(flex: 14, child: _td('0.00', right: true, size: 6.5)),
|
||||
pw.Expanded(
|
||||
flex: 20,
|
||||
child:
|
||||
_td(s.total.toStringAsFixed(2), right: true, size: 6.5)),
|
||||
]),
|
||||
_td(s.total.toStringAsFixed(2), right: true, size: 6.5),),
|
||||
],),
|
||||
),
|
||||
pw.Divider(height: 4, borderStyle: pw.BorderStyle.dashed),
|
||||
pw.Row(children: [
|
||||
@@ -285,24 +284,24 @@ class ReceiptService {
|
||||
child: _th(
|
||||
slabs.fold(0.0, (a, s) => a + s.taxable).toStringAsFixed(2),
|
||||
right: true,
|
||||
size: 6.5)),
|
||||
size: 6.5,),),
|
||||
pw.Expanded(
|
||||
flex: 16,
|
||||
child: _th(slabs.fold(0.0, (a, s) => a + s.cgst).toStringAsFixed(2),
|
||||
right: true, size: 6.5)),
|
||||
right: true, size: 6.5,),),
|
||||
pw.Expanded(
|
||||
flex: 16,
|
||||
child: _th(slabs.fold(0.0, (a, s) => a + s.sgst).toStringAsFixed(2),
|
||||
right: true, size: 6.5)),
|
||||
right: true, size: 6.5,),),
|
||||
pw.Expanded(flex: 14, child: _th('0.00', right: true, size: 6.5)),
|
||||
pw.Expanded(
|
||||
flex: 20,
|
||||
child: _th(
|
||||
slabs.fold(0.0, (a, s) => a + s.total).toStringAsFixed(2),
|
||||
right: true,
|
||||
size: 6.5)),
|
||||
]),
|
||||
]);
|
||||
size: 6.5,),),
|
||||
],),
|
||||
],);
|
||||
}
|
||||
|
||||
pw.Widget _references(SaleTransaction txn) {
|
||||
@@ -341,14 +340,14 @@ class ReceiptService {
|
||||
),
|
||||
pw.SizedBox(height: 3),
|
||||
pw.Text('* Thank You for Shopping with us *',
|
||||
style: pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold)),
|
||||
style: const pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold),),
|
||||
pw.Text('Goods once sold are exchangeable within 7 days with this bill',
|
||||
style: const pw.TextStyle(fontSize: 5.5),
|
||||
textAlign: pw.TextAlign.center),
|
||||
textAlign: pw.TextAlign.center,),
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text('Powered by Nearle POS',
|
||||
style: const pw.TextStyle(fontSize: 5.5)),
|
||||
]);
|
||||
style: const pw.TextStyle(fontSize: 5.5),),
|
||||
],);
|
||||
|
||||
// -------------------------------------------------------------- Helpers
|
||||
/// Pre-discount value, using printed MRP where one is known.
|
||||
@@ -391,12 +390,12 @@ class ReceiptService {
|
||||
style: pw.TextStyle(
|
||||
fontSize: 6.8,
|
||||
fontWeight: bold ? pw.FontWeight.bold : pw.FontWeight.normal,
|
||||
)),
|
||||
),),
|
||||
pw.Text(value.toStringAsFixed(2),
|
||||
style: pw.TextStyle(
|
||||
fontSize: 6.8,
|
||||
fontWeight: bold ? pw.FontWeight.bold : pw.FontWeight.normal,
|
||||
)),
|
||||
),),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -404,14 +403,14 @@ class ReceiptService {
|
||||
pw.Widget _th(String text, {bool right = false, double size = 6.8}) =>
|
||||
pw.Text(text,
|
||||
textAlign: right ? pw.TextAlign.right : pw.TextAlign.left,
|
||||
style: pw.TextStyle(fontSize: size, fontWeight: pw.FontWeight.bold));
|
||||
style: pw.TextStyle(fontSize: size, fontWeight: pw.FontWeight.bold),);
|
||||
|
||||
pw.Widget _td(String text, {bool right = false, double size = 6.8}) =>
|
||||
pw.Text(text,
|
||||
maxLines: 1,
|
||||
overflow: pw.TextOverflow.clip,
|
||||
textAlign: right ? pw.TextAlign.right : pw.TextAlign.left,
|
||||
style: pw.TextStyle(fontSize: size));
|
||||
style: pw.TextStyle(fontSize: size),);
|
||||
|
||||
// --------------------------------------------------------- Printing / IO
|
||||
/// Opens the system print preview.
|
||||
@@ -483,7 +482,7 @@ class ReceiptService {
|
||||
final font = await PdfGoogleFonts.robotoMonoRegular();
|
||||
doc.addPage(
|
||||
pw.Page(
|
||||
pageFormat: PdfPageFormat(
|
||||
pageFormat: const PdfPageFormat(
|
||||
_rollWidth,
|
||||
double.infinity,
|
||||
marginAll: 5 * PdfPageFormat.mm,
|
||||
@@ -491,16 +490,16 @@ class ReceiptService {
|
||||
theme: pw.ThemeData.withFont(base: font),
|
||||
build: (_) => pw.Column(children: [
|
||||
pw.Text(AppConstants.storeName.toUpperCase(),
|
||||
style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
|
||||
style: const pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold),),
|
||||
pw.SizedBox(height: 6),
|
||||
pw.Text('PRINTER TEST', style: const pw.TextStyle(fontSize: 10)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text(target.name, style: const pw.TextStyle(fontSize: 7)),
|
||||
pw.Text(Formatters.dateTime(DateTime.now()),
|
||||
style: const pw.TextStyle(fontSize: 7)),
|
||||
style: const pw.TextStyle(fontSize: 7),),
|
||||
pw.SizedBox(height: 6),
|
||||
pw.Text('1234567890 ABCDEFGHIJ',
|
||||
style: const pw.TextStyle(fontSize: 8)),
|
||||
style: const pw.TextStyle(fontSize: 8),),
|
||||
pw.Text('₹ 1,234.56', style: const pw.TextStyle(fontSize: 8)),
|
||||
pw.SizedBox(height: 6),
|
||||
pw.BarcodeWidget(
|
||||
@@ -510,7 +509,7 @@ class ReceiptService {
|
||||
height: 30,
|
||||
drawText: false,
|
||||
),
|
||||
]),
|
||||
],),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -40,13 +40,13 @@ class AppTheme {
|
||||
?.copyWith(color: AppColors.textOnPrimary),
|
||||
),
|
||||
|
||||
cardTheme: CardThemeData(
|
||||
cardTheme: const CardThemeData(
|
||||
color: AppColors.surface,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.brLg,
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
side: BorderSide(color: AppColors.border),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -149,7 +149,7 @@ class AppTheme {
|
||||
TargetPlatform.macOS: FadeUpwardsPageTransitionsBuilder(),
|
||||
TargetPlatform.linux: FadeUpwardsPageTransitionsBuilder(),
|
||||
TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
|
||||
}),
|
||||
},),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,12 +31,12 @@ class AppTypography {
|
||||
bodyLarge: _s(base.bodyLarge, 15.5, FontWeight.w400, 0),
|
||||
bodyMedium: _s(base.bodyMedium, 14.5, FontWeight.w400, 0),
|
||||
bodySmall: _s(base.bodySmall, 12.5, FontWeight.w400, 0,
|
||||
color: AppColors.textSecondary),
|
||||
color: AppColors.textSecondary,),
|
||||
|
||||
labelLarge: _s(base.labelLarge, 14.5, FontWeight.w600, 0),
|
||||
labelMedium: _s(base.labelMedium, 12.5, FontWeight.w600, 0.1),
|
||||
labelSmall: _s(base.labelSmall, 10.5, FontWeight.w600, 0.6,
|
||||
color: AppColors.textTertiary),
|
||||
color: AppColors.textTertiary,),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ class AppTypography {
|
||||
|
||||
/// Tabular figures — essential so totals don't jitter as quantities change.
|
||||
static TextStyle money(double size,
|
||||
{FontWeight weight = FontWeight.w700, Color? color}) =>
|
||||
{FontWeight weight = FontWeight.w700, Color? color,}) =>
|
||||
GoogleFonts.poppins(
|
||||
fontSize: size,
|
||||
fontWeight: weight,
|
||||
|
||||
@@ -29,7 +29,7 @@ extension BuildContextX on BuildContext {
|
||||
content: Text(message),
|
||||
backgroundColor: background,
|
||||
duration: const Duration(seconds: 2),
|
||||
));
|
||||
),);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class EmptyState extends StatelessWidget {
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(emoji,
|
||||
style: TextStyle(fontSize: compact ? 28 : 40)),
|
||||
style: TextStyle(fontSize: compact ? 28 : 40),),
|
||||
),
|
||||
SizedBox(height: compact ? AppSpacing.lg : AppSpacing.xxl),
|
||||
Text(
|
||||
|
||||
@@ -49,7 +49,11 @@ class PrimaryButton extends StatelessWidget {
|
||||
? MainAxisAlignment.spaceBetween
|
||||
: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
// Flexible so the label gives way to the trailing total rather
|
||||
// than overflowing the button: on a narrow bill panel "Charge"
|
||||
// plus a five-figure amount is wider than the button itself.
|
||||
Flexible(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
@@ -70,7 +74,11 @@ class PrimaryButton extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
trailing!,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import '../../domain/entities/customer.dart';
|
||||
import '../../domain/entities/product.dart';
|
||||
import '../../domain/entities/sync_event.dart';
|
||||
import '../local/app_database.dart';
|
||||
import '../local/catalogue_dao.dart';
|
||||
import '../local/order_dao.dart';
|
||||
import '../local/sync_log_dao.dart';
|
||||
|
||||
/// Terminal-side storage facade.
|
||||
///
|
||||
@@ -17,9 +19,11 @@ class LocalStore {
|
||||
|
||||
late CatalogueDao catalogue;
|
||||
late OrderDao orders;
|
||||
late SyncLogDao syncLog;
|
||||
|
||||
final Map<String, Product> _products = {};
|
||||
final Map<String, Customer> _customers = {};
|
||||
final List<SyncEvent> _syncEvents = [];
|
||||
|
||||
DateTime? _lastImportAt;
|
||||
String? _catalogueRevision;
|
||||
@@ -39,6 +43,7 @@ class LocalStore {
|
||||
|
||||
catalogue = CatalogueDao(AppDatabase.instance.db);
|
||||
orders = OrderDao(AppDatabase.instance.db);
|
||||
syncLog = SyncLogDao(AppDatabase.instance.db);
|
||||
|
||||
await hydrate();
|
||||
_ready = true;
|
||||
@@ -63,6 +68,20 @@ class LocalStore {
|
||||
: DateTime.fromMillisecondsSinceEpoch(int.parse(stamp));
|
||||
_catalogueRevision = await catalogue.meta(MetaKeys.catalogueRevision);
|
||||
_unsyncedOrders = await orders.unsyncedCount();
|
||||
|
||||
_syncEvents
|
||||
..clear()
|
||||
..addAll(await syncLog.recent());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Sync log
|
||||
/// Newest first. Backed by the table, so it survives a restart.
|
||||
List<SyncEvent> get syncEvents => List.unmodifiable(_syncEvents);
|
||||
|
||||
Future<void> appendSyncEvent(SyncEvent event) async {
|
||||
await syncLog.insert(event);
|
||||
await syncLog.trim();
|
||||
_syncEvents.insert(0, event);
|
||||
}
|
||||
|
||||
/// Test helper: wipes every table and reloads.
|
||||
@@ -102,6 +121,15 @@ class LocalStore {
|
||||
required DateTime at,
|
||||
}) async {
|
||||
await catalogue.replaceCatalogue(products: products, customers: customers);
|
||||
|
||||
// The server's stock figure predates any sale this terminal has made but
|
||||
// not yet uploaded, so those units would reappear on the shelf. Replay them
|
||||
// before anyone can bill against the inflated count.
|
||||
final committed = await orders.unsyncedStockCommitments();
|
||||
if (committed.isNotEmpty) {
|
||||
await catalogue.decrementStock(committed);
|
||||
}
|
||||
|
||||
await catalogue.setMeta(
|
||||
MetaKeys.lastImportAt,
|
||||
'${at.millisecondsSinceEpoch}',
|
||||
@@ -131,6 +159,12 @@ class LocalStore {
|
||||
|
||||
Future<void> applyStockMovement(Map<String, double> quantities) async {
|
||||
await catalogue.decrementStock(quantities);
|
||||
cacheStockMovement(quantities);
|
||||
}
|
||||
|
||||
/// Mirrors a stock decrement already written to disk into the memory cache.
|
||||
/// Used after a sale is committed as part of a larger transaction.
|
||||
void cacheStockMovement(Map<String, double> quantities) {
|
||||
quantities.forEach((id, qty) {
|
||||
final p = _products[id];
|
||||
if (p == null) return;
|
||||
@@ -149,6 +183,9 @@ class LocalStore {
|
||||
_customers[c.id] = c;
|
||||
}
|
||||
|
||||
/// Mirrors a customer already written to disk into the memory cache.
|
||||
void cacheCustomer(Customer c) => _customers[c.id] = c;
|
||||
|
||||
// ----------------------------------------------------------------- Orders
|
||||
/// Refreshes the cached unsynced tally after a write or a sync.
|
||||
Future<int> refreshUnsyncedCount() async {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
/// SQLite database for the terminal.
|
||||
@@ -14,7 +13,7 @@ class AppDatabase {
|
||||
static final AppDatabase instance = AppDatabase._();
|
||||
|
||||
static const String _fileName = 'nearle_pos.db';
|
||||
static const int _version = 3;
|
||||
static const int _version = 4;
|
||||
|
||||
Database? _db;
|
||||
|
||||
@@ -70,6 +69,7 @@ class AppDatabase {
|
||||
'ALTER TABLE ${Tables.products} ADD COLUMN hsn_code TEXT',
|
||||
);
|
||||
}
|
||||
if (from < 4) await _upgradeToV4(db, from: from);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -238,18 +238,7 @@ class AppDatabase {
|
||||
''');
|
||||
|
||||
// -------------------------------------------------------------- syncLog
|
||||
await db.execute('''
|
||||
CREATE TABLE ${Tables.syncLog} (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
synced_at INTEGER,
|
||||
summary TEXT NOT NULL,
|
||||
error TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
''');
|
||||
await db.execute(_createSyncLog);
|
||||
|
||||
// ------------------------------------------------------------- archive
|
||||
await db.execute(_createDayArchive);
|
||||
@@ -264,14 +253,61 @@ class AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Running totals per business day.
|
||||
/// Moves the schema to v4.
|
||||
///
|
||||
/// Adds the payload column the sync log needs to be usable at all, and re-keys
|
||||
/// the day archive by cashier so a shift report can be scoped to whoever is
|
||||
/// settling their till. Existing archived rows predate per-cashier attribution,
|
||||
/// so they are folded under an empty name rather than guessed at.
|
||||
Future<void> _upgradeToV4(Database db, {required int from}) async {
|
||||
// A v1 database had no sync_log payload column; v2+ did not either.
|
||||
await db.execute(
|
||||
'ALTER TABLE ${Tables.syncLog} ADD COLUMN payload_json TEXT',
|
||||
);
|
||||
|
||||
await db.execute('ALTER TABLE ${Tables.dayArchive} RENAME TO _day_archive_v3');
|
||||
await db.execute(_createDayArchive);
|
||||
await db.execute('''
|
||||
INSERT INTO ${Tables.dayArchive} (
|
||||
business_date, cashier_name, bill_count, item_count, gross_sales,
|
||||
tax_collected, discount_given, round_off, points_issued,
|
||||
points_redeemed, payments_json, first_bill_at, last_bill_at,
|
||||
synced_bills
|
||||
)
|
||||
SELECT
|
||||
business_date, '', bill_count, item_count, gross_sales,
|
||||
tax_collected, discount_given, round_off, points_issued,
|
||||
points_redeemed, payments_json, first_bill_at, last_bill_at,
|
||||
synced_bills
|
||||
FROM _day_archive_v3
|
||||
''');
|
||||
await db.execute('DROP TABLE _day_archive_v3');
|
||||
}
|
||||
|
||||
const String _createSyncLog = '''
|
||||
CREATE TABLE sync_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
synced_at INTEGER,
|
||||
summary TEXT NOT NULL,
|
||||
error TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
payload_json TEXT
|
||||
)
|
||||
''';
|
||||
|
||||
/// Running totals per business day and cashier.
|
||||
///
|
||||
/// Synced orders are deleted from the terminal, so their figures are folded in
|
||||
/// here first — otherwise "Bills Today" would collapse to zero the moment a
|
||||
/// mid-shift sync ran.
|
||||
/// mid-shift sync ran. Keyed by cashier as well as date, because once the
|
||||
/// orders are gone this row is the only thing left to settle a till against.
|
||||
const String _createDayArchive = '''
|
||||
CREATE TABLE day_archive (
|
||||
business_date TEXT PRIMARY KEY,
|
||||
business_date TEXT NOT NULL,
|
||||
cashier_name TEXT NOT NULL DEFAULT '',
|
||||
bill_count INTEGER NOT NULL DEFAULT 0,
|
||||
item_count REAL NOT NULL DEFAULT 0,
|
||||
gross_sales REAL NOT NULL DEFAULT 0,
|
||||
@@ -283,7 +319,8 @@ const String _createDayArchive = '''
|
||||
payments_json TEXT NOT NULL DEFAULT '{}',
|
||||
first_bill_at INTEGER,
|
||||
last_bill_at INTEGER,
|
||||
synced_bills INTEGER NOT NULL DEFAULT 0
|
||||
synced_bills INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (business_date, cashier_name)
|
||||
)
|
||||
''';
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ class CatalogueDao {
|
||||
|
||||
static DateTime? _date(Object? millis) => millis == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(millis! as int);
|
||||
: DateTime.fromMillisecondsSinceEpoch(millis as int);
|
||||
|
||||
// -------------------------------------------------------------- Products
|
||||
Future<List<Product>> allProducts() async {
|
||||
@@ -248,10 +248,30 @@ class CatalogueDao {
|
||||
}
|
||||
|
||||
/// Monotonic invoice counter held in the meta table.
|
||||
///
|
||||
/// Read and write happen in one transaction. Done as separate awaits, two
|
||||
/// checkouts could interleave, take the same number, and the second insert
|
||||
/// would then fail the UNIQUE constraint on `invoice_number` — after the
|
||||
/// sequence had already been consumed.
|
||||
Future<int> nextInvoiceSequence() async {
|
||||
final current = int.tryParse(await meta(MetaKeys.invoiceSequence) ?? '0') ?? 0;
|
||||
return _db.transaction<int>((txn) async {
|
||||
final rows = await txn.query(
|
||||
Tables.meta,
|
||||
where: 'key = ?',
|
||||
whereArgs: [MetaKeys.invoiceSequence],
|
||||
limit: 1,
|
||||
);
|
||||
final current = rows.isEmpty
|
||||
? 0
|
||||
: int.tryParse(rows.first['value']! as String) ?? 0;
|
||||
final next = current + 1;
|
||||
await setMeta(MetaKeys.invoiceSequence, '$next');
|
||||
|
||||
await txn.insert(
|
||||
Tables.meta,
|
||||
{'key': MetaKeys.invoiceSequence, 'value': '$next'},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,11 +28,48 @@ class OrderDao {
|
||||
'${dt.day.toString().padLeft(2, '0')}';
|
||||
|
||||
// ----------------------------------------------------------------- Write
|
||||
/// Writes the bill and its lines atomically.
|
||||
Future<void> insertOrder(SaleTransaction t) async {
|
||||
/// Commits an entire sale in one transaction.
|
||||
///
|
||||
/// The bill, the stock it consumed and the shopper's loyalty movement have to
|
||||
/// land together or not at all. Applied as three separate writes, a failure
|
||||
/// part-way through left a persisted bill with unapplied loyalty while the
|
||||
/// cashier saw an error and rang the sale again — a duplicate bill and a
|
||||
/// double stock decrement.
|
||||
Future<void> commitSale({
|
||||
required SaleTransaction transaction,
|
||||
required Map<String, double> stockMovements,
|
||||
Map<String, Object?>? customerRow,
|
||||
}) async {
|
||||
await _db.transaction((txn) async {
|
||||
await _insertOrder(txn, transaction);
|
||||
|
||||
final batch = txn.batch();
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
stockMovements.forEach((id, qty) {
|
||||
batch.rawUpdate(
|
||||
'UPDATE ${Tables.products} '
|
||||
'SET stock = MAX(0, stock - ?), updated_at = ? WHERE id = ?',
|
||||
[qty, now, id],
|
||||
);
|
||||
});
|
||||
if (customerRow != null) {
|
||||
batch.insert(
|
||||
Tables.customers,
|
||||
customerRow,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _insertOrder(DatabaseExecutor txn, SaleTransaction t) async {
|
||||
final cart = t.cart;
|
||||
|
||||
await _db.transaction((txn) async {
|
||||
// A replace of the order row does not reliably cascade to its lines, so
|
||||
// clear them first — otherwise re-saving a bill doubles its items.
|
||||
await txn.delete(Tables.orderItems, where: 'order_id = ?', whereArgs: [t.id]);
|
||||
|
||||
await txn.insert(
|
||||
Tables.orders,
|
||||
{
|
||||
@@ -53,7 +90,7 @@ class OrderDao {
|
||||
'tax_amount': cart.taxAmount,
|
||||
'round_off': cart.roundOff,
|
||||
'total': t.total,
|
||||
'points_earned': cart.pointsEarned,
|
||||
'points_earned': t.pointsEarned,
|
||||
'points_redeemed': cart.pointsRedeemed,
|
||||
'payments_json': jsonEncode([
|
||||
for (final p in t.payments)
|
||||
@@ -89,15 +126,29 @@ class OrderDao {
|
||||
});
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Read
|
||||
Future<List<SaleTransaction>> recent({int limit = 100}) =>
|
||||
_query(orderBy: 'created_at DESC', limit: limit);
|
||||
|
||||
Future<List<SaleTransaction>> forBusinessDate(DateTime day) =>
|
||||
_query(where: 'business_date = ?', whereArgs: [businessDateOf(day)]);
|
||||
/// Bills for a day, optionally narrowed to one operator.
|
||||
///
|
||||
/// A shift report that is settled against a till has to cover exactly the
|
||||
/// bills that cashier rang, not everything the terminal did that day.
|
||||
Future<List<SaleTransaction>> forBusinessDate(
|
||||
DateTime day, {
|
||||
String? cashierName,
|
||||
}) =>
|
||||
_query(
|
||||
where: cashierName == null
|
||||
? 'business_date = ?'
|
||||
: 'business_date = ? AND cashier_name = ?',
|
||||
whereArgs: [
|
||||
businessDateOf(day),
|
||||
if (cashierName != null) cashierName,
|
||||
],
|
||||
);
|
||||
|
||||
/// The end-of-day upload set.
|
||||
Future<List<SaleTransaction>> unsynced({int limit = 500}) => _query(
|
||||
@@ -124,6 +175,25 @@ class OrderDao {
|
||||
return r.first['c']! as int;
|
||||
}
|
||||
|
||||
/// Stock already consumed by bills that have not yet reached the server.
|
||||
///
|
||||
/// A re-import overwrites stock with the server's figure, which does not know
|
||||
/// about these sales. Replaying them keeps the shelf count honest when the
|
||||
/// catalogue is pulled again mid-shift.
|
||||
Future<Map<String, double>> unsyncedStockCommitments() async {
|
||||
final rows = await _db.rawQuery(
|
||||
'SELECT i.product_id AS pid, SUM(i.quantity) AS q '
|
||||
'FROM ${Tables.orderItems} i '
|
||||
'JOIN ${Tables.orders} o ON o.id = i.order_id '
|
||||
'WHERE o.sync_status = ? GROUP BY i.product_id',
|
||||
[pending],
|
||||
);
|
||||
return {
|
||||
for (final r in rows)
|
||||
r['pid']! as String: (r['q']! as num).toDouble(),
|
||||
};
|
||||
}
|
||||
|
||||
Future<double> salesTotalForDay(DateTime day) async {
|
||||
final r = await _db.rawQuery(
|
||||
'SELECT COALESCE(SUM(total), 0) AS t FROM ${Tables.orders} '
|
||||
@@ -182,19 +252,23 @@ class OrderDao {
|
||||
if (orders.isEmpty) return;
|
||||
|
||||
await _db.transaction((txn) async {
|
||||
final byDate = <String, List<SaleTransaction>>{};
|
||||
// Grouped by cashier as well as day: the archive is what a till is
|
||||
// settled against once the bills themselves are gone.
|
||||
final byDate = <({String date, String cashier}), List<SaleTransaction>>{};
|
||||
for (final o in orders) {
|
||||
byDate.putIfAbsent(businessDateOf(o.createdAt), () => []).add(o);
|
||||
final key = (date: businessDateOf(o.createdAt), cashier: o.cashierName);
|
||||
byDate.putIfAbsent(key, () => []).add(o);
|
||||
}
|
||||
|
||||
for (final entry in byDate.entries) {
|
||||
final date = entry.key;
|
||||
final date = entry.key.date;
|
||||
final cashier = entry.key.cashier;
|
||||
final batchOrders = entry.value;
|
||||
|
||||
final prior = await txn.query(
|
||||
Tables.dayArchive,
|
||||
where: 'business_date = ?',
|
||||
whereArgs: [date],
|
||||
where: 'business_date = ? AND cashier_name = ?',
|
||||
whereArgs: [date, cashier],
|
||||
limit: 1,
|
||||
);
|
||||
final existing = prior.isEmpty ? null : prior.first;
|
||||
@@ -221,6 +295,7 @@ class OrderDao {
|
||||
Tables.dayArchive,
|
||||
{
|
||||
'business_date': date,
|
||||
'cashier_name': cashier,
|
||||
'bill_count':
|
||||
((existing?['bill_count'] as int?) ?? 0) + batchOrders.length,
|
||||
'item_count': ((existing?['item_count'] as num?)?.toDouble() ?? 0) +
|
||||
@@ -267,16 +342,24 @@ class OrderDao {
|
||||
});
|
||||
}
|
||||
|
||||
/// Archived figures for a business day, or null if nothing has synced yet.
|
||||
Future<Map<String, Object?>?> dayArchive(DateTime day) async {
|
||||
final rows = await _db.query(
|
||||
/// Archived figures for a business day, one row per cashier.
|
||||
///
|
||||
/// Empty when nothing has synced yet. Pass [cashierName] to scope it to a
|
||||
/// single operator; omit it for the whole terminal.
|
||||
Future<List<Map<String, Object?>>> dayArchive(
|
||||
DateTime day, {
|
||||
String? cashierName,
|
||||
}) =>
|
||||
_db.query(
|
||||
Tables.dayArchive,
|
||||
where: 'business_date = ?',
|
||||
whereArgs: [businessDateOf(day)],
|
||||
limit: 1,
|
||||
where: cashierName == null
|
||||
? 'business_date = ?'
|
||||
: 'business_date = ? AND cashier_name = ?',
|
||||
whereArgs: [
|
||||
businessDateOf(day),
|
||||
if (cashierName != null) cashierName,
|
||||
],
|
||||
);
|
||||
return rows.isEmpty ? null : rows.first;
|
||||
}
|
||||
|
||||
/// Records a failed attempt. The rows stay at `sync_status = 0`.
|
||||
Future<void> markFailed(List<String> orderIds, String error) async {
|
||||
@@ -316,7 +399,7 @@ class OrderDao {
|
||||
cart: _cartFromJson(
|
||||
jsonDecode(r['cart_json']! as String) as Map<String, Object?>,
|
||||
),
|
||||
))
|
||||
),)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -389,6 +472,9 @@ class OrderDao {
|
||||
}).toList();
|
||||
|
||||
final customerId = o['customer_id'] as String?;
|
||||
// Rebuilt with no lifetime spend on purpose: the tier discount this shopper
|
||||
// earned is already included in the recorded `bill_discount`, so giving the
|
||||
// reconstruction a tier would apply it a second time.
|
||||
final customer = customerId == null
|
||||
? null
|
||||
: Customer(
|
||||
@@ -404,18 +490,37 @@ class OrderDao {
|
||||
amount: (p['amount']! as num).toDouble(),
|
||||
tendered: (p['tendered'] as num?)?.toDouble(),
|
||||
reference: p['reference'] as String?,
|
||||
))
|
||||
),)
|
||||
.toList();
|
||||
|
||||
// Bill-level reductions live on the order row, not on the lines, so they
|
||||
// have to be put back explicitly. Without this the rebuilt cart bills at
|
||||
// the undiscounted subtotal and every downstream figure — the upload
|
||||
// payload, the day archive, the shift report — is overstated.
|
||||
final billDiscount = (o['bill_discount']! as num).toDouble();
|
||||
|
||||
return SaleTransaction(
|
||||
id: o['id']! as String,
|
||||
invoiceNumber: o['invoice_number']! as String,
|
||||
cart: Cart(lines: lines, customer: customer),
|
||||
cart: Cart(
|
||||
lines: lines,
|
||||
customer: customer,
|
||||
billDiscount: billDiscount > 0
|
||||
? Discount(
|
||||
type: DiscountType.flat,
|
||||
value: billDiscount,
|
||||
reason: 'Bill discount',
|
||||
)
|
||||
: Discount.none,
|
||||
pointsRedeemed: (o['points_redeemed'] as int?) ?? 0,
|
||||
),
|
||||
payments: payments,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(o['created_at']! as int),
|
||||
cashierName: o['cashier_name']! as String,
|
||||
terminalId: o['terminal_id']! as String,
|
||||
status: TransactionStatus.values.byName(o['status']! as String),
|
||||
storedTotal: (o['total']! as num).toDouble(),
|
||||
storedPointsEarned: (o['points_earned'] as int?) ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
75
lib/data/local/sync_log_dao.dart
Normal file
75
lib/data/local/sync_log_dao.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../../domain/entities/sync_event.dart';
|
||||
import 'app_database.dart';
|
||||
|
||||
/// Persists the history of this terminal's conversations with the server.
|
||||
///
|
||||
/// Held on disk rather than in memory because it is the only record of a sync
|
||||
/// that survives the bills themselves: once the server accepts an order the row
|
||||
/// is deleted from the terminal, so without this the evidence that it ever went
|
||||
/// up disappears at the next restart.
|
||||
class SyncLogDao {
|
||||
const SyncLogDao(this._db);
|
||||
|
||||
final Database _db;
|
||||
|
||||
Future<void> insert(SyncEvent e) async {
|
||||
await _db.insert(
|
||||
Tables.syncLog,
|
||||
{
|
||||
'id': e.id,
|
||||
'type': e.type.name,
|
||||
'status': e.status.name,
|
||||
'created_at': e.createdAt.millisecondsSinceEpoch,
|
||||
'synced_at': e.syncedAt?.millisecondsSinceEpoch,
|
||||
'summary': e.summary,
|
||||
'error': e.error,
|
||||
'attempts': e.attempts,
|
||||
'payload_json': e.payload.isEmpty ? null : jsonEncode(e.payload),
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
/// Newest first, which is the order the events log renders.
|
||||
Future<List<SyncEvent>> recent({int limit = 200}) async {
|
||||
final rows = await _db.query(
|
||||
Tables.syncLog,
|
||||
orderBy: 'created_at DESC',
|
||||
limit: limit,
|
||||
);
|
||||
return rows.map(_fromRow).toList();
|
||||
}
|
||||
|
||||
/// Keeps the log from growing without bound on a terminal that runs for
|
||||
/// months. Only ever trims the oldest entries.
|
||||
Future<void> trim({int keep = 500}) async {
|
||||
await _db.rawDelete(
|
||||
'DELETE FROM ${Tables.syncLog} WHERE id NOT IN ('
|
||||
'SELECT id FROM ${Tables.syncLog} ORDER BY created_at DESC LIMIT ?)',
|
||||
[keep],
|
||||
);
|
||||
}
|
||||
|
||||
SyncEvent _fromRow(Map<String, Object?> r) {
|
||||
final payload = r['payload_json'] as String?;
|
||||
return SyncEvent(
|
||||
id: r['id']! as String,
|
||||
type: SyncEventType.values.byName(r['type']! as String),
|
||||
status: SyncStatus.values.byName(r['status']! as String),
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(r['created_at']! as int),
|
||||
summary: r['summary']! as String,
|
||||
payload: payload == null
|
||||
? const {}
|
||||
: (jsonDecode(payload) as Map).cast<String, Object?>(),
|
||||
syncedAt: r['synced_at'] == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(r['synced_at']! as int),
|
||||
error: r['error'] as String?,
|
||||
attempts: (r['attempts'] as int?) ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -53,44 +53,26 @@ class CustomerRepositoryImpl implements CustomerRepository {
|
||||
return customer;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Customer> recordSale({
|
||||
required String customerId,
|
||||
required double amount,
|
||||
required int pointsEarned,
|
||||
required int pointsRedeemed,
|
||||
}) async {
|
||||
final current = _store.customerById(customerId);
|
||||
if (current == null) {
|
||||
throw StateError('Customer $customerId not found.');
|
||||
}
|
||||
final updated = current.copyWith(
|
||||
loyaltyPoints:
|
||||
(current.loyaltyPoints - pointsRedeemed + pointsEarned)
|
||||
.clamp(0, 1 << 31),
|
||||
lifetimeSpend: (current.lifetimeSpend + amount).asMoney,
|
||||
visitCount: current.visitCount + 1,
|
||||
lastVisitAt: DateTime.now(),
|
||||
);
|
||||
await _store.putCustomer(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Customer>> search(String query) async {
|
||||
final q = query.trim().toLowerCase();
|
||||
if (q.isEmpty) return recent();
|
||||
return _store.customers
|
||||
.where((c) =>
|
||||
c.name.toLowerCase().contains(q) || _digits(c.mobile).contains(q))
|
||||
.toList();
|
||||
|
||||
// Stored numbers are digits only, so the query has to be reduced the same
|
||||
// way — otherwise a cashier typing "98765 43210" or "98-76" matches nothing.
|
||||
final digits = _digits(q);
|
||||
|
||||
return _store.customers.where((c) {
|
||||
if (c.name.toLowerCase().contains(q)) return true;
|
||||
return digits.isNotEmpty && _digits(c.mobile).contains(digits);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Customer>> recent({int limit = 20}) async {
|
||||
final list = _store.customers.toList()
|
||||
..sort((a, b) => (b.lastVisitAt ?? DateTime(2000))
|
||||
.compareTo(a.lastVisitAt ?? DateTime(2000)));
|
||||
.compareTo(a.lastVisitAt ?? DateTime(2000)),);
|
||||
return list.take(limit).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,8 @@ class SyncRepositoryImpl implements SyncRepository {
|
||||
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// In-session event log. Order sync state itself lives on the order rows,
|
||||
/// so this is only a human-readable history of attempts.
|
||||
final List<SyncEvent> _events = [];
|
||||
/// Human-readable history of sync attempts, backed by the `sync_log` table.
|
||||
/// Order sync state itself lives on the order rows.
|
||||
|
||||
@override
|
||||
bool get hasCatalogue => _store.hasCatalogue;
|
||||
@@ -34,9 +33,9 @@ class SyncRepositoryImpl implements SyncRepository {
|
||||
String? get catalogueRevision => _store.catalogueRevision;
|
||||
|
||||
@override
|
||||
List<SyncEvent> get events => List.unmodifiable(_events.reversed);
|
||||
List<SyncEvent> get events => _store.syncEvents;
|
||||
|
||||
void _log(SyncEvent e) => _events.add(e);
|
||||
Future<void> _log(SyncEvent e) => _store.appendSyncEvent(e);
|
||||
|
||||
// ------------------------------------------------------- Morning: import
|
||||
@override
|
||||
@@ -71,7 +70,7 @@ class SyncRepositoryImpl implements SyncRepository {
|
||||
},
|
||||
attempts: 1,
|
||||
);
|
||||
_log(event);
|
||||
await _log(event);
|
||||
return event;
|
||||
} catch (e) {
|
||||
final event = SyncEvent(
|
||||
@@ -83,7 +82,7 @@ class SyncRepositoryImpl implements SyncRepository {
|
||||
error: e.toString(),
|
||||
attempts: 1,
|
||||
);
|
||||
_log(event);
|
||||
await _log(event);
|
||||
return event;
|
||||
}
|
||||
}
|
||||
@@ -99,21 +98,25 @@ class SyncRepositoryImpl implements SyncRepository {
|
||||
Future<ShiftReport> todayReport({
|
||||
required String terminalId,
|
||||
required String cashierName,
|
||||
bool scopeToCashier = false,
|
||||
}) async {
|
||||
final today = DateTime.now();
|
||||
final scope = scopeToCashier ? cashierName : null;
|
||||
|
||||
// Bills still held locally.
|
||||
final live = ShiftReport.fromTransactions(
|
||||
transactions: await _store.orders.forBusinessDate(today),
|
||||
var report = ShiftReport.fromTransactions(
|
||||
transactions:
|
||||
await _store.orders.forBusinessDate(today, cashierName: scope),
|
||||
businessDate: today,
|
||||
terminalId: terminalId,
|
||||
cashierName: cashierName,
|
||||
);
|
||||
|
||||
// Bills already uploaded and deleted survive only as archived totals.
|
||||
final row = await _store.orders.dayArchive(today);
|
||||
if (row == null) return live;
|
||||
// Bills already uploaded and deleted survive only as archived totals, one
|
||||
// row per cashier. Unscoped, every operator's row folds into the total.
|
||||
final rows = await _store.orders.dayArchive(today, cashierName: scope);
|
||||
|
||||
for (final row in rows) {
|
||||
final payments = (jsonDecode(row['payments_json']! as String)
|
||||
as Map<String, Object?>)
|
||||
.map(
|
||||
@@ -123,15 +126,17 @@ class SyncRepositoryImpl implements SyncRepository {
|
||||
),
|
||||
);
|
||||
|
||||
final archived = ShiftReport.fromArchive(
|
||||
report = ShiftReport.fromArchive(
|
||||
row: row,
|
||||
payments: payments,
|
||||
businessDate: today,
|
||||
terminalId: terminalId,
|
||||
cashierName: cashierName,
|
||||
);
|
||||
) +
|
||||
report;
|
||||
}
|
||||
|
||||
return archived + live;
|
||||
return report;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -150,7 +155,7 @@ class SyncRepositoryImpl implements SyncRepository {
|
||||
: DateTime.fromMillisecondsSinceEpoch(r['synced_at']! as int),
|
||||
attempts: (r['sync_attempts'] as int?) ?? 0,
|
||||
error: r['sync_error'] as String?,
|
||||
))
|
||||
),)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -161,73 +166,101 @@ class SyncRepositoryImpl implements SyncRepository {
|
||||
}) async {
|
||||
onProgress?.call(0.05, 'Collecting unsynced bills…');
|
||||
|
||||
final pending = await _store.orders.unsynced();
|
||||
if (pending.isEmpty) {
|
||||
final started = DateTime.now();
|
||||
final total = await _store.orders.unsyncedCount();
|
||||
|
||||
if (total == 0) {
|
||||
onProgress?.call(1, 'Nothing to upload');
|
||||
return const SyncOutcome(attempted: 0, uploaded: 0);
|
||||
}
|
||||
|
||||
final started = DateTime.now();
|
||||
final ids = pending.map((o) => o.id).toList();
|
||||
var attempted = 0;
|
||||
var uploaded = 0;
|
||||
final syncedInvoices = <String>[];
|
||||
|
||||
onProgress?.call(0.35, 'Uploading ${pending.length} bills…');
|
||||
// `unsynced()` returns a bounded page. Draining it in a loop means a day
|
||||
// with more bills than one page still uploads completely, instead of
|
||||
// reporting success with the remainder silently left behind.
|
||||
while (true) {
|
||||
final batch = await _store.orders.unsynced();
|
||||
if (batch.isEmpty) break;
|
||||
|
||||
final ids = batch.map((o) => o.id).toList();
|
||||
attempted += batch.length;
|
||||
|
||||
onProgress?.call(
|
||||
(attempted / total).clamp(0.05, 0.9),
|
||||
'Uploading $attempted of $total bills…',
|
||||
);
|
||||
|
||||
try {
|
||||
final accepted = await _orderSink.pushOrders(
|
||||
pending.map(_orderToPayload).toList(),
|
||||
batch.map(_orderToPayload).toList(),
|
||||
);
|
||||
|
||||
onProgress?.call(0.85, 'Clearing uploaded bills from this terminal…');
|
||||
|
||||
// Only what the server confirmed is archived and removed. Anything it
|
||||
// did not acknowledge stays on disk.
|
||||
final acceptedOrders =
|
||||
pending.where((o) => accepted.contains(o.id)).toList();
|
||||
batch.where((o) => accepted.contains(o.id)).toList();
|
||||
await _store.orders.archiveAndDelete(acceptedOrders);
|
||||
await _store.refreshUnsyncedCount();
|
||||
|
||||
uploaded += acceptedOrders.length;
|
||||
syncedInvoices.addAll(acceptedOrders.map((o) => o.invoiceNumber));
|
||||
|
||||
final rejected = ids.where((id) => !accepted.contains(id)).toList();
|
||||
if (rejected.isNotEmpty) {
|
||||
await _store.orders.markFailed(rejected, 'Rejected by server');
|
||||
// Rejected rows stay pending, so the next page would return the same
|
||||
// bills forever. Stop and let the cashier retry.
|
||||
break;
|
||||
}
|
||||
|
||||
onProgress?.call(1, 'Done');
|
||||
|
||||
_log(SyncEvent(
|
||||
id: _uuid.v4(),
|
||||
type: SyncEventType.shiftReport,
|
||||
status: SyncStatus.synced,
|
||||
createdAt: started,
|
||||
syncedAt: DateTime.now(),
|
||||
summary: '${accepted.length} of ${pending.length} bills uploaded',
|
||||
attempts: 1,
|
||||
));
|
||||
|
||||
return SyncOutcome(attempted: pending.length, uploaded: accepted.length);
|
||||
} catch (e) {
|
||||
// Transport failed: record the attempt but leave every row at 0.
|
||||
await _store.orders.markFailed(ids, e.toString());
|
||||
await _store.refreshUnsyncedCount();
|
||||
|
||||
_log(SyncEvent(
|
||||
final remaining = await _store.orders.unsyncedCount();
|
||||
final pendingValue =
|
||||
batch.fold<double>(0, (s, o) => s + o.total);
|
||||
|
||||
await _log(SyncEvent(
|
||||
id: _uuid.v4(),
|
||||
type: SyncEventType.shiftReport,
|
||||
status: SyncStatus.failed,
|
||||
createdAt: started,
|
||||
summary: '${pending.length} bills still pending '
|
||||
'(${Formatters.money(pending.fold<double>(0, (s, o) => s + o.total))})',
|
||||
summary: '$remaining bills still pending '
|
||||
'(${Formatters.money(pendingValue)})',
|
||||
error: e.toString(),
|
||||
attempts: 1,
|
||||
));
|
||||
),);
|
||||
|
||||
return SyncOutcome(
|
||||
attempted: pending.length,
|
||||
uploaded: 0,
|
||||
attempted: attempted,
|
||||
uploaded: uploaded,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.call(1, 'Done');
|
||||
|
||||
// Synced bills are deleted from the terminal, so this log line is the only
|
||||
// remaining record on the device that they went up.
|
||||
await _log(SyncEvent(
|
||||
id: _uuid.v4(),
|
||||
type: SyncEventType.shiftReport,
|
||||
status: SyncStatus.synced,
|
||||
createdAt: started,
|
||||
syncedAt: DateTime.now(),
|
||||
summary: '$uploaded of $attempted bills uploaded',
|
||||
payload: {'invoices': syncedInvoices},
|
||||
attempts: 1,
|
||||
),);
|
||||
|
||||
return SyncOutcome(attempted: attempted, uploaded: uploaded);
|
||||
}
|
||||
|
||||
/// The JSON body sent per order.
|
||||
Map<String, Object?> _orderToPayload(SaleTransaction t) => {
|
||||
'id': t.id,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import '../../domain/entities/customer.dart';
|
||||
import '../../domain/entities/transaction.dart';
|
||||
import '../../domain/repositories/transaction_repository.dart';
|
||||
import '../datasources/local_store.dart';
|
||||
import '../local/catalogue_dao.dart';
|
||||
|
||||
/// All bill persistence goes straight to SQLite.
|
||||
class TransactionRepositoryImpl implements TransactionRepository {
|
||||
@@ -9,11 +11,23 @@ class TransactionRepositoryImpl implements TransactionRepository {
|
||||
final LocalStore _store;
|
||||
|
||||
@override
|
||||
Future<SaleTransaction> save(SaleTransaction transaction) async {
|
||||
// Written with sync_status = 0; the end-of-day upload picks it up.
|
||||
await _store.orders.insertOrder(transaction);
|
||||
Future<void> commitSale({
|
||||
required SaleTransaction transaction,
|
||||
required Map<String, double> stockMovements,
|
||||
Customer? updatedCustomer,
|
||||
}) async {
|
||||
await _store.orders.commitSale(
|
||||
transaction: transaction,
|
||||
stockMovements: stockMovements,
|
||||
customerRow: updatedCustomer == null
|
||||
? null
|
||||
: CatalogueDao.customerToRow(updatedCustomer),
|
||||
);
|
||||
|
||||
// Disk is committed; bring the read caches in line with it.
|
||||
_store.cacheStockMovement(stockMovements);
|
||||
if (updatedCustomer != null) _store.cacheCustomer(updatedCustomer);
|
||||
await _store.refreshUnsyncedCount();
|
||||
return transaction;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -174,11 +174,29 @@ class Cart extends Equatable {
|
||||
double get taxableAmount => (netAmount - taxAmount).asMoney;
|
||||
|
||||
/// GST broken out per slab — required on a compliant tax invoice.
|
||||
///
|
||||
/// The slabs are reconciled against [taxAmount] before being returned.
|
||||
/// Rounding each slab on its own leaves the parts summing to a paisa either
|
||||
/// side of the total printed on the same bill, which a tax invoice cannot
|
||||
/// show; the residue is absorbed by the largest slab.
|
||||
Map<double, double> get taxBreakdown {
|
||||
final map = <double, double>{};
|
||||
final raw = <double, double>{};
|
||||
for (final line in lines) {
|
||||
final rate = line.product.gstRate;
|
||||
map[rate] = ((map[rate] ?? 0) + line.taxAmount * _billFactor).asMoney;
|
||||
raw[rate] = (raw[rate] ?? 0) + line.taxAmount * _billFactor;
|
||||
}
|
||||
if (raw.isEmpty) return const {};
|
||||
|
||||
final map = {
|
||||
for (final e in raw.entries) e.key: e.value.asMoney,
|
||||
};
|
||||
|
||||
final drift =
|
||||
(taxAmount - map.values.fold(0.0, (a, b) => a + b)).asMoney;
|
||||
if (drift != 0) {
|
||||
final largest =
|
||||
raw.entries.reduce((a, b) => a.value >= b.value ? a : b).key;
|
||||
map[largest] = (map[largest]! + drift).asMoney;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../core/utils/extensions.dart';
|
||||
|
||||
enum Gender {
|
||||
male('Male'),
|
||||
@@ -93,6 +94,26 @@ class Customer extends Equatable {
|
||||
return dob.month == now.month && dob.day == now.day;
|
||||
}
|
||||
|
||||
/// The shopper as they stand after a completed sale.
|
||||
///
|
||||
/// Pure, so the caller can compute the new row and persist it in the same
|
||||
/// transaction as the bill rather than as a separate write that might fail
|
||||
/// on its own.
|
||||
Customer applySale({
|
||||
required double amount,
|
||||
required int pointsEarned,
|
||||
required int pointsRedeemed,
|
||||
DateTime? at,
|
||||
}) {
|
||||
return copyWith(
|
||||
loyaltyPoints:
|
||||
(loyaltyPoints - pointsRedeemed + pointsEarned).clamp(0, 1 << 31),
|
||||
lifetimeSpend: (lifetimeSpend + amount).asMoney,
|
||||
visitCount: visitCount + 1,
|
||||
lastVisitAt: at ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
Customer copyWith({
|
||||
String? name,
|
||||
String? email,
|
||||
|
||||
@@ -88,7 +88,7 @@ class ShiftReport extends Equatable {
|
||||
t.status == TransactionStatus.completed &&
|
||||
t.createdAt.year == businessDate.year &&
|
||||
t.createdAt.month == businessDate.month &&
|
||||
t.createdAt.day == businessDate.day)
|
||||
t.createdAt.day == businessDate.day,)
|
||||
.toList()
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
|
||||
@@ -111,7 +111,7 @@ class ShiftReport extends Equatable {
|
||||
completed.fold(0.0, (s, t) => s + t.cart.taxAmount).asMoney,
|
||||
discountGiven: completed
|
||||
.fold(0.0,
|
||||
(s, t) => s + t.cart.billDiscountTotal + t.cart.lineDiscountTotal)
|
||||
(s, t) => s + t.cart.billDiscountTotal + t.cart.lineDiscountTotal,)
|
||||
.asMoney,
|
||||
roundOff: completed.fold(0.0, (s, t) => s + t.cart.roundOff).asMoney,
|
||||
paymentBreakdown: byMethod,
|
||||
|
||||
@@ -70,6 +70,8 @@ class SaleTransaction extends Equatable {
|
||||
required this.cashierName,
|
||||
this.status = TransactionStatus.completed,
|
||||
this.terminalId = 'TERM-01',
|
||||
this.storedTotal,
|
||||
this.storedPointsEarned,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -81,9 +83,20 @@ class SaleTransaction extends Equatable {
|
||||
final TransactionStatus status;
|
||||
final String terminalId;
|
||||
|
||||
/// The figure actually charged, as recorded at sale time.
|
||||
///
|
||||
/// Set only when a bill is read back from storage. Deriving the total from
|
||||
/// [cart] is correct for a live sale, but a rehydrated cart is a
|
||||
/// reconstruction — if it ever loses a component the money must not move with
|
||||
/// it. The recorded value wins whenever there is one.
|
||||
final double? storedTotal;
|
||||
|
||||
/// Points issued by this sale, as recorded at sale time. See [storedTotal].
|
||||
final int? storedPointsEarned;
|
||||
|
||||
Customer? get customer => cart.customer;
|
||||
|
||||
double get total => cart.grandTotal;
|
||||
double get total => storedTotal ?? cart.grandTotal;
|
||||
|
||||
double get amountPaid =>
|
||||
payments.fold(0.0, (sum, p) => sum + p.amount).asMoney;
|
||||
@@ -101,7 +114,7 @@ class SaleTransaction extends Equatable {
|
||||
|
||||
bool get isSplit => payments.length > 1;
|
||||
|
||||
int get pointsEarned => cart.pointsEarned;
|
||||
int get pointsEarned => storedPointsEarned ?? cart.pointsEarned;
|
||||
int get pointsRedeemed => cart.pointsRedeemed;
|
||||
|
||||
String get paymentSummary =>
|
||||
|
||||
@@ -11,12 +11,6 @@ abstract class CustomerRepository {
|
||||
Future<Customer> update(Customer customer);
|
||||
|
||||
/// Applies loyalty and lifetime-spend changes once a sale completes.
|
||||
Future<Customer> recordSale({
|
||||
required String customerId,
|
||||
required double amount,
|
||||
required int pointsEarned,
|
||||
required int pointsRedeemed,
|
||||
});
|
||||
|
||||
Future<List<Customer>> search(String query);
|
||||
|
||||
|
||||
@@ -62,9 +62,13 @@ abstract class SyncRepository {
|
||||
Future<List<SaleTransaction>> unsyncedOrders();
|
||||
|
||||
/// Today's trading totals, read back from SQLite.
|
||||
///
|
||||
/// Terminal-wide by default. Set [scopeToCashier] to cover only the bills
|
||||
/// [cashierName] rang — what a till is actually settled against.
|
||||
Future<ShiftReport> todayReport({
|
||||
required String terminalId,
|
||||
required String cashierName,
|
||||
bool scopeToCashier = false,
|
||||
});
|
||||
|
||||
/// End-of-day step — uploads pending orders and flips the accepted ones to
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import '../entities/customer.dart';
|
||||
import '../entities/transaction.dart';
|
||||
|
||||
abstract class TransactionRepository {
|
||||
Future<SaleTransaction> save(SaleTransaction transaction);
|
||||
/// Persists a completed sale as one atomic unit.
|
||||
///
|
||||
/// The bill, the stock it consumed and the shopper's loyalty movement either
|
||||
/// all land or none do, so a failure part-way through can never leave a
|
||||
/// persisted bill that the cashier believes failed.
|
||||
Future<void> commitSale({
|
||||
required SaleTransaction transaction,
|
||||
required Map<String, double> stockMovements,
|
||||
Customer? updatedCustomer,
|
||||
});
|
||||
|
||||
Future<List<SaleTransaction>> history({int limit = 50});
|
||||
|
||||
|
||||
@@ -51,10 +51,33 @@ class CheckoutSale {
|
||||
required Cart cart,
|
||||
required List<PaymentSplit> payments,
|
||||
required String cashierName,
|
||||
String terminalId = 'TERM-01',
|
||||
}) async {
|
||||
_validate(cart, payments);
|
||||
await _assertStockAvailable(cart);
|
||||
|
||||
final now = DateTime.now();
|
||||
|
||||
// The shopper's new balance is computed before anything is written, so it
|
||||
// can be persisted in the same transaction as the bill.
|
||||
Customer? updatedCustomer;
|
||||
final customer = cart.customer;
|
||||
if (customer != null) {
|
||||
final current = await _customers.findById(customer.id);
|
||||
if (current == null) {
|
||||
throw const CheckoutFailure(
|
||||
'This customer is no longer on file. Remove them from the bill to '
|
||||
'continue.',
|
||||
);
|
||||
}
|
||||
updatedCustomer = current.applySale(
|
||||
amount: cart.grandTotal,
|
||||
pointsEarned: cart.pointsEarned,
|
||||
pointsRedeemed: cart.pointsRedeemed,
|
||||
at: now,
|
||||
);
|
||||
}
|
||||
|
||||
final sequence = await _transactions.nextInvoiceSequence();
|
||||
|
||||
final transaction = SaleTransaction(
|
||||
@@ -64,24 +87,16 @@ class CheckoutSale {
|
||||
payments: payments,
|
||||
createdAt: now,
|
||||
cashierName: cashierName,
|
||||
terminalId: terminalId,
|
||||
);
|
||||
|
||||
await _transactions.save(transaction);
|
||||
|
||||
await _products.decrementStock({
|
||||
await _transactions.commitSale(
|
||||
transaction: transaction,
|
||||
stockMovements: {
|
||||
for (final line in cart.lines) line.product.id: line.quantity,
|
||||
});
|
||||
|
||||
Customer? updatedCustomer;
|
||||
final customer = cart.customer;
|
||||
if (customer != null) {
|
||||
updatedCustomer = await _customers.recordSale(
|
||||
customerId: customer.id,
|
||||
amount: cart.grandTotal,
|
||||
pointsEarned: cart.pointsEarned,
|
||||
pointsRedeemed: cart.pointsRedeemed,
|
||||
},
|
||||
updatedCustomer: updatedCustomer,
|
||||
);
|
||||
}
|
||||
|
||||
return CheckoutResult(
|
||||
transaction: transaction,
|
||||
@@ -89,6 +104,29 @@ class CheckoutSale {
|
||||
);
|
||||
}
|
||||
|
||||
/// Re-checks every line against live stock.
|
||||
///
|
||||
/// [CartLine.exceedsStock] reads the product snapshot taken when the item was
|
||||
/// added, which goes stale the moment anything else sells the same item — a
|
||||
/// parked bill resumed after its stock was sold would otherwise pass
|
||||
/// validation and oversell.
|
||||
Future<void> _assertStockAvailable(Cart cart) async {
|
||||
for (final line in cart.lines) {
|
||||
final live = await _products.findById(line.product.id);
|
||||
if (live == null) {
|
||||
throw CheckoutFailure(
|
||||
'${line.product.name} is no longer in the catalogue.',
|
||||
);
|
||||
}
|
||||
if (line.quantity > live.stock) {
|
||||
throw CheckoutFailure(
|
||||
'Only ${live.stock.toStringAsFixed(0)} ${live.unit.symbol} of '
|
||||
'${live.name} in stock.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _validate(Cart cart, List<PaymentSplit> payments) {
|
||||
if (cart.isEmpty) {
|
||||
throw const CheckoutFailure('Add at least one item before charging.');
|
||||
|
||||
@@ -76,12 +76,12 @@ class _StartupFailureApp extends StatelessWidget {
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.dangerSurface,
|
||||
borderRadius: AppRadius.brMd,
|
||||
),
|
||||
child: const Icon(Icons.error_outline_rounded,
|
||||
color: AppColors.danger, size: 28),
|
||||
color: AppColors.danger, size: 28,),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
const Text(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -408,14 +407,14 @@ class _FormPanel extends ConsumerWidget {
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.dangerSurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline_rounded,
|
||||
color: AppColors.danger, size: 18),
|
||||
color: AppColors.danger, size: 18,),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -509,13 +508,13 @@ class _DemoHint extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.info_outline_rounded,
|
||||
size: 17, color: AppColors.primary),
|
||||
size: 17, color: AppColors.primary,),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
Text(
|
||||
'Demo account',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
@@ -523,10 +522,10 @@ class _DemoHint extends StatelessWidget {
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
SizedBox(height: 2),
|
||||
SelectableText(
|
||||
'${DemoCredentials.email} · ${DemoCredentials.password}',
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
|
||||
@@ -174,7 +174,7 @@ class _CustomerCaptureSheetState
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.border,
|
||||
borderRadius: AppRadius.brPill,
|
||||
),
|
||||
@@ -378,7 +378,7 @@ class _CustomerCaptureSheetState
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.successSurface,
|
||||
borderRadius: AppRadius.brLg,
|
||||
),
|
||||
@@ -433,7 +433,7 @@ class _CustomerCaptureSheetState
|
||||
children: [
|
||||
Expanded(
|
||||
child: _miniStat(
|
||||
'${c.loyaltyPoints}', 'points held'),
|
||||
'${c.loyaltyPoints}', 'points held',),
|
||||
),
|
||||
Expanded(
|
||||
child: _miniStat(
|
||||
|
||||
@@ -195,9 +195,9 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
|
||||
StatusPill.tier(c.tier, dense: true),
|
||||
Cell('${c.loyaltyPoints}', mono: true),
|
||||
Cell(Formatters.moneyCompact(c.lifetimeSpend),
|
||||
mono: true, bold: true),
|
||||
mono: true, bold: true,),
|
||||
Cell('${c.visitCount}', mono: true),
|
||||
])
|
||||
],)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -146,7 +146,7 @@ class EventsView extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.shield_outlined,
|
||||
size: 15, color: AppColors.textTertiary),
|
||||
size: 15, color: AppColors.textTertiary,),
|
||||
SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -181,14 +181,14 @@ class EventsView extends ConsumerWidget {
|
||||
.map((o) => [
|
||||
Cell(o.invoiceNumber, bold: true, mono: true),
|
||||
Cell(Formatters.time(o.createdAt),
|
||||
color: AppColors.textTertiary),
|
||||
color: AppColors.textTertiary,),
|
||||
Cell(Formatters.money(o.total), mono: true, bold: true),
|
||||
TagChip(
|
||||
o.isSynced ? 'Synced' : 'Pending',
|
||||
color:
|
||||
o.isSynced ? AppColors.success : AppColors.warning,
|
||||
),
|
||||
])
|
||||
],)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
@@ -227,8 +227,8 @@ class EventsView extends ConsumerWidget {
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
Cell(Formatters.time(e.createdAt),
|
||||
color: AppColors.textTertiary),
|
||||
])
|
||||
color: AppColors.textTertiary,),
|
||||
],)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -102,14 +102,14 @@ class ProductImportView extends ConsumerWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(p.emoji,
|
||||
style: const TextStyle(fontSize: 17)),
|
||||
style: const TextStyle(fontSize: 17),),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Flexible(child: Cell(p.name, bold: true)),
|
||||
],
|
||||
),
|
||||
Cell(p.sku, color: AppColors.textTertiary),
|
||||
TagChip(p.category.label,
|
||||
color: AppColors.textSecondary),
|
||||
color: AppColors.textSecondary,),
|
||||
Cell(Formatters.money(p.price), mono: true, bold: true),
|
||||
TagChip(
|
||||
p.isOutOfStock
|
||||
@@ -121,7 +121,7 @@ class ProductImportView extends ConsumerWidget {
|
||||
? AppColors.warning
|
||||
: AppColors.success),
|
||||
),
|
||||
])
|
||||
],)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
@@ -146,13 +146,13 @@ class _NotImportedBanner extends StatelessWidget {
|
||||
borderRadius: AppRadius.brLg,
|
||||
border: Border.all(color: AppColors.warning.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Row(
|
||||
child: const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.cloud_download_outlined,
|
||||
color: AppColors.warning, size: 22),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
const Expanded(
|
||||
Icon(Icons.cloud_download_outlined,
|
||||
color: AppColors.warning, size: 22,),
|
||||
SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -225,7 +225,7 @@ class _ImportPanel extends ConsumerWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.dangerSurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
@@ -233,7 +233,7 @@ class _ImportPanel extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.wifi_off_rounded,
|
||||
color: AppColors.danger, size: 18),
|
||||
color: AppColors.danger, size: 18,),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -254,14 +254,14 @@ class _ImportPanel extends ConsumerWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.successSurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline_rounded,
|
||||
color: AppColors.success, size: 18),
|
||||
color: AppColors.success, size: 18,),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -312,13 +312,13 @@ class _ImportPanel extends ConsumerWidget {
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Row(
|
||||
const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.info_outline_rounded,
|
||||
size: 15, color: AppColors.textTertiary),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
const Expanded(
|
||||
Icon(Icons.info_outline_rounded,
|
||||
size: 15, color: AppColors.textTertiary,),
|
||||
SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'After this import the terminal works entirely offline. '
|
||||
'Sales, customers and parked bills are held locally and are '
|
||||
|
||||
@@ -64,21 +64,21 @@ class _PromosViewState extends State<PromosView> {
|
||||
icon: Icons.campaign_rounded,
|
||||
caption: 'of ${_campaigns.length} configured',
|
||||
),
|
||||
StatTile(
|
||||
const StatTile(
|
||||
label: 'Redemptions',
|
||||
value: '970',
|
||||
icon: Icons.confirmation_number_rounded,
|
||||
color: AppColors.info,
|
||||
caption: 'this month',
|
||||
),
|
||||
StatTile(
|
||||
const StatTile(
|
||||
label: 'Discount Given',
|
||||
value: '₹48,240',
|
||||
icon: Icons.local_offer_rounded,
|
||||
color: AppColors.warning,
|
||||
caption: '2.6% of sales',
|
||||
),
|
||||
StatTile(
|
||||
const StatTile(
|
||||
label: 'Incremental Sales',
|
||||
value: '₹2.14L',
|
||||
icon: Icons.trending_up_rounded,
|
||||
@@ -133,7 +133,7 @@ class _PromosViewState extends State<PromosView> {
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: Icon(Icons.sell_rounded,
|
||||
size: 18, color: c.$6),
|
||||
size: 18, color: c.$6,),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
|
||||
@@ -167,7 +167,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
if (list.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.warningSurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
@@ -175,7 +175,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.print_disabled_rounded,
|
||||
size: 18, color: AppColors.warning),
|
||||
size: 18, color: AppColors.warning,),
|
||||
SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -235,7 +235,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
final ok = await ref
|
||||
.read(receiptServiceProvider)
|
||||
.printTestPage(
|
||||
printerUrl: settings.printerUrl);
|
||||
printerUrl: settings.printerUrl,);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
@@ -245,8 +245,8 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
: AppColors.danger,
|
||||
content: Text(ok
|
||||
? 'Test slip sent to the printer.'
|
||||
: 'Could not reach the printer.'),
|
||||
));
|
||||
: 'Could not reach the printer.',),
|
||||
),);
|
||||
},
|
||||
icon: const Icon(Icons.receipt_long_rounded, size: 17),
|
||||
label: const Text('Print test slip'),
|
||||
@@ -307,9 +307,9 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
Cell(s.role.label, color: AppColors.textSecondary),
|
||||
s.id == current?.id
|
||||
? const TagChip('Signed in',
|
||||
color: AppColors.success)
|
||||
color: AppColors.success,)
|
||||
: const SizedBox.shrink(),
|
||||
])
|
||||
],)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -407,7 +407,7 @@ class Cell extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: mono
|
||||
? AppTypography.money(13.5,
|
||||
weight: bold ? FontWeight.w700 : FontWeight.w500, color: color)
|
||||
weight: bold ? FontWeight.w700 : FontWeight.w500, color: color,)
|
||||
: TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: bold ? FontWeight.w600 : FontWeight.w400,
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../../app/providers.dart';
|
||||
import '../../../core/utils/extensions.dart';
|
||||
import '../../../domain/entities/transaction.dart';
|
||||
import '../../../domain/usecases/checkout_sale.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
import '../../pos/providers/cart_controller.dart';
|
||||
import '../../pos/providers/catalog_providers.dart';
|
||||
import '../../sync/providers/sync_controller.dart';
|
||||
@@ -44,6 +45,7 @@ class PaymentState {
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
CheckoutResult? result,
|
||||
bool clearResult = false,
|
||||
}) {
|
||||
return PaymentState(
|
||||
splits: splits ?? this.splits,
|
||||
@@ -52,7 +54,7 @@ class PaymentState {
|
||||
reference: reference ?? this.reference,
|
||||
isProcessing: isProcessing ?? this.isProcessing,
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
result: result ?? this.result,
|
||||
result: clearResult ? null : (result ?? this.result),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -156,7 +158,10 @@ class PaymentController extends StateNotifier<PaymentState> {
|
||||
if (state.isProcessing) return null;
|
||||
|
||||
// A single-tender sale needn't be staged first — fold it in automatically.
|
||||
var splits = state.splits;
|
||||
// Remembered so it can be rolled back if the sale is rejected, otherwise a
|
||||
// retry starts with a phantom tender already staged.
|
||||
final stagedSplits = state.splits;
|
||||
var splits = stagedSplits;
|
||||
if (splits.isEmpty || balanceDue > 0.01) {
|
||||
addSplit();
|
||||
splits = state.splits;
|
||||
@@ -168,10 +173,16 @@ class PaymentController extends StateNotifier<PaymentState> {
|
||||
final cart = _ref.read(cartControllerProvider);
|
||||
final session = _ref.read(cashierSessionProvider);
|
||||
|
||||
// The signed-in operator owns the bill. Falling back to the seed session
|
||||
// stamped every sale with the same name, so a bill could not be traced to
|
||||
// whoever actually rang it.
|
||||
final user = _ref.read(currentUserProvider);
|
||||
|
||||
final result = await _ref.read(checkoutSaleProvider)(
|
||||
cart: cart,
|
||||
payments: splits,
|
||||
cashierName: session.name,
|
||||
cashierName: user?.name ?? session.name,
|
||||
terminalId: session.terminalId,
|
||||
);
|
||||
|
||||
state = state.copyWith(isProcessing: false, result: result);
|
||||
@@ -190,12 +201,17 @@ class PaymentController extends StateNotifier<PaymentState> {
|
||||
|
||||
return result;
|
||||
} on CheckoutFailure catch (e) {
|
||||
state = state.copyWith(isProcessing: false, error: e.message);
|
||||
state = state.copyWith(
|
||||
isProcessing: false,
|
||||
error: e.message,
|
||||
splits: stagedSplits,
|
||||
);
|
||||
return null;
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isProcessing: false,
|
||||
error: 'Could not complete the sale. $e',
|
||||
splits: stagedSplits,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
void _backspaceCash() {
|
||||
if (_cashBuffer.isEmpty) return;
|
||||
setState(
|
||||
() => _cashBuffer = _cashBuffer.substring(0, _cashBuffer.length - 1));
|
||||
() => _cashBuffer = _cashBuffer.substring(0, _cashBuffer.length - 1),);
|
||||
_syncCash();
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
: AppColors.primarySurface,
|
||||
child: customer == null
|
||||
? const Icon(Icons.person_add_alt_1_outlined,
|
||||
size: 19, color: AppColors.textSecondary)
|
||||
size: 19, color: AppColors.textSecondary,)
|
||||
: Text(
|
||||
Formatters.initials(customer.name),
|
||||
style: const TextStyle(
|
||||
@@ -316,7 +316,7 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
child: Row(
|
||||
children: [
|
||||
Text(e.value.method.emoji,
|
||||
style: const TextStyle(fontSize: 15)),
|
||||
style: const TextStyle(fontSize: 15),),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -326,7 +326,7 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
),
|
||||
),
|
||||
Text(Formatters.money(e.value.amount),
|
||||
style: AppTypography.money(13.5)),
|
||||
style: AppTypography.money(13.5),),
|
||||
IconButton(
|
||||
onPressed: () => controller.removeSplit(e.key),
|
||||
icon: const Icon(Icons.close_rounded, size: 16),
|
||||
@@ -380,7 +380,7 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
children: [
|
||||
const Text('₹',
|
||||
style:
|
||||
TextStyle(fontSize: 22, color: AppColors.textTertiary)),
|
||||
TextStyle(fontSize: 22, color: AppColors.textTertiary),),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
@@ -481,7 +481,7 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
Row(
|
||||
children: [
|
||||
Text(state.activeMethod.emoji,
|
||||
style: const TextStyle(fontSize: 20)),
|
||||
style: const TextStyle(fontSize: 20),),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -498,13 +498,13 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
child: Container(
|
||||
width: 104,
|
||||
height: 104,
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primarySurface,
|
||||
borderRadius: AppRadius.brXl,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(state.activeMethod.emoji,
|
||||
style: const TextStyle(fontSize: 46)),
|
||||
style: const TextStyle(fontSize: 46),),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
@@ -543,9 +543,7 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
onPressed: controller.balanceDue > 0
|
||||
? () {
|
||||
controller.addSplit(
|
||||
amount: amount == null
|
||||
? null
|
||||
: amount.clamp(0, controller.balanceDue).toDouble(),
|
||||
amount: amount?.clamp(0, controller.balanceDue).toDouble(),
|
||||
);
|
||||
setState(() => _cashBuffer = '');
|
||||
}
|
||||
@@ -586,14 +584,14 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.dangerSurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline_rounded,
|
||||
color: AppColors.danger, size: 18),
|
||||
color: AppColors.danger, size: 18,),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -613,7 +611,7 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline_rounded,
|
||||
size: 15, color: AppColors.textTertiary),
|
||||
size: 15, color: AppColors.textTertiary,),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -634,7 +632,7 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
),
|
||||
),
|
||||
child: const Text('Exact',
|
||||
style: TextStyle(fontSize: 12.5)),
|
||||
style: TextStyle(fontSize: 12.5),),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
@@ -80,7 +82,7 @@ class CartController extends StateNotifier<Cart> {
|
||||
stamp: DateTime.now(),
|
||||
product: product,
|
||||
message: '${product.name} is out of stock',
|
||||
));
|
||||
),);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -98,7 +100,7 @@ class CartController extends StateNotifier<Cart> {
|
||||
product: product,
|
||||
message: 'Only ${product.stock.toStringAsFixed(0)} '
|
||||
'${product.unit.symbol} left',
|
||||
));
|
||||
),);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,7 +112,7 @@ class CartController extends StateNotifier<Cart> {
|
||||
quantity: quantity,
|
||||
addedAt: DateTime.now(),
|
||||
),
|
||||
]);
|
||||
],);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
lines: _replace(existing.copyWith(quantity: requested)),
|
||||
@@ -123,7 +125,7 @@ class CartController extends StateNotifier<Cart> {
|
||||
outcome: existing == null ? ScanOutcome.added : ScanOutcome.incremented,
|
||||
stamp: DateTime.now(),
|
||||
product: product,
|
||||
));
|
||||
),);
|
||||
}
|
||||
|
||||
/// Scanner entry point. Resolves the barcode and adds it with no dialogs.
|
||||
@@ -131,12 +133,13 @@ class CartController extends StateNotifier<Cart> {
|
||||
final product = await _products.findByBarcode(code);
|
||||
|
||||
if (product == null) {
|
||||
_sound.scanError();
|
||||
// Deliberately not awaited — the beep must never delay the next scan.
|
||||
unawaited(_sound.scanError());
|
||||
onFeedback(ScanFeedback(
|
||||
outcome: ScanOutcome.notFound,
|
||||
stamp: DateTime.now(),
|
||||
message: 'No product for barcode $code',
|
||||
));
|
||||
),);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -163,7 +166,7 @@ class CartController extends StateNotifier<Cart> {
|
||||
stamp: DateTime.now(),
|
||||
product: line.product,
|
||||
message: 'Only ${line.product.stock.toStringAsFixed(0)} in stock',
|
||||
));
|
||||
),);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -265,7 +268,7 @@ class CartController extends StateNotifier<Cart> {
|
||||
cart: state,
|
||||
parkedAt: DateTime.now(),
|
||||
label: label,
|
||||
));
|
||||
),);
|
||||
reset();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
enum PosModule {
|
||||
pos('Point of Sale', 'POS', Icons.point_of_sale_rounded, NavSection.billing),
|
||||
customers('Customers', 'Customers', Icons.people_alt_rounded,
|
||||
NavSection.billing),
|
||||
NavSection.billing,),
|
||||
|
||||
productImport('Product Import', 'Product Import',
|
||||
Icons.cloud_download_rounded, NavSection.catalogue),
|
||||
Icons.cloud_download_rounded, NavSection.catalogue,),
|
||||
promos('Promotions', 'Promo', Icons.sell_rounded, NavSection.catalogue),
|
||||
|
||||
events('Events', 'Events', Icons.sync_rounded, NavSection.session),
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../../modules/screens/promos_view.dart';
|
||||
import '../../modules/screens/settings_view.dart';
|
||||
import '../../sync/providers/sync_controller.dart';
|
||||
import '../providers/cart_controller.dart';
|
||||
import '../providers/catalog_providers.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../widgets/app_sidebar.dart';
|
||||
import '../widgets/billing_panel.dart';
|
||||
@@ -56,6 +57,9 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
|
||||
// Without an imported catalogue there is nothing to resolve against.
|
||||
if (!ref.read(catalogueReadyProvider)) return;
|
||||
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
|
||||
// Drop any character the burst leaked into the search box before the
|
||||
// scan was recognised, so the grid is not left filtered to nothing.
|
||||
ref.read(searchQueryProvider.notifier).state = '';
|
||||
ref.read(cartControllerProvider.notifier).scanBarcode(code);
|
||||
},
|
||||
)..attach();
|
||||
@@ -73,13 +77,13 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => FractionallySizedBox(
|
||||
builder: (_) => const FractionallySizedBox(
|
||||
heightFactor: 0.92,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(AppRadius.xxl),
|
||||
),
|
||||
child: const BillingPanel(inSheet: true),
|
||||
child: BillingPanel(inSheet: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -98,7 +98,7 @@ class _CatalogueRequired extends ConsumerWidget {
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.cloud_download_outlined,
|
||||
size: 42, color: AppColors.primary),
|
||||
size: 42, color: AppColors.primary,),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
Text(
|
||||
@@ -136,7 +136,7 @@ class _CatalogueRequired extends ConsumerWidget {
|
||||
minHeight: 8,
|
||||
backgroundColor: AppColors.divider,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
AppColors.primary),
|
||||
AppColors.primary,),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
@@ -146,7 +146,7 @@ class _CatalogueRequired extends ConsumerWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.dangerSurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
@@ -154,7 +154,7 @@ class _CatalogueRequired extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.wifi_off_rounded,
|
||||
color: AppColors.danger, size: 18),
|
||||
color: AppColors.danger, size: 18,),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
|
||||
@@ -245,7 +245,7 @@ class _Section extends ConsumerWidget {
|
||||
AppSpacing.sm,
|
||||
),
|
||||
child: Text(section.label.toUpperCase(),
|
||||
style: AppTypography.sectionLabel()),
|
||||
style: AppTypography.sectionLabel(),),
|
||||
)
|
||||
else
|
||||
const Padding(
|
||||
@@ -459,7 +459,7 @@ class _LogoutTile extends ConsumerWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.logout_rounded,
|
||||
size: 19, color: AppColors.danger),
|
||||
size: 19, color: AppColors.danger,),
|
||||
if (expanded) ...[
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
const Text(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.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';
|
||||
@@ -66,7 +67,7 @@ class BillingPanel extends ConsumerWidget {
|
||||
),
|
||||
if (cart.isNotEmpty) _Summary(cart: cart),
|
||||
_Actions(cart: cart),
|
||||
]),
|
||||
],),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -101,7 +102,7 @@ class _Header extends ConsumerWidget {
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primarySurface,
|
||||
borderRadius: AppRadius.brPill,
|
||||
),
|
||||
@@ -213,13 +214,13 @@ class _Summary extends ConsumerWidget {
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm + 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.successSurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: Row(children: [
|
||||
const Icon(Icons.stars_rounded,
|
||||
size: 16, color: AppColors.success),
|
||||
size: 16, color: AppColors.success,),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(
|
||||
'This sale earns +${cart.pointsEarned} pts',
|
||||
@@ -229,7 +230,7 @@ class _Summary extends ConsumerWidget {
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
]),
|
||||
],),
|
||||
),
|
||||
|
||||
_Row(label: 'Subtotal', value: Formatters.money(cart.subtotal)),
|
||||
@@ -324,7 +325,7 @@ class _Summary extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -365,7 +366,7 @@ class _Row extends StatelessWidget {
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: AppColors.textTertiary,
|
||||
)),
|
||||
),),
|
||||
],
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
const Spacer(),
|
||||
@@ -381,7 +382,7 @@ class _Row extends StatelessWidget {
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Icon(trailingIcon, size: 14, color: AppColors.textTertiary),
|
||||
],
|
||||
]),
|
||||
],),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -412,7 +413,8 @@ class _Actions extends ConsumerWidget {
|
||||
if (ref.read(cartControllerProvider).customer == null) {
|
||||
await showCustomerCaptureSheet(context);
|
||||
}
|
||||
if (context.mounted) context.push(AppRoutes.payment);
|
||||
// Navigation result is not needed here.
|
||||
if (context.mounted) unawaited(context.push(AppRoutes.payment));
|
||||
}
|
||||
: null,
|
||||
trailing: enabled
|
||||
|
||||
@@ -36,7 +36,7 @@ class CartFab extends ConsumerWidget {
|
||||
child: Container(
|
||||
height: AppSizes.buttonHeightLarge,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: AppRadius.brLg,
|
||||
boxShadow: AppColors.shadowLg,
|
||||
),
|
||||
@@ -79,7 +79,7 @@ class CartFab extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
const Icon(Icons.keyboard_arrow_up_rounded,
|
||||
color: Colors.white, size: 20),
|
||||
color: Colors.white, size: 20,),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -34,12 +34,12 @@ class CartLineTile extends StatelessWidget {
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: AppSpacing.xl),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.dangerSurface,
|
||||
borderRadius: AppRadius.brMd,
|
||||
),
|
||||
child: const Icon(Icons.delete_outline_rounded,
|
||||
color: AppColors.danger),
|
||||
color: AppColors.danger,),
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
@@ -83,18 +83,18 @@ class CartLineTile extends StatelessWidget {
|
||||
Formatters.money(p.price),
|
||||
style: AppTypography.money(13.5,
|
||||
weight: FontWeight.w600,
|
||||
color: AppColors.textSecondary),
|
||||
color: AppColors.textSecondary,),
|
||||
),
|
||||
Text(' / ${p.unit.symbol}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: AppColors.textTertiary,
|
||||
)),
|
||||
),),
|
||||
if (line.discount.isActive) ...[
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 5, vertical: 1),
|
||||
horizontal: 5, vertical: 1,),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.successSurface,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -109,7 +109,7 @@ class CartLineTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
],),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -121,7 +121,7 @@ class CartLineTile extends StatelessWidget {
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: 'Remove',
|
||||
),
|
||||
]),
|
||||
],),
|
||||
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
|
||||
@@ -162,14 +162,14 @@ class CartLineTile extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
],),
|
||||
|
||||
if (line.exceedsStock)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.sm),
|
||||
child: Row(children: [
|
||||
const Icon(Icons.error_outline_rounded,
|
||||
size: 14, color: AppColors.danger),
|
||||
size: 14, color: AppColors.danger,),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Text(
|
||||
'Only ${p.stock.toStringAsFixed(0)} ${p.unit.symbol} '
|
||||
@@ -180,9 +180,9 @@ class CartLineTile extends StatelessWidget {
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
]),
|
||||
],),
|
||||
),
|
||||
]),
|
||||
],),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -222,7 +222,7 @@ class _Stepper extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
_btn(Icons.add_rounded, onIncrement),
|
||||
]),
|
||||
],),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ class _Chip extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
],),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -37,7 +37,7 @@ class CustomerBar extends ConsumerWidget {
|
||||
: AppColors.primarySurface,
|
||||
child: customer == null
|
||||
? const Icon(Icons.directions_walk_rounded,
|
||||
size: 20, color: AppColors.textSecondary)
|
||||
size: 20, color: AppColors.textSecondary,)
|
||||
: Text(
|
||||
Formatters.initials(customer.name),
|
||||
style: const TextStyle(
|
||||
@@ -65,7 +65,7 @@ class CustomerBar extends ConsumerWidget {
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
StatusPill.tier(customer.tier, dense: true),
|
||||
],
|
||||
]),
|
||||
],),
|
||||
if (customer != null)
|
||||
Text(
|
||||
'${Formatters.mobile(customer.mobile)} · '
|
||||
@@ -92,7 +92,7 @@ class CustomerBar extends ConsumerWidget {
|
||||
icon: const Icon(Icons.person_off_outlined, size: 17),
|
||||
label: const Text('Detach'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.textSecondary),
|
||||
foregroundColor: AppColors.textSecondary,),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
OutlinedButton.icon(
|
||||
@@ -104,7 +104,7 @@ class CustomerBar extends ConsumerWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ class _DiscountSheetState extends State<_DiscountSheet> {
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.border,
|
||||
borderRadius: AppRadius.brPill,
|
||||
),
|
||||
@@ -124,13 +124,13 @@ class _DiscountSheetState extends State<_DiscountSheet> {
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
|
||||
Text(widget.title,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),),
|
||||
const SizedBox(height: 2),
|
||||
Text(widget.subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
)),
|
||||
),),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
|
||||
SegmentedButton<DiscountType>(
|
||||
@@ -177,10 +177,10 @@ class _DiscountSheetState extends State<_DiscountSheet> {
|
||||
.map((v) => ActionChip(
|
||||
label: Text(_type == DiscountType.percentage
|
||||
? '$v%'
|
||||
: '₹$v'),
|
||||
: '₹$v',),
|
||||
onPressed: () =>
|
||||
setState(() => _value.text = v.toString()),
|
||||
))
|
||||
),)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
@@ -205,8 +205,8 @@ class _DiscountSheetState extends State<_DiscountSheet> {
|
||||
onPressed: _apply,
|
||||
),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
],),
|
||||
],),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,13 +133,13 @@ class _Breadcrumb extends StatelessWidget {
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs + 2),
|
||||
child: Icon(Icons.chevron_right_rounded,
|
||||
size: 13, color: AppColors.textTertiary),
|
||||
size: 13, color: AppColors.textTertiary,),
|
||||
),
|
||||
Text(module.section.label, style: style),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs + 2),
|
||||
child: Icon(Icons.chevron_right_rounded,
|
||||
size: 13, color: AppColors.textTertiary),
|
||||
size: 13, color: AppColors.textTertiary,),
|
||||
),
|
||||
Text(
|
||||
module.label,
|
||||
@@ -285,7 +285,7 @@ class _ParkedBillsButton extends ConsumerWidget {
|
||||
final bill = parked[i];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.receipt_long_rounded,
|
||||
color: AppColors.primary),
|
||||
color: AppColors.primary,),
|
||||
title: Text(bill.displayLabel),
|
||||
subtitle: Text(
|
||||
'${bill.cart.lineCount} items · '
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
@@ -178,7 +177,7 @@ class _ProductCardState extends State<ProductCard> {
|
||||
left: AppSpacing.sm,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
horizontal: 6, vertical: 2,),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
@@ -199,7 +198,7 @@ class _ProductCardState extends State<ProductCard> {
|
||||
top: AppSpacing.sm,
|
||||
right: AppSpacing.sm,
|
||||
child: Icon(Icons.warning_amber_rounded,
|
||||
size: 15, color: AppColors.warning),
|
||||
size: 15, color: AppColors.warning,),
|
||||
),
|
||||
|
||||
// Quantity badge once the item is on the bill.
|
||||
@@ -243,10 +242,10 @@ class _ProductCardState extends State<ProductCard> {
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.add_rounded,
|
||||
size: 18, color: Colors.white),
|
||||
size: 18, color: Colors.white,),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -67,7 +67,7 @@ class _ScanToastState extends ConsumerState<ScanToast> {
|
||||
horizontal: AppSpacing.xl,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.textPrimary,
|
||||
borderRadius: AppRadius.brPill,
|
||||
boxShadow: AppColors.shadowLg,
|
||||
@@ -110,7 +110,7 @@ class _ScanToastState extends ConsumerState<ScanToast> {
|
||||
fontSize: 13.5,
|
||||
),
|
||||
),
|
||||
]),
|
||||
],),
|
||||
)
|
||||
.animate(key: ValueKey(feedback.stamp))
|
||||
.fadeIn(duration: 140.ms)
|
||||
|
||||
@@ -44,6 +44,13 @@ class _PosSearchFieldState extends ConsumerState<PosSearchField> {
|
||||
Widget build(BuildContext context) {
|
||||
final query = ref.watch(searchQueryProvider);
|
||||
|
||||
// A scan clears the query from outside this widget. Mirror that into the
|
||||
// controller so any stray character the scanner leaked into the field goes
|
||||
// with it.
|
||||
ref.listen<String>(searchQueryProvider, (_, next) {
|
||||
if (next.isEmpty && _controller.text.isNotEmpty) _controller.clear();
|
||||
});
|
||||
|
||||
return TextField(
|
||||
controller: _controller,
|
||||
focusNode: widget.focusNode,
|
||||
@@ -80,23 +87,23 @@ class _PosSearchFieldState extends ConsumerState<PosSearchField> {
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primarySurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: const Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.qr_code_scanner_rounded,
|
||||
size: 18, color: AppColors.primary),
|
||||
size: 18, color: AppColors.primary,),
|
||||
SizedBox(width: AppSpacing.xs + 2),
|
||||
Text('Scanner ready',
|
||||
style: TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
)),
|
||||
]),
|
||||
),),
|
||||
],),
|
||||
),
|
||||
]),
|
||||
],),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
||||
content: Text(
|
||||
'Printer did not respond — use Print to send it again.',
|
||||
),
|
||||
));
|
||||
),);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -112,7 +112,7 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
||||
height: 480,
|
||||
child: ReceiptPreview(transaction: txn),
|
||||
),
|
||||
]),
|
||||
],),
|
||||
)
|
||||
: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -148,7 +148,7 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.check_rounded,
|
||||
size: 44, color: AppColors.success),
|
||||
size: 44, color: AppColors.success,),
|
||||
)
|
||||
.animate()
|
||||
.scale(
|
||||
@@ -161,7 +161,7 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
Text('Sale complete',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.text.headlineMedium),
|
||||
style: context.text.headlineMedium,),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
'${txn.invoiceNumber} · ${Formatters.dateTime(txn.createdAt)}',
|
||||
@@ -172,7 +172,7 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primarySurface,
|
||||
borderRadius: AppRadius.brLg,
|
||||
),
|
||||
@@ -188,12 +188,12 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
||||
_row('Paid via', txn.paymentSummary),
|
||||
if (txn.changeDue > 0)
|
||||
_row('Change returned', Formatters.money(txn.changeDue),
|
||||
highlight: AppColors.success),
|
||||
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),
|
||||
highlight: AppColors.success,),
|
||||
if (txn.pointsRedeemed > 0)
|
||||
_row('Points redeemed', '-${txn.pointsRedeemed}'),
|
||||
],
|
||||
@@ -203,7 +203,7 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
||||
Formatters.money(txn.cart.totalSavings),
|
||||
highlight: AppColors.success,
|
||||
),
|
||||
]),
|
||||
],),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
@@ -256,7 +256,7 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
||||
content: Text(
|
||||
'Could not open WhatsApp on this device.',
|
||||
),
|
||||
));
|
||||
),);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -314,7 +314,7 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
color: AppColors.textSecondary,
|
||||
)),
|
||||
),),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
|
||||
@@ -43,7 +43,7 @@ class ReceiptPreview extends StatelessWidget {
|
||||
final discount = gross - cart.netAmount;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: AppRadius.brLg,
|
||||
boxShadow: AppColors.shadowMd,
|
||||
@@ -70,20 +70,20 @@ class ReceiptPreview extends StatelessWidget {
|
||||
.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
Text(AppConstants.storeLegalName,
|
||||
style: AppTypography.mono(8.5)),
|
||||
style: AppTypography.mono(8.5),),
|
||||
const SizedBox(height: 2),
|
||||
Text(AppConstants.storeAddress,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.mono(8.5)),
|
||||
style: AppTypography.mono(8.5),),
|
||||
Text('Customer care : ${AppConstants.storePhone}',
|
||||
style: AppTypography.mono(8.5)),
|
||||
style: AppTypography.mono(8.5),),
|
||||
Text('CIN No : ${AppConstants.storeCin}',
|
||||
style: AppTypography.mono(8.5)),
|
||||
style: AppTypography.mono(8.5),),
|
||||
Text('GSTIN : ${AppConstants.storeGstin}',
|
||||
style: AppTypography.mono(8.5)),
|
||||
style: AppTypography.mono(8.5),),
|
||||
Text('FSSAI Lic No : ${AppConstants.storeFssai}',
|
||||
style: AppTypography.mono(8.5)),
|
||||
]),
|
||||
style: AppTypography.mono(8.5),),
|
||||
],),
|
||||
),
|
||||
|
||||
if (discount > 0) ...[
|
||||
@@ -104,10 +104,10 @@ class ReceiptPreview extends StatelessWidget {
|
||||
child: Column(children: [
|
||||
Text('TAX INVOICE',
|
||||
style: AppTypography.mono(11)
|
||||
.copyWith(fontWeight: FontWeight.w700)),
|
||||
.copyWith(fontWeight: FontWeight.w700),),
|
||||
Text('xxxxxx Original for Recipient xxxxxx',
|
||||
style: AppTypography.mono(8)),
|
||||
]),
|
||||
style: AppTypography.mono(8),),
|
||||
],),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
|
||||
@@ -117,7 +117,7 @@ class ReceiptPreview extends StatelessWidget {
|
||||
_plain('Customer Type: '
|
||||
'${txn.customer == null ? 'URD' : 'REG'}'),
|
||||
_row('Date:${Formatters.receiptStamp(txn.createdAt)}',
|
||||
'Bill No:${txn.invoiceNumber.split('-').last}'),
|
||||
'Bill No:${txn.invoiceNumber.split('-').last}',),
|
||||
_row(
|
||||
'Store:${AppConstants.storeCode} '
|
||||
'Cashier:${txn.cashierName}',
|
||||
@@ -135,13 +135,13 @@ class ReceiptPreview extends StatelessWidget {
|
||||
Expanded(flex: 34, child: _bold('Item Description')),
|
||||
Expanded(
|
||||
flex: 16,
|
||||
child: _bold('Net Price', align: TextAlign.right)),
|
||||
child: _bold('Net Price', align: TextAlign.right),),
|
||||
Expanded(
|
||||
flex: 8, child: _bold('Qty', align: TextAlign.right)),
|
||||
flex: 8, child: _bold('Qty', align: TextAlign.right),),
|
||||
Expanded(
|
||||
flex: 18,
|
||||
child: _bold('Value', align: TextAlign.right)),
|
||||
]),
|
||||
child: _bold('Value', align: TextAlign.right),),
|
||||
],),
|
||||
const SizedBox(height: 3),
|
||||
|
||||
for (final slab in slabs) ...[
|
||||
@@ -162,28 +162,28 @@ class ReceiptPreview extends StatelessWidget {
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
flex: 12,
|
||||
child: _cell(line.product.hsnCode ?? '-')),
|
||||
child: _cell(line.product.hsnCode ?? '-'),),
|
||||
Expanded(
|
||||
flex: 34,
|
||||
child:
|
||||
_cell(line.product.name.toUpperCase())),
|
||||
_cell(line.product.name.toUpperCase()),),
|
||||
Expanded(
|
||||
flex: 16,
|
||||
child: _cell(
|
||||
line.product.price.toStringAsFixed(2),
|
||||
align: TextAlign.right),
|
||||
align: TextAlign.right,),
|
||||
),
|
||||
Expanded(
|
||||
flex: 8,
|
||||
child: _cell(_qty(line.quantity),
|
||||
align: TextAlign.right),
|
||||
align: TextAlign.right,),
|
||||
),
|
||||
Expanded(
|
||||
flex: 18,
|
||||
child: _cell(line.payable.toStringAsFixed(2),
|
||||
align: TextAlign.right),
|
||||
align: TextAlign.right,),
|
||||
),
|
||||
]),
|
||||
],),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -197,7 +197,7 @@ class ReceiptPreview extends StatelessWidget {
|
||||
_amount('Gross Sales Value', gross),
|
||||
if (discount > 0) _amount('Total Discount', discount),
|
||||
_amount(
|
||||
'Net Sales Value (Inclusive of GST)', cart.netAmount),
|
||||
'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),
|
||||
@@ -206,34 +206,34 @@ class ReceiptPreview extends StatelessWidget {
|
||||
if (txn.changeDue > 0)
|
||||
_amount('Change Returned', txn.changeDue),
|
||||
Text('(AMOUNT INCLUSIVE OF APPLICABLE TAXES)',
|
||||
style: AppTypography.mono(8)),
|
||||
style: AppTypography.mono(8),),
|
||||
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// ------------------------------------------- gst breakup
|
||||
Center(
|
||||
child: Text('------GST Breakup Details------ Amount (INR)',
|
||||
style: AppTypography.mono(8.5)),
|
||||
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)),
|
||||
child: _bold('Taxable', align: TextAlign.right),),
|
||||
Expanded(
|
||||
flex: 16,
|
||||
child: _bold('CGST', align: TextAlign.right)),
|
||||
child: _bold('CGST', align: TextAlign.right),),
|
||||
Expanded(
|
||||
flex: 16,
|
||||
child: _bold('SGST', align: TextAlign.right)),
|
||||
child: _bold('SGST', align: TextAlign.right),),
|
||||
Expanded(
|
||||
flex: 14,
|
||||
child: _bold('CESS', align: TextAlign.right)),
|
||||
child: _bold('CESS', align: TextAlign.right),),
|
||||
Expanded(
|
||||
flex: 20,
|
||||
child: _bold('Total', align: TextAlign.right)),
|
||||
]),
|
||||
child: _bold('Total', align: TextAlign.right),),
|
||||
],),
|
||||
const SizedBox(height: 2),
|
||||
for (final s in slabs)
|
||||
Padding(
|
||||
@@ -243,23 +243,23 @@ class ReceiptPreview extends StatelessWidget {
|
||||
Expanded(
|
||||
flex: 20,
|
||||
child: _cell(s.taxable.toStringAsFixed(2),
|
||||
align: TextAlign.right)),
|
||||
align: TextAlign.right,),),
|
||||
Expanded(
|
||||
flex: 16,
|
||||
child: _cell(s.cgst.toStringAsFixed(2),
|
||||
align: TextAlign.right)),
|
||||
align: TextAlign.right,),),
|
||||
Expanded(
|
||||
flex: 16,
|
||||
child: _cell(s.sgst.toStringAsFixed(2),
|
||||
align: TextAlign.right)),
|
||||
align: TextAlign.right,),),
|
||||
Expanded(
|
||||
flex: 14,
|
||||
child: _cell('0.00', align: TextAlign.right)),
|
||||
child: _cell('0.00', align: TextAlign.right),),
|
||||
Expanded(
|
||||
flex: 20,
|
||||
child: _cell(s.total.toStringAsFixed(2),
|
||||
align: TextAlign.right)),
|
||||
]),
|
||||
align: TextAlign.right,),),
|
||||
],),
|
||||
),
|
||||
const _Dashes(),
|
||||
Row(children: [
|
||||
@@ -270,32 +270,32 @@ class ReceiptPreview extends StatelessWidget {
|
||||
slabs
|
||||
.fold(0.0, (a, s) => a + s.taxable)
|
||||
.toStringAsFixed(2),
|
||||
align: TextAlign.right)),
|
||||
align: TextAlign.right,),),
|
||||
Expanded(
|
||||
flex: 16,
|
||||
child: _bold(
|
||||
slabs
|
||||
.fold(0.0, (a, s) => a + s.cgst)
|
||||
.toStringAsFixed(2),
|
||||
align: TextAlign.right)),
|
||||
align: TextAlign.right,),),
|
||||
Expanded(
|
||||
flex: 16,
|
||||
child: _bold(
|
||||
slabs
|
||||
.fold(0.0, (a, s) => a + s.sgst)
|
||||
.toStringAsFixed(2),
|
||||
align: TextAlign.right)),
|
||||
align: TextAlign.right,),),
|
||||
Expanded(
|
||||
flex: 14,
|
||||
child: _bold('0.00', align: TextAlign.right)),
|
||||
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)),
|
||||
]),
|
||||
align: TextAlign.right,),),
|
||||
],),
|
||||
|
||||
const _Dashes(),
|
||||
_plain('TaxInvoice# ${txn.invoiceNumber}'),
|
||||
@@ -311,10 +311,10 @@ class ReceiptPreview extends StatelessWidget {
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text('* Thank You for Shopping with us *',
|
||||
style: AppTypography.mono(10)
|
||||
.copyWith(fontWeight: FontWeight.w700)),
|
||||
.copyWith(fontWeight: FontWeight.w700),),
|
||||
Text('Powered by Nearle POS',
|
||||
style: AppTypography.mono(8)),
|
||||
]),
|
||||
style: AppTypography.mono(8),),
|
||||
],),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -322,7 +322,7 @@ class ReceiptPreview extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const _Perforation(top: false),
|
||||
]),
|
||||
],),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -342,7 +342,7 @@ class ReceiptPreview extends StatelessWidget {
|
||||
Flexible(
|
||||
child: Text(left,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTypography.mono(9)),
|
||||
style: AppTypography.mono(9),),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(right, style: AppTypography.mono(9)),
|
||||
@@ -518,6 +518,6 @@ class _FakeBarcode extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(value, style: AppTypography.mono(9)),
|
||||
]);
|
||||
],);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ final unsyncedCountProvider = FutureProvider<int>((ref) {
|
||||
return ref.watch(syncRepositoryProvider).unsyncedCount();
|
||||
});
|
||||
|
||||
/// Today's totals, read back from SQLite.
|
||||
/// Everything this terminal traded today, across every operator.
|
||||
final todayReportProvider = FutureProvider<ShiftReport>((ref) {
|
||||
ref.watch(orderVersionProvider);
|
||||
final session = ref.watch(cashierSessionProvider);
|
||||
@@ -109,6 +109,22 @@ final todayReportProvider = FutureProvider<ShiftReport>((ref) {
|
||||
);
|
||||
});
|
||||
|
||||
/// Only the bills the signed-in operator rang.
|
||||
///
|
||||
/// This is the figure a cashier counts their drawer against at the end of a
|
||||
/// shift, so it must not include anyone else's sales.
|
||||
final myShiftReportProvider = FutureProvider<ShiftReport>((ref) {
|
||||
ref.watch(orderVersionProvider);
|
||||
final session = ref.watch(cashierSessionProvider);
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
return ref.watch(syncRepositoryProvider).todayReport(
|
||||
terminalId: session.terminalId,
|
||||
cashierName: user?.name ?? session.name,
|
||||
scopeToCashier: true,
|
||||
);
|
||||
});
|
||||
|
||||
/// Per-order sync state for the events log.
|
||||
final orderSyncRowsProvider = FutureProvider<List<OrderSyncRow>>((ref) {
|
||||
ref.watch(orderVersionProvider);
|
||||
|
||||
@@ -56,7 +56,8 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final report = ref.watch(todayReportProvider).value;
|
||||
// The operator is settling their own till, so this covers only their bills.
|
||||
final report = ref.watch(myShiftReportProvider).value;
|
||||
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
|
||||
final cart = ref.watch(cartControllerProvider);
|
||||
final pushing = ref.watch(orderSyncProvider) is SyncRunning;
|
||||
@@ -111,7 +112,7 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
||||
_row('Items sold', report.itemCount.toStringAsFixed(0)),
|
||||
_row('Gross sales', Formatters.money(report.grossSales)),
|
||||
_row('GST collected',
|
||||
Formatters.money(report.taxCollected)),
|
||||
Formatters.money(report.taxCollected),),
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
169
test/unit/barcode_service_test.dart
Normal file
169
test/unit/barcode_service_test.dart
Normal file
@@ -0,0 +1,169 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nearle_pos/core/services/barcode_service.dart';
|
||||
|
||||
/// The scanner is a keyboard, so the only thing separating a scan from the
|
||||
/// cashier typing is timing. These drive the handler with a controlled clock
|
||||
/// rather than real delays, so the boundary is exact.
|
||||
void main() {
|
||||
late List<String> scans;
|
||||
late int manualKeys;
|
||||
late DateTime now;
|
||||
late bool editingText;
|
||||
|
||||
setUp(() {
|
||||
scans = [];
|
||||
manualKeys = 0;
|
||||
now = DateTime(2026, 7, 31, 10);
|
||||
editingText = false;
|
||||
});
|
||||
|
||||
BarcodeService build() => BarcodeService(
|
||||
onScan: scans.add,
|
||||
onManualKey: () => manualKeys++,
|
||||
clock: () => now,
|
||||
isEditingText: () => editingText,
|
||||
);
|
||||
|
||||
KeyEvent char(String c) => KeyDownEvent(
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
character: c,
|
||||
timeStamp: Duration.zero,
|
||||
);
|
||||
|
||||
KeyEvent enterKey() => const KeyDownEvent(
|
||||
physicalKey: PhysicalKeyboardKey.enter,
|
||||
logicalKey: LogicalKeyboardKey.enter,
|
||||
timeStamp: Duration.zero,
|
||||
);
|
||||
|
||||
/// Feeds [text] with a fixed gap between keystrokes, returning which
|
||||
/// keystrokes the service swallowed.
|
||||
List<bool> type(
|
||||
BarcodeService service,
|
||||
String text, {
|
||||
required int gapMs,
|
||||
}) {
|
||||
return [
|
||||
for (final c in text.split('')) ...[
|
||||
() {
|
||||
now = now.add(Duration(milliseconds: gapMs));
|
||||
return service.handleKey(char(c));
|
||||
}(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
group('scanner input', () {
|
||||
test('a fast burst terminated by Enter is billed as a scan', () {
|
||||
final service = build();
|
||||
|
||||
type(service, '8901234500011', gapMs: 5);
|
||||
now = now.add(const Duration(milliseconds: 5));
|
||||
final enterConsumed = service.handleKey(enterKey());
|
||||
|
||||
expect(scans, ['8901234500011']);
|
||||
expect(enterConsumed, isTrue,
|
||||
reason: 'the scanner terminator must not reach the focused field',);
|
||||
expect(manualKeys, 0);
|
||||
});
|
||||
|
||||
test('the burst is swallowed so it cannot also land in a text field', () {
|
||||
final service = build();
|
||||
|
||||
final consumed = type(service, '8901234500011', gapMs: 5);
|
||||
|
||||
// The first keystroke cannot be judged yet — nothing has arrived to
|
||||
// measure a gap against — so exactly one character escapes. Everything
|
||||
// after it is recognised as machine-paced and consumed.
|
||||
expect(consumed.first, isFalse);
|
||||
expect(consumed.skip(1), everyElement(isTrue));
|
||||
});
|
||||
|
||||
test('a scanner that sends no Enter still flushes on idle', () async {
|
||||
final service = build();
|
||||
type(service, '8901234500011', gapMs: 5);
|
||||
|
||||
expect(scans, isEmpty, reason: 'nothing has flushed yet');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
expect(scans, ['8901234500011']);
|
||||
});
|
||||
});
|
||||
|
||||
group('human input', () {
|
||||
test('typing at human speed is never treated as a scan', () {
|
||||
final service = build();
|
||||
|
||||
final consumed = type(service, '9876543210', gapMs: 200);
|
||||
now = now.add(const Duration(milliseconds: 200));
|
||||
final enterConsumed = service.handleKey(enterKey());
|
||||
|
||||
expect(scans, isEmpty);
|
||||
expect(consumed, everyElement(isFalse),
|
||||
reason: 'hand-typed characters must reach the field',);
|
||||
expect(enterConsumed, isFalse,
|
||||
reason: 'swallowing Enter would break form submission',);
|
||||
});
|
||||
|
||||
test('a fast typist in a text field does not trigger a phantom scan', () {
|
||||
editingText = true;
|
||||
final service = build();
|
||||
|
||||
// ~100ms per character is a quick typist and used to clear the bar.
|
||||
type(service, '9876543210', gapMs: 100);
|
||||
now = now.add(const Duration(milliseconds: 100));
|
||||
final enterConsumed = service.handleKey(enterKey());
|
||||
|
||||
expect(scans, isEmpty,
|
||||
reason: 'entering a mobile number must not jump to billing',);
|
||||
expect(enterConsumed, isFalse);
|
||||
});
|
||||
|
||||
test('the same speed does count as a scan when no field has focus', () {
|
||||
editingText = false;
|
||||
final service = build();
|
||||
|
||||
type(service, '9876543210', gapMs: 100);
|
||||
now = now.add(const Duration(milliseconds: 100));
|
||||
service.handleKey(enterKey());
|
||||
|
||||
expect(scans, ['9876543210']);
|
||||
});
|
||||
|
||||
test('a pause mid-burst starts a new entry', () {
|
||||
final service = build();
|
||||
|
||||
type(service, '890123', gapMs: 5);
|
||||
now = now.add(const Duration(milliseconds: 500)); // cashier hesitates
|
||||
type(service, '4500011', gapMs: 5);
|
||||
now = now.add(const Duration(milliseconds: 5));
|
||||
service.handleKey(enterKey());
|
||||
|
||||
expect(scans, ['4500011'],
|
||||
reason: 'only the characters after the pause form the code',);
|
||||
});
|
||||
});
|
||||
|
||||
group('rejected input', () {
|
||||
test('a buffer shorter than a barcode is reported as manual input', () {
|
||||
final service = build();
|
||||
|
||||
type(service, 'abc', gapMs: 5);
|
||||
now = now.add(const Duration(milliseconds: 5));
|
||||
final enterConsumed = service.handleKey(enterKey());
|
||||
|
||||
expect(scans, isEmpty);
|
||||
expect(manualKeys, 1);
|
||||
expect(enterConsumed, isFalse);
|
||||
});
|
||||
|
||||
test('non-alphanumeric characters are ignored entirely', () {
|
||||
final service = build();
|
||||
|
||||
now = now.add(const Duration(milliseconds: 5));
|
||||
expect(service.handleKey(char(r'$')), isFalse);
|
||||
expect(scans, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,7 @@ const _item = Product(
|
||||
gstRate: 0.18,
|
||||
);
|
||||
|
||||
Customer _silver() => Customer(
|
||||
Customer _silver() => const Customer(
|
||||
id: 'cust-1',
|
||||
name: 'Silver Shopper',
|
||||
mobile: '9876543210',
|
||||
@@ -169,10 +169,10 @@ void main() {
|
||||
const cart = Cart(lines: [
|
||||
CartLine(product: _item, quantity: 1),
|
||||
CartLine(product: zeroRated, quantity: 1),
|
||||
]);
|
||||
],);
|
||||
|
||||
final breakdown = cart.taxBreakdown;
|
||||
expect(breakdown.keys, containsAll<double>([0.18, 0.0]));
|
||||
expect(breakdown.keys, containsAll([0.18, 0.0]));
|
||||
expect(breakdown[0.0], 0);
|
||||
expect(breakdown[0.18]!, greaterThan(0));
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nearle_pos/data/datasources/local_store.dart';
|
||||
import 'package:nearle_pos/data/datasources/seed_data.dart';
|
||||
import 'package:nearle_pos/data/repositories/customer_repository_impl.dart';
|
||||
import 'package:nearle_pos/data/repositories/product_repository_impl.dart';
|
||||
import 'package:nearle_pos/data/repositories/transaction_repository_impl.dart';
|
||||
@@ -15,10 +16,19 @@ void main() {
|
||||
late TransactionRepositoryImpl transactions;
|
||||
late CheckoutSale checkout;
|
||||
|
||||
setUpAll(() {
|
||||
// The store only reaches for demo rows through these hooks, so the seed has
|
||||
// to be registered before any test asks for a catalogue.
|
||||
LocalStore.registerSeed(
|
||||
products: SeedData.products,
|
||||
customers: SeedData.customers,
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
store = LocalStore.instance;
|
||||
// Fresh seed for every test so stock and invoice sequence never leak.
|
||||
await store.reset();
|
||||
await store.reset(withCatalogue: true);
|
||||
|
||||
products = ProductRepositoryImpl(store);
|
||||
customers = CustomerRepositoryImpl(store);
|
||||
|
||||
204
test/unit/migration_test.dart
Normal file
204
test/unit/migration_test.dart
Normal file
@@ -0,0 +1,204 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nearle_pos/data/local/app_database.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
/// A terminal already in the field is running the v3 schema. Upgrading it must
|
||||
/// carry the day's archived takings across rather than dropping them, so this
|
||||
/// builds a genuine v3 database and opens it with the current code.
|
||||
void main() {
|
||||
late Directory dir;
|
||||
late String dbPath;
|
||||
|
||||
setUpAll(sqfliteFfiInit);
|
||||
|
||||
setUp(() async {
|
||||
dir = await Directory.systemTemp.createTemp('nearle_migration');
|
||||
dbPath = '${dir.path}/nearle_pos.db';
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await AppDatabase.instance.close();
|
||||
if (dir.existsSync()) dir.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
/// The schema exactly as v3 shipped it.
|
||||
Future<void> createV3Database() async {
|
||||
final db = await databaseFactory.openDatabase(
|
||||
dbPath,
|
||||
options: OpenDatabaseOptions(
|
||||
version: 3,
|
||||
onCreate: (db, _) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE products (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, barcode TEXT NOT NULL,
|
||||
sku TEXT NOT NULL, category TEXT NOT NULL, price REAL NOT NULL,
|
||||
mrp REAL, stock REAL NOT NULL DEFAULT 0, emoji TEXT,
|
||||
image_url TEXT, unit TEXT NOT NULL DEFAULT 'piece',
|
||||
gst_rate REAL NOT NULL DEFAULT 0.18, hsn_code TEXT, brand TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE customers (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, mobile TEXT NOT NULL,
|
||||
email TEXT, gender TEXT NOT NULL DEFAULT 'unspecified',
|
||||
date_of_birth INTEGER, loyalty_points INTEGER NOT NULL DEFAULT 0,
|
||||
lifetime_spend REAL NOT NULL DEFAULT 0,
|
||||
visit_count INTEGER NOT NULL DEFAULT 0, created_at INTEGER,
|
||||
last_visit_at INTEGER)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE orders (
|
||||
id TEXT PRIMARY KEY, invoice_number TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL, business_date TEXT NOT NULL,
|
||||
cashier_name TEXT NOT NULL, terminal_id TEXT NOT NULL,
|
||||
customer_id TEXT, customer_mobile TEXT, customer_name TEXT,
|
||||
subtotal REAL NOT NULL, line_discount REAL NOT NULL DEFAULT 0,
|
||||
bill_discount REAL NOT NULL DEFAULT 0,
|
||||
loyalty_value REAL NOT NULL DEFAULT 0,
|
||||
taxable_amount REAL NOT NULL DEFAULT 0,
|
||||
tax_amount REAL NOT NULL DEFAULT 0,
|
||||
round_off REAL NOT NULL DEFAULT 0, total REAL NOT NULL,
|
||||
points_earned INTEGER NOT NULL DEFAULT 0,
|
||||
points_redeemed INTEGER NOT NULL DEFAULT 0,
|
||||
payments_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
sync_status INTEGER NOT NULL DEFAULT 0, synced_at INTEGER,
|
||||
sync_attempts INTEGER NOT NULL DEFAULT 0, sync_error TEXT)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE order_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, order_id TEXT NOT NULL,
|
||||
product_id TEXT NOT NULL, name TEXT NOT NULL,
|
||||
barcode TEXT NOT NULL, sku TEXT NOT NULL, unit TEXT NOT NULL,
|
||||
unit_price REAL NOT NULL, quantity REAL NOT NULL,
|
||||
discount REAL NOT NULL DEFAULT 0, gst_rate REAL NOT NULL DEFAULT 0,
|
||||
tax_amount REAL NOT NULL DEFAULT 0, line_total REAL NOT NULL,
|
||||
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE parked_bills (
|
||||
id TEXT PRIMARY KEY, label TEXT, parked_at INTEGER NOT NULL,
|
||||
cart_json TEXT NOT NULL)
|
||||
''');
|
||||
// v3 sync_log: no payload column.
|
||||
await db.execute('''
|
||||
CREATE TABLE sync_log (
|
||||
id TEXT PRIMARY KEY, type TEXT NOT NULL, status TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL, synced_at INTEGER,
|
||||
summary TEXT NOT NULL, error TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0)
|
||||
''');
|
||||
// v3 day_archive: keyed by date alone, no cashier.
|
||||
await db.execute('''
|
||||
CREATE TABLE day_archive (
|
||||
business_date TEXT PRIMARY KEY,
|
||||
bill_count INTEGER NOT NULL DEFAULT 0,
|
||||
item_count REAL NOT NULL DEFAULT 0,
|
||||
gross_sales REAL NOT NULL DEFAULT 0,
|
||||
tax_collected REAL NOT NULL DEFAULT 0,
|
||||
discount_given REAL NOT NULL DEFAULT 0,
|
||||
round_off REAL NOT NULL DEFAULT 0,
|
||||
points_issued INTEGER NOT NULL DEFAULT 0,
|
||||
points_redeemed INTEGER NOT NULL DEFAULT 0,
|
||||
payments_json TEXT NOT NULL DEFAULT '{}',
|
||||
first_bill_at INTEGER, last_bill_at INTEGER,
|
||||
synced_bills INTEGER NOT NULL DEFAULT 0)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE TABLE app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)',
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await db.insert('day_archive', {
|
||||
'business_date': '2026-07-30',
|
||||
'bill_count': 12,
|
||||
'item_count': 40.0,
|
||||
'gross_sales': 8450.0,
|
||||
'tax_collected': 620.5,
|
||||
'discount_given': 130.0,
|
||||
'round_off': 1.5,
|
||||
'points_issued': 84,
|
||||
'points_redeemed': 20,
|
||||
'payments_json': '{"cash":5000.0,"upi":3450.0}',
|
||||
'first_bill_at': 1000,
|
||||
'last_bill_at': 2000,
|
||||
'synced_bills': 12,
|
||||
});
|
||||
await db.insert('app_meta', {'key': 'invoice_sequence', 'value': '12'});
|
||||
await db.close();
|
||||
}
|
||||
|
||||
test('a v3 terminal upgrades without losing its archived takings', () async {
|
||||
await createV3Database();
|
||||
|
||||
await AppDatabase.instance.open(overridePath: dbPath);
|
||||
final db = AppDatabase.instance.db;
|
||||
|
||||
expect(await db.getVersion(), 4);
|
||||
|
||||
final rows = await db.query('day_archive');
|
||||
expect(rows, hasLength(1));
|
||||
|
||||
final row = rows.single;
|
||||
expect(row['business_date'], '2026-07-30');
|
||||
expect(row['cashier_name'], '',
|
||||
reason: 'rows from before per-cashier attribution get an empty name',);
|
||||
expect(row['bill_count'], 12);
|
||||
expect(row['gross_sales'], 8450.0);
|
||||
expect(row['tax_collected'], 620.5);
|
||||
expect(row['payments_json'], '{"cash":5000.0,"upi":3450.0}');
|
||||
expect(row['synced_bills'], 12);
|
||||
|
||||
// Unrelated state must be untouched by the migration.
|
||||
final meta = await db.query('app_meta', where: "key = 'invoice_sequence'");
|
||||
expect(meta.single['value'], '12');
|
||||
});
|
||||
|
||||
test('the upgraded sync log accepts a payload', () async {
|
||||
await createV3Database();
|
||||
await AppDatabase.instance.open(overridePath: dbPath);
|
||||
final db = AppDatabase.instance.db;
|
||||
|
||||
await db.insert('sync_log', {
|
||||
'id': 'e1',
|
||||
'type': 'shiftReport',
|
||||
'status': 'synced',
|
||||
'created_at': 1,
|
||||
'summary': '3 bills uploaded',
|
||||
'attempts': 1,
|
||||
'payload_json': '{"invoices":["INV-1"]}',
|
||||
});
|
||||
|
||||
final row = (await db.query('sync_log')).single;
|
||||
expect(row['payload_json'], '{"invoices":["INV-1"]}');
|
||||
});
|
||||
|
||||
test('the day archive now holds one row per cashier', () async {
|
||||
await createV3Database();
|
||||
await AppDatabase.instance.open(overridePath: dbPath);
|
||||
final db = AppDatabase.instance.db;
|
||||
|
||||
for (final cashier in ['Divya', 'Rahul']) {
|
||||
await db.insert('day_archive', {
|
||||
'business_date': '2026-07-31',
|
||||
'cashier_name': cashier,
|
||||
'bill_count': 1,
|
||||
'gross_sales': 100.0,
|
||||
'payments_json': '{}',
|
||||
});
|
||||
}
|
||||
|
||||
final rows = await db.query(
|
||||
'day_archive',
|
||||
where: 'business_date = ?',
|
||||
whereArgs: ['2026-07-31'],
|
||||
);
|
||||
expect(rows, hasLength(2),
|
||||
reason: 'two cashiers on the same day must not collide',);
|
||||
});
|
||||
}
|
||||
463
test/unit/persistence_test.dart
Normal file
463
test/unit/persistence_test.dart
Normal file
@@ -0,0 +1,463 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nearle_pos/data/datasources/local_store.dart';
|
||||
import 'package:nearle_pos/data/datasources/remote_catalogue_source.dart';
|
||||
import 'package:nearle_pos/data/datasources/seed_data.dart';
|
||||
import 'package:nearle_pos/data/repositories/customer_repository_impl.dart';
|
||||
import 'package:nearle_pos/data/repositories/product_repository_impl.dart';
|
||||
import 'package:nearle_pos/data/repositories/sync_repository_impl.dart';
|
||||
import 'package:nearle_pos/data/repositories/transaction_repository_impl.dart';
|
||||
import 'package:nearle_pos/domain/entities/cart.dart';
|
||||
import 'package:nearle_pos/domain/entities/customer.dart';
|
||||
import 'package:nearle_pos/domain/entities/shift_report.dart';
|
||||
import 'package:nearle_pos/domain/entities/sync_event.dart';
|
||||
import 'package:nearle_pos/domain/entities/transaction.dart';
|
||||
import 'package:nearle_pos/domain/usecases/checkout_sale.dart';
|
||||
|
||||
/// Guards the boundary between a live bill and a stored one.
|
||||
///
|
||||
/// Every figure a shop is paid on survives a write to SQLite and a read back
|
||||
/// out of it. These are regressions: the read path used to rebuild a bill from
|
||||
/// its lines alone, silently dropping bill-level discounts and loyalty.
|
||||
void main() {
|
||||
late LocalStore store;
|
||||
late ProductRepositoryImpl products;
|
||||
late CustomerRepositoryImpl customers;
|
||||
late TransactionRepositoryImpl transactions;
|
||||
late CheckoutSale checkout;
|
||||
|
||||
setUpAll(() {
|
||||
LocalStore.registerSeed(
|
||||
products: SeedData.products,
|
||||
customers: SeedData.customers,
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
store = LocalStore.instance;
|
||||
await store.reset(withCatalogue: true);
|
||||
|
||||
products = ProductRepositoryImpl(store);
|
||||
customers = CustomerRepositoryImpl(store);
|
||||
transactions = TransactionRepositoryImpl(store);
|
||||
checkout = CheckoutSale(
|
||||
productRepository: products,
|
||||
customerRepository: customers,
|
||||
transactionRepository: transactions,
|
||||
);
|
||||
});
|
||||
|
||||
/// A Gold member (5% tier discount) holding 400 points.
|
||||
Future<Customer> goldMember() async {
|
||||
final created = await customers.create(const Customer(
|
||||
id: 'ignored',
|
||||
name: 'Gold Shopper',
|
||||
mobile: '9000000001',
|
||||
),);
|
||||
return customers.update(
|
||||
created.copyWith(loyaltyPoints: 400, lifetimeSpend: 60000),
|
||||
);
|
||||
}
|
||||
|
||||
group('order round trip', () {
|
||||
test('bill discount, loyalty and total survive a write and read back',
|
||||
() async {
|
||||
final milk = (await products.findByBarcode('8901234500011'))!;
|
||||
final member = await goldMember();
|
||||
|
||||
// 10 x 62.00 = 620 subtotal, less 5% tier (31) and a flat 50 = 81,
|
||||
// less 40 points at 0.25 = 10. Payable 529.
|
||||
final cart = Cart(
|
||||
lines: [CartLine(product: milk, quantity: 10)],
|
||||
customer: member,
|
||||
billDiscount: const Discount(type: DiscountType.flat, value: 50),
|
||||
pointsRedeemed: 40,
|
||||
);
|
||||
expect(cart.grandTotal, 529);
|
||||
expect(cart.billDiscountTotal, 81);
|
||||
|
||||
final result = await checkout(
|
||||
cart: cart,
|
||||
payments: const [
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: 529, tendered: 529),
|
||||
],
|
||||
cashierName: 'Divya',
|
||||
);
|
||||
|
||||
final stored = (await transactions.history()).single;
|
||||
|
||||
expect(stored.total, result.transaction.total,
|
||||
reason: 'the figure charged must not move in storage',);
|
||||
expect(stored.total, 529);
|
||||
expect(stored.cart.billDiscountTotal, 81);
|
||||
expect(stored.cart.loyaltyRedemptionValue, 10);
|
||||
expect(stored.cart.pointsRedeemed, 40);
|
||||
expect(stored.cart.taxAmount, cart.taxAmount);
|
||||
expect(stored.cart.subtotal, 620);
|
||||
});
|
||||
|
||||
test('a rebuilt bill does not re-apply the tier discount', () async {
|
||||
final milk = (await products.findByBarcode('8901234500011'))!;
|
||||
final member = await goldMember();
|
||||
|
||||
final cart = Cart(
|
||||
lines: [CartLine(product: milk, quantity: 10)],
|
||||
customer: member,
|
||||
);
|
||||
// 620 less the 5% tier discount only.
|
||||
expect(cart.grandTotal, 589);
|
||||
|
||||
await checkout(
|
||||
cart: cart,
|
||||
payments: const [
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: 589, tendered: 600),
|
||||
],
|
||||
cashierName: 'Divya',
|
||||
);
|
||||
|
||||
final stored = (await transactions.history()).single;
|
||||
expect(stored.cart.billDiscountTotal, 31);
|
||||
expect(stored.total, 589);
|
||||
});
|
||||
|
||||
test('the shift report reads back the amount actually charged', () async {
|
||||
final milk = (await products.findByBarcode('8901234500011'))!;
|
||||
final member = await goldMember();
|
||||
|
||||
await checkout(
|
||||
cart: Cart(
|
||||
lines: [CartLine(product: milk, quantity: 10)],
|
||||
customer: member,
|
||||
billDiscount: const Discount(type: DiscountType.flat, value: 50),
|
||||
pointsRedeemed: 40,
|
||||
),
|
||||
payments: const [
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: 529, tendered: 529),
|
||||
],
|
||||
cashierName: 'Divya',
|
||||
);
|
||||
|
||||
final report = ShiftReport.fromTransactions(
|
||||
transactions: await transactions.history(),
|
||||
businessDate: DateTime.now(),
|
||||
terminalId: 'TERM-01',
|
||||
cashierName: 'Divya',
|
||||
);
|
||||
|
||||
expect(report.grossSales, 529);
|
||||
expect(report.discountGiven, 81);
|
||||
expect(report.loyaltyPointsRedeemed, 40);
|
||||
});
|
||||
|
||||
test('the bill is stamped with the operator who rang it', () async {
|
||||
final milk = (await products.findByBarcode('8901234500011'))!;
|
||||
|
||||
await checkout(
|
||||
cart: Cart(lines: [CartLine(product: milk, quantity: 1)]),
|
||||
payments: const [
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: 62, tendered: 100),
|
||||
],
|
||||
cashierName: 'Rahul',
|
||||
terminalId: 'TERM-07',
|
||||
);
|
||||
|
||||
final stored = (await transactions.history()).single;
|
||||
expect(stored.cashierName, 'Rahul');
|
||||
expect(stored.terminalId, 'TERM-07');
|
||||
});
|
||||
});
|
||||
|
||||
group('stock safety', () {
|
||||
test('a resumed parked bill is re-checked against live stock', () async {
|
||||
final milk = (await products.findByBarcode('8901234500011'))!;
|
||||
|
||||
await transactions.park(ParkedBill(
|
||||
id: 'p-1',
|
||||
cart: Cart(lines: [CartLine(product: milk, quantity: milk.stock)]),
|
||||
parkedAt: DateTime.now(),
|
||||
),);
|
||||
|
||||
// The same stock is sold on another bill in the meantime.
|
||||
await products.decrementStock({milk.id: milk.stock});
|
||||
|
||||
final resumed = (await transactions.parkedBills()).single;
|
||||
|
||||
await expectLater(
|
||||
checkout(
|
||||
cart: resumed.cart,
|
||||
payments: [
|
||||
PaymentSplit(
|
||||
method: PaymentMethod.cash,
|
||||
amount: resumed.cart.grandTotal,
|
||||
tendered: resumed.cart.grandTotal,
|
||||
),
|
||||
],
|
||||
cashierName: 'Divya',
|
||||
),
|
||||
throwsA(isA<CheckoutFailure>()),
|
||||
);
|
||||
|
||||
expect(await transactions.history(), isEmpty);
|
||||
});
|
||||
|
||||
test('a re-import does not restore stock already sold', () async {
|
||||
final milk = (await products.findByBarcode('8901234500011'))!;
|
||||
final opening = milk.stock;
|
||||
|
||||
await checkout(
|
||||
cart: Cart(lines: [CartLine(product: milk, quantity: 4)]),
|
||||
payments: const [
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: 248, tendered: 250),
|
||||
],
|
||||
cashierName: 'Divya',
|
||||
);
|
||||
expect((await products.findByBarcode('8901234500011'))!.stock,
|
||||
opening - 4,);
|
||||
|
||||
// The morning catalogue is pulled again before the bills went up.
|
||||
await store.importCatalogue(
|
||||
products: SeedData.products(),
|
||||
customers: SeedData.customers(),
|
||||
revision: 'seed-2',
|
||||
at: DateTime.now(),
|
||||
);
|
||||
|
||||
expect(
|
||||
(await products.findByBarcode('8901234500011'))!.stock,
|
||||
opening - 4,
|
||||
reason: 'units sold on unsynced bills must not reappear on the shelf',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('atomicity', () {
|
||||
test('nothing is persisted when the sale cannot be completed', () async {
|
||||
final milk = (await products.findByBarcode('8901234500011'))!;
|
||||
final opening = milk.stock;
|
||||
|
||||
// A customer that was never written to the database.
|
||||
const ghost = Customer(id: 'missing', name: 'Ghost', mobile: '9999999999');
|
||||
|
||||
await expectLater(
|
||||
checkout(
|
||||
cart: Cart(
|
||||
lines: [CartLine(product: milk, quantity: 2)],
|
||||
customer: ghost,
|
||||
),
|
||||
payments: const [
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: 124, tendered: 124),
|
||||
],
|
||||
cashierName: 'Divya',
|
||||
),
|
||||
throwsA(isA<CheckoutFailure>()),
|
||||
);
|
||||
|
||||
expect(await transactions.history(), isEmpty,
|
||||
reason: 'a failed sale must not leave a bill behind',);
|
||||
expect((await products.findByBarcode('8901234500011'))!.stock, opening,
|
||||
reason: 'a failed sale must not consume stock',);
|
||||
});
|
||||
|
||||
test('concurrent checkouts never share an invoice sequence', () async {
|
||||
final numbers = await Future.wait(
|
||||
List.generate(25, (_) => transactions.nextInvoiceSequence()),
|
||||
);
|
||||
expect(numbers.toSet().length, numbers.length);
|
||||
});
|
||||
});
|
||||
|
||||
group('end of day', () {
|
||||
test('the archived day totals match what was charged', () async {
|
||||
final sync = SyncRepositoryImpl(
|
||||
store,
|
||||
RemoteCatalogueSource(isOffline: () => false),
|
||||
RemoteOrderSink(isOffline: () => false),
|
||||
);
|
||||
|
||||
final milk = (await products.findByBarcode('8901234500011'))!;
|
||||
final member = await goldMember();
|
||||
|
||||
await checkout(
|
||||
cart: Cart(
|
||||
lines: [CartLine(product: milk, quantity: 10)],
|
||||
customer: member,
|
||||
billDiscount: const Discount(type: DiscountType.flat, value: 50),
|
||||
pointsRedeemed: 40,
|
||||
),
|
||||
payments: const [
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: 529, tendered: 529),
|
||||
],
|
||||
cashierName: 'Divya',
|
||||
);
|
||||
|
||||
final outcome = await sync.syncOrders();
|
||||
expect(outcome.uploaded, 1);
|
||||
expect(await sync.unsyncedCount(), 0);
|
||||
|
||||
// Synced bills are deleted, so today's figures now come from the archive.
|
||||
// A wrong row here is permanent — there is nothing left to recompute from.
|
||||
final report = await sync.todayReport(
|
||||
terminalId: 'TERM-01',
|
||||
cashierName: 'Divya',
|
||||
);
|
||||
|
||||
expect(report.billCount, 1);
|
||||
expect(report.grossSales, 529);
|
||||
expect(report.discountGiven, 81);
|
||||
expect(report.loyaltyPointsRedeemed, 40);
|
||||
expect(report.paymentBreakdown[PaymentMethod.cash], 529);
|
||||
});
|
||||
});
|
||||
|
||||
group('sync log', () {
|
||||
test('survives a restart, because it outlives the bills it describes',
|
||||
() async {
|
||||
final sync = SyncRepositoryImpl(
|
||||
store,
|
||||
RemoteCatalogueSource(isOffline: () => false),
|
||||
RemoteOrderSink(isOffline: () => false),
|
||||
);
|
||||
|
||||
final milk = (await products.findByBarcode('8901234500011'))!;
|
||||
await checkout(
|
||||
cart: Cart(lines: [CartLine(product: milk, quantity: 1)]),
|
||||
payments: const [
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: 62, tendered: 62),
|
||||
],
|
||||
cashierName: 'Divya',
|
||||
);
|
||||
await sync.syncOrders();
|
||||
|
||||
expect(sync.events, isNotEmpty);
|
||||
final summary = sync.events.first.summary;
|
||||
|
||||
// The cache is dropped and rebuilt from disk, as it is on a cold start.
|
||||
await store.hydrate();
|
||||
|
||||
expect(sync.events, isNotEmpty,
|
||||
reason: 'the only record that those bills went up must persist',);
|
||||
expect(sync.events.first.summary, summary);
|
||||
expect(sync.events.first.payload['invoices'], isNotEmpty);
|
||||
});
|
||||
|
||||
test('a failed import is recorded with its error', () async {
|
||||
final sync = SyncRepositoryImpl(
|
||||
store,
|
||||
RemoteCatalogueSource(isOffline: () => true),
|
||||
RemoteOrderSink(isOffline: () => true),
|
||||
);
|
||||
|
||||
await sync.importCatalogue();
|
||||
await store.hydrate();
|
||||
|
||||
expect(sync.events.first.status, SyncStatus.failed);
|
||||
expect(sync.events.first.error, isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('shift report scoping', () {
|
||||
Future<void> sell(String cashier, double qty) async {
|
||||
final milk = (await products.findByBarcode('8901234500011'))!;
|
||||
final due = Cart(lines: [CartLine(product: milk, quantity: qty)]).grandTotal;
|
||||
await checkout(
|
||||
cart: Cart(lines: [CartLine(product: milk, quantity: qty)]),
|
||||
payments: [
|
||||
PaymentSplit(method: PaymentMethod.cash, amount: due, tendered: due),
|
||||
],
|
||||
cashierName: cashier,
|
||||
);
|
||||
}
|
||||
|
||||
test('a cashier settles their own till, not the terminal', () async {
|
||||
final sync = SyncRepositoryImpl(
|
||||
store,
|
||||
RemoteCatalogueSource(isOffline: () => false),
|
||||
RemoteOrderSink(isOffline: () => false),
|
||||
);
|
||||
|
||||
await sell('Divya', 2); // 124
|
||||
await sell('Rahul', 3); // 186
|
||||
|
||||
final divya = await sync.todayReport(
|
||||
terminalId: 'TERM-01',
|
||||
cashierName: 'Divya',
|
||||
scopeToCashier: true,
|
||||
);
|
||||
final terminal = await sync.todayReport(
|
||||
terminalId: 'TERM-01',
|
||||
cashierName: 'Divya',
|
||||
);
|
||||
|
||||
expect(divya.billCount, 1);
|
||||
expect(divya.grossSales, 124);
|
||||
expect(terminal.billCount, 2);
|
||||
expect(terminal.grossSales, 310);
|
||||
});
|
||||
|
||||
test('scoping holds after the bills are uploaded and deleted', () async {
|
||||
final sync = SyncRepositoryImpl(
|
||||
store,
|
||||
RemoteCatalogueSource(isOffline: () => false),
|
||||
RemoteOrderSink(isOffline: () => false),
|
||||
);
|
||||
|
||||
await sell('Divya', 2);
|
||||
await sell('Rahul', 3);
|
||||
await sync.syncOrders();
|
||||
expect(await sync.unsyncedCount(), 0);
|
||||
|
||||
final divya = await sync.todayReport(
|
||||
terminalId: 'TERM-01',
|
||||
cashierName: 'Divya',
|
||||
scopeToCashier: true,
|
||||
);
|
||||
final terminal = await sync.todayReport(
|
||||
terminalId: 'TERM-01',
|
||||
cashierName: 'Divya',
|
||||
);
|
||||
|
||||
expect(divya.grossSales, 124,
|
||||
reason: 'the archive must keep each cashier separable',);
|
||||
expect(divya.billCount, 1);
|
||||
expect(terminal.grossSales, 310);
|
||||
expect(terminal.billCount, 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('customer lookup', () {
|
||||
test('a mobile number matches however the cashier punctuates it', () async {
|
||||
await customers.create(const Customer(
|
||||
id: 'ignored',
|
||||
name: 'Punctuation Test',
|
||||
mobile: '9000000042',
|
||||
),);
|
||||
|
||||
for (final query in ['9000000042', '90000 00042', '90-000-00042']) {
|
||||
expect(
|
||||
(await customers.search(query)).map((c) => c.name),
|
||||
contains('Punctuation Test'),
|
||||
reason: 'searching "$query" should find the customer',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('tax invoice', () {
|
||||
test('the slab breakdown sums to the GST charged', () async {
|
||||
// Two different slabs plus a bill discount, so apportioning is in play.
|
||||
final milk = (await products.findByBarcode('8901234500011'))!; // 5%
|
||||
final all = await products.getAll();
|
||||
final eighteen = all.firstWhere((p) => p.gstRate == 0.18);
|
||||
|
||||
final cart = Cart(
|
||||
lines: [
|
||||
CartLine(product: milk, quantity: 3),
|
||||
CartLine(product: eighteen, quantity: 7),
|
||||
],
|
||||
billDiscount: const Discount(type: DiscountType.percentage, value: 13),
|
||||
);
|
||||
|
||||
final summed = cart.taxBreakdown.values.fold(0.0, (a, b) => a + b);
|
||||
expect(summed, closeTo(cart.taxAmount, 0.0001));
|
||||
});
|
||||
});
|
||||
}
|
||||
130
test/widget/app_smoke_test.dart
Normal file
130
test/widget/app_smoke_test.dart
Normal file
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:nearle_pos/app/app.dart';
|
||||
import 'package:nearle_pos/data/datasources/local_store.dart';
|
||||
import 'package:nearle_pos/data/datasources/seed_data.dart';
|
||||
import 'package:nearle_pos/domain/entities/shift_report.dart';
|
||||
import 'package:nearle_pos/presentation/auth/providers/auth_controller.dart';
|
||||
import 'package:nearle_pos/presentation/pos/providers/cart_controller.dart';
|
||||
import 'package:nearle_pos/presentation/pos/screens/pos_dashboard_screen.dart';
|
||||
import 'package:nearle_pos/presentation/sync/providers/sync_controller.dart';
|
||||
|
||||
/// Boots the real application widget.
|
||||
///
|
||||
/// The unit suite proves the money is right; this proves the thing actually
|
||||
/// assembles — router guard, provider graph, theme and shell — which type
|
||||
/// checking alone cannot tell you.
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
// Tests have no network, and an attempted font fetch throws.
|
||||
GoogleFonts.config.allowRuntimeFetching = false;
|
||||
LocalStore.registerSeed(
|
||||
products: SeedData.products,
|
||||
customers: SeedData.customers,
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
await LocalStore.instance.reset(withCatalogue: true);
|
||||
});
|
||||
|
||||
ShiftReport blankReport() => ShiftReport.blank(
|
||||
businessDate: DateTime(2026, 7, 31),
|
||||
terminalId: 'TERM-01',
|
||||
cashierName: 'Suriya',
|
||||
);
|
||||
|
||||
Future<void> bootApp(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
// Catalogue reads come from the in-memory cache and resolve on the
|
||||
// spot, but these four go to SQLite. Real disk I/O cannot be driven
|
||||
// by the fake clock a widget test runs on: sqflite's own lock-warning
|
||||
// timer is left pending and trips the binding's leak check. Stubbed
|
||||
// so this test measures rendering, which is what it is for.
|
||||
unsyncedCountProvider.overrideWith((ref) async => 0),
|
||||
parkedBillsProvider.overrideWith((ref) async => []),
|
||||
orderSyncRowsProvider.overrideWith((ref) async => []),
|
||||
todayReportProvider.overrideWith((ref) async => blankReport()),
|
||||
myShiftReportProvider.overrideWith((ref) async => blankReport()),
|
||||
],
|
||||
child: const NearlePosApp(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Pumps a fixed number of frames instead of settling.
|
||||
///
|
||||
/// Once past login the header subscribes to a periodic clock, so there is
|
||||
/// always another frame pending and `pumpAndSettle` never returns.
|
||||
Future<void> settle(WidgetTester tester, {int frames = 15}) async {
|
||||
for (var i = 0; i < frames; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> signIn(WidgetTester tester) async {
|
||||
final fields = find.byType(TextFormField);
|
||||
await tester.enterText(fields.first, DemoCredentials.email);
|
||||
await tester.enterText(fields.at(1), DemoCredentials.password);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Sign in').last);
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
testWidgets('the terminal starts on the login screen', (tester) async {
|
||||
await bootApp(tester);
|
||||
|
||||
expect(find.byType(TextFormField), findsWidgets);
|
||||
expect(find.text('Sign in'), findsWidgets);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('the auth guard keeps an unauthenticated terminal out',
|
||||
(tester) async {
|
||||
await bootApp(tester);
|
||||
|
||||
expect(find.byType(PosDashboardScreen), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('signing in reaches the billing terminal', (tester) async {
|
||||
await bootApp(tester);
|
||||
await signIn(tester);
|
||||
|
||||
expect(find.byType(PosDashboardScreen), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('the shell renders every module without throwing',
|
||||
(tester) async {
|
||||
// Wide enough that the sidebar shows labels and the bill stays docked —
|
||||
// the layout where a button label and its total compete for width.
|
||||
tester.view.physicalSize = const Size(1800, 1200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await bootApp(tester);
|
||||
await signIn(tester);
|
||||
|
||||
for (final label in [
|
||||
'Customers',
|
||||
'Product Import',
|
||||
'Promo',
|
||||
'Events',
|
||||
'Settings',
|
||||
'POS',
|
||||
]) {
|
||||
final target = find.text(label);
|
||||
expect(target, findsWidgets, reason: 'no sidebar entry for "$label"');
|
||||
|
||||
await tester.tap(target.first);
|
||||
await settle(tester);
|
||||
expect(tester.takeException(), isNull, reason: 'opening "$label" threw');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -58,7 +58,7 @@ void main() {
|
||||
label: 'CHARGE',
|
||||
onPressed: () {},
|
||||
trailing: const Text('\u20B9384.00'),
|
||||
)),
|
||||
),),
|
||||
);
|
||||
|
||||
expect(find.text('CHARGE'), findsOneWidget);
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:nearle_pos/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pump();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user