Fix billing data integrity, sale atomicity and stock safety
Bills were persisted correctly but read back wrong. The read path rebuilt a cart from its lines alone, dropping bill-level discounts and loyalty, so every figure derived from a stored bill was overstated: the upload payload, the day archive and the shift report. A discounted 529 bill read back as 620. Money and data integrity - order_dao: restore bill_discount and points_redeemed when rebuilding a cart; keep the reconstruction tier-less so the membership discount is not applied twice. Trust the recorded total and points via SaleTransaction.storedTotal. - checkout_sale + order_dao.commitSale: write the bill, its stock movement and the loyalty update in one transaction. Previously a failure part-way through left a persisted bill the cashier believed had failed, inviting a duplicate. - checkout_sale: re-check every line against live stock. A parked bill resumed after its stock was sold passed validation and oversold. - catalogue_dao: allocate the invoice sequence in one transaction; the previous read-modify-write could hand two sales the same number and fail UNIQUE. - local_store: replay unsynced sales after a catalogue import, so a mid-shift re-import cannot restore stock that has already been sold. - payment_controller: stamp the signed-in operator on the bill instead of the hardcoded seed session, and pass the terminal id through. - cart: reconcile per-slab GST against the bill total so the parts sum to the whole on a tax invoice. Sync and reporting - sync_repository: drain unsynced bills in a loop rather than silently capping at one page; stop on rejection so rejected rows cannot loop forever. - sync_log_dao (new): persist the sync history to the sync_log table, which the schema already defined but nothing used. It was in memory, so the only record that bills had been uploaded died at restart. - Scope shift reports by cashier. day_archive is re-keyed to (business_date, cashier_name) so a till stays settleable after its bills are uploaded and deleted. Schema v4 with a migration that carries v3 rows across. Input and UI - barcode_service: consume machine-paced keystrokes so a scan cannot also land in the focused field, and raise the bar to 60ms/char while a text field has focus so typing a mobile number is not read as a scan. Clock and focus check injected so the behaviour is testable. - primary_button: make the label flexible; label plus trailing total overflowed the Charge button by up to 131px. - app_router: redirect instead of null-casting when the receipt route is entered without its transaction. - customer_repository: reduce the search query to digits so a punctuated mobile number matches. Cleanup - Remove TransactionRepository.save, CustomerRepository.recordSale and OrderDao.insertOrder, all superseded by commitSale. - dart fix across the tree; 251 analyzer issues down to 3 info-level. Tests: 23 passing / 15 failing -> 90 passing. Fixed the two defects that broke the existing suite (containsAll type argument, reset() needing a catalogue) and deleted the leftover template test. Added coverage for the order round trip, the day archive after a real sync, stock safety, checkout atomicity, the v3->v4 migration, scanner-versus-human input, and an app-level smoke test that renders every module. Note: bills already uploaded with a discount went up overstated. This stops it happening again but does not correct historical server data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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,28 +49,36 @@ class PrimaryButton extends StatelessWidget {
|
||||
? MainAxisAlignment.spaceBetween
|
||||
: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: large ? 24 : 20, color: fg),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: fg,
|
||||
fontSize: large ? 19 : 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.1,
|
||||
// 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) ...[
|
||||
Icon(icon, size: large ? 24 : 20, color: fg),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: fg,
|
||||
fontSize: large ? 19 : 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
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;
|
||||
final next = current + 1;
|
||||
await setMeta(MetaKeys.invoiceSequence, '$next');
|
||||
return next;
|
||||
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 txn.insert(
|
||||
Tables.meta,
|
||||
{'key': MetaKeys.invoiceSequence, 'value': '$next'},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,76 +28,127 @@ class OrderDao {
|
||||
'${dt.day.toString().padLeft(2, '0')}';
|
||||
|
||||
// ----------------------------------------------------------------- Write
|
||||
/// Writes the bill and its lines atomically.
|
||||
Future<void> insertOrder(SaleTransaction t) async {
|
||||
final cart = t.cart;
|
||||
|
||||
/// 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 txn.insert(
|
||||
Tables.orders,
|
||||
{
|
||||
'id': t.id,
|
||||
'invoice_number': t.invoiceNumber,
|
||||
'created_at': t.createdAt.millisecondsSinceEpoch,
|
||||
'business_date': businessDateOf(t.createdAt),
|
||||
'cashier_name': t.cashierName,
|
||||
'terminal_id': t.terminalId,
|
||||
'customer_id': cart.customer?.id,
|
||||
'customer_mobile': cart.customer?.mobile,
|
||||
'customer_name': cart.customer?.name,
|
||||
'subtotal': cart.subtotal,
|
||||
'line_discount': cart.lineDiscountTotal,
|
||||
'bill_discount': cart.billDiscountTotal,
|
||||
'loyalty_value': cart.loyaltyRedemptionValue,
|
||||
'taxable_amount': cart.taxableAmount,
|
||||
'tax_amount': cart.taxAmount,
|
||||
'round_off': cart.roundOff,
|
||||
'total': t.total,
|
||||
'points_earned': cart.pointsEarned,
|
||||
'points_redeemed': cart.pointsRedeemed,
|
||||
'payments_json': jsonEncode([
|
||||
for (final p in t.payments)
|
||||
{
|
||||
'method': p.method.name,
|
||||
'amount': p.amount,
|
||||
'tendered': p.tendered,
|
||||
'reference': p.reference,
|
||||
},
|
||||
]),
|
||||
'status': t.status.name,
|
||||
'sync_status': pending,
|
||||
'sync_attempts': 0,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
await _insertOrder(txn, transaction);
|
||||
|
||||
final batch = txn.batch();
|
||||
for (final line in cart.lines) {
|
||||
batch.insert(Tables.orderItems, {
|
||||
'order_id': t.id,
|
||||
'product_id': line.product.id,
|
||||
'name': line.product.name,
|
||||
'barcode': line.product.barcode,
|
||||
'sku': line.product.sku,
|
||||
'unit': line.product.unit.name,
|
||||
'unit_price': line.product.price,
|
||||
'quantity': line.quantity,
|
||||
'discount': line.discountAmount,
|
||||
'gst_rate': line.product.gstRate,
|
||||
'tax_amount': line.taxAmount,
|
||||
'line_total': line.payable,
|
||||
});
|
||||
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;
|
||||
|
||||
// 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,
|
||||
{
|
||||
'id': t.id,
|
||||
'invoice_number': t.invoiceNumber,
|
||||
'created_at': t.createdAt.millisecondsSinceEpoch,
|
||||
'business_date': businessDateOf(t.createdAt),
|
||||
'cashier_name': t.cashierName,
|
||||
'terminal_id': t.terminalId,
|
||||
'customer_id': cart.customer?.id,
|
||||
'customer_mobile': cart.customer?.mobile,
|
||||
'customer_name': cart.customer?.name,
|
||||
'subtotal': cart.subtotal,
|
||||
'line_discount': cart.lineDiscountTotal,
|
||||
'bill_discount': cart.billDiscountTotal,
|
||||
'loyalty_value': cart.loyaltyRedemptionValue,
|
||||
'taxable_amount': cart.taxableAmount,
|
||||
'tax_amount': cart.taxAmount,
|
||||
'round_off': cart.roundOff,
|
||||
'total': t.total,
|
||||
'points_earned': t.pointsEarned,
|
||||
'points_redeemed': cart.pointsRedeemed,
|
||||
'payments_json': jsonEncode([
|
||||
for (final p in t.payments)
|
||||
{
|
||||
'method': p.method.name,
|
||||
'amount': p.amount,
|
||||
'tendered': p.tendered,
|
||||
'reference': p.reference,
|
||||
},
|
||||
]),
|
||||
'status': t.status.name,
|
||||
'sync_status': pending,
|
||||
'sync_attempts': 0,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
|
||||
final batch = txn.batch();
|
||||
for (final line in cart.lines) {
|
||||
batch.insert(Tables.orderItems, {
|
||||
'order_id': t.id,
|
||||
'product_id': line.product.id,
|
||||
'name': line.product.name,
|
||||
'barcode': line.product.barcode,
|
||||
'sku': line.product.sku,
|
||||
'unit': line.product.unit.name,
|
||||
'unit_price': line.product.price,
|
||||
'quantity': line.quantity,
|
||||
'discount': line.discountAmount,
|
||||
'gst_rate': line.product.gstRate,
|
||||
'tax_amount': line.taxAmount,
|
||||
'line_total': line.payable,
|
||||
});
|
||||
}
|
||||
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(
|
||||
Tables.dayArchive,
|
||||
where: 'business_date = ?',
|
||||
whereArgs: [businessDateOf(day)],
|
||||
limit: 1,
|
||||
);
|
||||
return rows.isEmpty ? null : rows.first;
|
||||
}
|
||||
/// 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: cashierName == null
|
||||
? 'business_date = ?'
|
||||
: 'business_date = ? AND cashier_name = ?',
|
||||
whereArgs: [
|
||||
businessDateOf(day),
|
||||
if (cashierName != null) cashierName,
|
||||
],
|
||||
);
|
||||
|
||||
/// 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,39 +98,45 @@ 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);
|
||||
|
||||
final payments = (jsonDecode(row['payments_json']! as String)
|
||||
as Map<String, Object?>)
|
||||
.map(
|
||||
(k, v) => MapEntry(
|
||||
PaymentMethod.values.byName(k),
|
||||
(v! as num).toDouble(),
|
||||
),
|
||||
);
|
||||
for (final row in rows) {
|
||||
final payments = (jsonDecode(row['payments_json']! as String)
|
||||
as Map<String, Object?>)
|
||||
.map(
|
||||
(k, v) => MapEntry(
|
||||
PaymentMethod.values.byName(k),
|
||||
(v! as num).toDouble(),
|
||||
),
|
||||
);
|
||||
|
||||
final archived = ShiftReport.fromArchive(
|
||||
row: row,
|
||||
payments: payments,
|
||||
businessDate: today,
|
||||
terminalId: terminalId,
|
||||
cashierName: cashierName,
|
||||
);
|
||||
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,71 +166,99 @@ 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;
|
||||
|
||||
try {
|
||||
final accepted = await _orderSink.pushOrders(
|
||||
pending.map(_orderToPayload).toList(),
|
||||
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…',
|
||||
);
|
||||
|
||||
onProgress?.call(0.85, 'Clearing uploaded bills from this terminal…');
|
||||
try {
|
||||
final accepted = await _orderSink.pushOrders(
|
||||
batch.map(_orderToPayload).toList(),
|
||||
);
|
||||
|
||||
// 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();
|
||||
await _store.orders.archiveAndDelete(acceptedOrders);
|
||||
await _store.refreshUnsyncedCount();
|
||||
// Only what the server confirmed is archived and removed. Anything it
|
||||
// did not acknowledge stays on disk.
|
||||
final acceptedOrders =
|
||||
batch.where((o) => accepted.contains(o.id)).toList();
|
||||
await _store.orders.archiveAndDelete(acceptedOrders);
|
||||
await _store.refreshUnsyncedCount();
|
||||
|
||||
final rejected = ids.where((id) => !accepted.contains(id)).toList();
|
||||
if (rejected.isNotEmpty) {
|
||||
await _store.orders.markFailed(rejected, 'Rejected by server');
|
||||
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;
|
||||
}
|
||||
} catch (e) {
|
||||
// Transport failed: record the attempt but leave every row at 0.
|
||||
await _store.orders.markFailed(ids, e.toString());
|
||||
await _store.refreshUnsyncedCount();
|
||||
|
||||
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: '$remaining bills still pending '
|
||||
'(${Formatters.money(pendingValue)})',
|
||||
error: e.toString(),
|
||||
attempts: 1,
|
||||
),);
|
||||
|
||||
return SyncOutcome(
|
||||
attempted: attempted,
|
||||
uploaded: uploaded,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
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))})',
|
||||
error: e.toString(),
|
||||
attempts: 1,
|
||||
));
|
||||
|
||||
return SyncOutcome(
|
||||
attempted: pending.length,
|
||||
uploaded: 0,
|
||||
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.
|
||||
|
||||
@@ -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({
|
||||
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,
|
||||
);
|
||||
}
|
||||
await _transactions.commitSale(
|
||||
transaction: transaction,
|
||||
stockMovements: {
|
||||
for (final line in cart.lines) line.product.id: line.quantity,
|
||||
},
|
||||
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),),
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
Reference in New Issue
Block a user