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:
Suriya
2026-07-31 18:34:10 +05:30
parent 9891a69a5f
commit af3933092f
62 changed files with 2035 additions and 561 deletions

View File

@@ -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),

View File

@@ -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;
}

View File

@@ -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,
),
]),
],),
),
);

View File

@@ -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(),
}),
},),
);
}

View File

@@ -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,

View File

@@ -29,7 +29,7 @@ extension BuildContextX on BuildContext {
content: Text(message),
backgroundColor: background,
duration: const Duration(seconds: 2),
));
),);
}
}

View File

@@ -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(

View File

@@ -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!,
],
],
);

View File

@@ -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 {

View File

@@ -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)
)
''';

View File

@@ -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;
});
}
}

View File

@@ -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,
);
}

View 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,
);
}
}

View File

@@ -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();
}
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 =>

View File

@@ -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);

View File

@@ -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

View File

@@ -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});

View File

@@ -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.');

View File

@@ -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(

View File

@@ -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,
),

View File

@@ -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(

View File

@@ -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(),
),
],

View File

@@ -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(),
),
),

View File

@@ -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 '

View File

@@ -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(

View File

@@ -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(),
),
);

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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),),
),
],
),

View File

@@ -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();
}

View File

@@ -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),

View File

@@ -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),
),
),
);

View File

@@ -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(

View File

@@ -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(

View File

@@ -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

View File

@@ -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,),
],
),
),

View File

@@ -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),
]),
],),
);
}

View File

@@ -108,7 +108,7 @@ class _Chip extends StatelessWidget {
),
),
],
]),
],),
),
),
),

View File

@@ -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),
),
),
]),
],),
);
}
}

View File

@@ -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,
),
),
]),
]),
],),
],),
),
);
}

View File

@@ -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 · '

View File

@@ -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,),
),
),
]),
],),
),
),
),

View File

@@ -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)

View File

@@ -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,
)),
]),
),),
],),
),
]),
],),
),
);
}

View File

@@ -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(

View File

@@ -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)),
]);
],);
}
}

View File

@@ -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);

View File

@@ -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),),
],
],