Fix billing data integrity, sale atomicity and stock safety
Bills were persisted correctly but read back wrong. The read path rebuilt a cart from its lines alone, dropping bill-level discounts and loyalty, so every figure derived from a stored bill was overstated: the upload payload, the day archive and the shift report. A discounted 529 bill read back as 620. Money and data integrity - order_dao: restore bill_discount and points_redeemed when rebuilding a cart; keep the reconstruction tier-less so the membership discount is not applied twice. Trust the recorded total and points via SaleTransaction.storedTotal. - checkout_sale + order_dao.commitSale: write the bill, its stock movement and the loyalty update in one transaction. Previously a failure part-way through left a persisted bill the cashier believed had failed, inviting a duplicate. - checkout_sale: re-check every line against live stock. A parked bill resumed after its stock was sold passed validation and oversold. - catalogue_dao: allocate the invoice sequence in one transaction; the previous read-modify-write could hand two sales the same number and fail UNIQUE. - local_store: replay unsynced sales after a catalogue import, so a mid-shift re-import cannot restore stock that has already been sold. - payment_controller: stamp the signed-in operator on the bill instead of the hardcoded seed session, and pass the terminal id through. - cart: reconcile per-slab GST against the bill total so the parts sum to the whole on a tax invoice. Sync and reporting - sync_repository: drain unsynced bills in a loop rather than silently capping at one page; stop on rejection so rejected rows cannot loop forever. - sync_log_dao (new): persist the sync history to the sync_log table, which the schema already defined but nothing used. It was in memory, so the only record that bills had been uploaded died at restart. - Scope shift reports by cashier. day_archive is re-keyed to (business_date, cashier_name) so a till stays settleable after its bills are uploaded and deleted. Schema v4 with a migration that carries v3 rows across. Input and UI - barcode_service: consume machine-paced keystrokes so a scan cannot also land in the focused field, and raise the bar to 60ms/char while a text field has focus so typing a mobile number is not read as a scan. Clock and focus check injected so the behaviour is testable. - primary_button: make the label flexible; label plus trailing total overflowed the Charge button by up to 131px. - app_router: redirect instead of null-casting when the receipt route is entered without its transaction. - customer_repository: reduce the search query to digits so a punctuated mobile number matches. Cleanup - Remove TransactionRepository.save, CustomerRepository.recordSale and OrderDao.insertOrder, all superseded by commitSale. - dart fix across the tree; 251 analyzer issues down to 3 info-level. Tests: 23 passing / 15 failing -> 90 passing. Fixed the two defects that broke the existing suite (containsAll type argument, reset() needing a catalogue) and deleted the leftover template test. Added coverage for the order round trip, the day archive after a real sync, stock safety, checkout atomicity, the v3->v4 migration, scanner-versus-human input, and an app-level smoke test that renders every module. Note: bills already uploaded with a discount went up overstated. This stops it happening again but does not correct historical server data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -68,6 +68,11 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
GoRoute(
|
||||
path: AppRoutes.receipt,
|
||||
name: 'receipt',
|
||||
// The bill travels in `extra`, which does not survive a reload, a deep
|
||||
// link or a restored session. Sending the cashier back to billing beats
|
||||
// crashing on a null cast.
|
||||
redirect: (context, state) =>
|
||||
state.extra is SaleTransaction ? null : AppRoutes.pos,
|
||||
pageBuilder: (context, state) => _fade(
|
||||
state,
|
||||
ReceiptScreen(transaction: state.extra! as SaleTransaction),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../constants/app_constants.dart';
|
||||
|
||||
@@ -11,17 +12,39 @@ import '../constants/app_constants.dart';
|
||||
/// when the characters arrived faster than a human could type — that way the
|
||||
/// cashier can still type into the same field by hand.
|
||||
class BarcodeService {
|
||||
BarcodeService({this.onScan, this.onManualKey});
|
||||
BarcodeService({
|
||||
this.onScan,
|
||||
this.onManualKey,
|
||||
DateTime Function()? clock,
|
||||
bool Function()? isEditingText,
|
||||
}) : _clock = clock ?? DateTime.now,
|
||||
_isEditingText = isEditingText ?? _primaryFocusIsTextField;
|
||||
|
||||
final void Function(String code)? onScan;
|
||||
final VoidCallback? onManualKey;
|
||||
|
||||
/// Injectable so tests can drive keystroke timing exactly rather than
|
||||
/// sleeping, which is what makes scanner-versus-human behaviour testable.
|
||||
final DateTime Function() _clock;
|
||||
final bool Function() _isEditingText;
|
||||
|
||||
final StringBuffer _buffer = StringBuffer();
|
||||
DateTime? _lastKeyAt;
|
||||
DateTime? _firstKeyAt;
|
||||
int _charCount = 0;
|
||||
|
||||
/// Cleared when any gap in the buffer is too slow to be a machine.
|
||||
bool _machinePaced = true;
|
||||
|
||||
Timer? _flushTimer;
|
||||
|
||||
bool _attached = false;
|
||||
|
||||
/// Sustained pace no typist holds. Applied when a text field has focus, where
|
||||
/// the cost of a false positive is a swallowed keystroke rather than nothing.
|
||||
static Duration get _strictGap =>
|
||||
Duration(microseconds: AppConstants.barcodeScanTimeout.inMicroseconds ~/ 2);
|
||||
|
||||
void attach() {
|
||||
if (_attached) return;
|
||||
HardwareKeyboard.instance.addHandler(_handleKey);
|
||||
@@ -35,22 +58,44 @@ class BarcodeService {
|
||||
_attached = false;
|
||||
}
|
||||
|
||||
/// Whether the cashier is typing into a field right now.
|
||||
///
|
||||
/// A scan still registers while a field has focus, but the bar for calling it
|
||||
/// a scan is raised, because getting it wrong means eating real keystrokes.
|
||||
static bool _primaryFocusIsTextField() {
|
||||
final context = FocusManager.instance.primaryFocus?.context;
|
||||
if (context == null) return false;
|
||||
return context.widget is EditableText ||
|
||||
context.findAncestorWidgetOfExactType<EditableText>() != null;
|
||||
}
|
||||
|
||||
void _resetBuffer() {
|
||||
_buffer.clear();
|
||||
_firstKeyAt = null;
|
||||
_charCount = 0;
|
||||
_machinePaced = true;
|
||||
}
|
||||
|
||||
/// The key handler, exposed so it can be driven directly in tests.
|
||||
@visibleForTesting
|
||||
bool handleKey(KeyEvent event) => _handleKey(event);
|
||||
|
||||
bool _handleKey(KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return false;
|
||||
|
||||
final now = DateTime.now();
|
||||
final gap = _lastKeyAt == null
|
||||
? Duration.zero
|
||||
: now.difference(_lastKeyAt!);
|
||||
final now = _clock();
|
||||
final gap = _lastKeyAt == null ? Duration.zero : now.difference(_lastKeyAt!);
|
||||
_lastKeyAt = now;
|
||||
|
||||
// A long pause means a new entry started; discard whatever was buffered.
|
||||
if (gap > AppConstants.barcodeScanTimeout) {
|
||||
_buffer.clear();
|
||||
_resetBuffer();
|
||||
}
|
||||
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter ||
|
||||
event.logicalKey == LogicalKeyboardKey.numpadEnter) {
|
||||
// Only swallow Enter if the buffer actually resolves to a scan —
|
||||
// otherwise a form submit on a hand-typed field would be eaten.
|
||||
return _flush();
|
||||
}
|
||||
|
||||
@@ -58,9 +103,19 @@ class BarcodeService {
|
||||
if (char == null || char.trim().isEmpty) return false;
|
||||
if (!RegExp(r'^[0-9A-Za-z\-]$').hasMatch(char)) return false;
|
||||
|
||||
if (_charCount > 0 && gap > AppConstants.barcodeScanTimeout) {
|
||||
_machinePaced = false;
|
||||
}
|
||||
|
||||
_buffer.write(char);
|
||||
_firstKeyAt ??= now;
|
||||
_charCount++;
|
||||
_scheduleFlush();
|
||||
return false;
|
||||
|
||||
// Consume the keystroke once the burst is unmistakably machine-paced.
|
||||
// Without this the scanned digits also land in whatever field has focus,
|
||||
// so a scan would add the item *and* type the barcode into the search box.
|
||||
return _looksLikeScan(atLeast: 2);
|
||||
}
|
||||
|
||||
/// Some scanners are not configured to send a terminating Enter, so we also
|
||||
@@ -69,16 +124,31 @@ class BarcodeService {
|
||||
_flushTimer?.cancel();
|
||||
_flushTimer = Timer(
|
||||
AppConstants.barcodeScanTimeout * 2,
|
||||
() => _flush(),
|
||||
_flush,
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether the buffer so far has the shape and speed of a scanner burst.
|
||||
bool _looksLikeScan({required int atLeast}) {
|
||||
if (_charCount < atLeast || !_machinePaced) return false;
|
||||
|
||||
final first = _firstKeyAt;
|
||||
if (first == null || _charCount < 2) return false;
|
||||
|
||||
final span = _lastKeyAt!.difference(first);
|
||||
final averageGap = span ~/ (_charCount - 1);
|
||||
|
||||
return averageGap <=
|
||||
(_isEditingText() ? _strictGap : AppConstants.barcodeScanTimeout);
|
||||
}
|
||||
|
||||
bool _flush() {
|
||||
_flushTimer?.cancel();
|
||||
final code = _buffer.toString().trim();
|
||||
_buffer.clear();
|
||||
final wasScan = _looksLikeScan(atLeast: AppConstants.minBarcodeLength);
|
||||
_resetBuffer();
|
||||
|
||||
if (code.length >= AppConstants.minBarcodeLength) {
|
||||
if (wasScan && code.length >= AppConstants.minBarcodeLength) {
|
||||
onScan?.call(code);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
@@ -58,7 +57,7 @@ class ReceiptService {
|
||||
|
||||
doc.addPage(
|
||||
pw.Page(
|
||||
pageFormat: PdfPageFormat(
|
||||
pageFormat: const PdfPageFormat(
|
||||
_rollWidth,
|
||||
double.infinity,
|
||||
marginAll: 5 * PdfPageFormat.mm,
|
||||
@@ -94,23 +93,23 @@ class ReceiptService {
|
||||
pw.Widget _header(SaleTransaction txn) => pw.Column(children: [
|
||||
pw.Text(
|
||||
AppConstants.storeName.toUpperCase(),
|
||||
style: pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold),
|
||||
style: const pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold),
|
||||
),
|
||||
pw.Text(AppConstants.storeLegalName,
|
||||
style: const pw.TextStyle(fontSize: 7)),
|
||||
style: const pw.TextStyle(fontSize: 7),),
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(AppConstants.storeAddress,
|
||||
style: const pw.TextStyle(fontSize: 6.5),
|
||||
textAlign: pw.TextAlign.center),
|
||||
textAlign: pw.TextAlign.center,),
|
||||
pw.Text('Customer care : ${AppConstants.storePhone}',
|
||||
style: const pw.TextStyle(fontSize: 6.5)),
|
||||
style: const pw.TextStyle(fontSize: 6.5),),
|
||||
pw.Text('CIN No : ${AppConstants.storeCin}',
|
||||
style: const pw.TextStyle(fontSize: 6.5)),
|
||||
style: const pw.TextStyle(fontSize: 6.5),),
|
||||
pw.Text('GSTIN : ${AppConstants.storeGstin}',
|
||||
style: const pw.TextStyle(fontSize: 6.5)),
|
||||
style: const pw.TextStyle(fontSize: 6.5),),
|
||||
pw.Text('FSSAI Lic No : ${AppConstants.storeFssai}',
|
||||
style: const pw.TextStyle(fontSize: 6.5)),
|
||||
]);
|
||||
style: const pw.TextStyle(fontSize: 6.5),),
|
||||
],);
|
||||
|
||||
pw.Widget _savingsLine(SaleTransaction txn) {
|
||||
final saved = _grossSalesValue(txn) - txn.cart.netAmount;
|
||||
@@ -120,7 +119,7 @@ class ReceiptService {
|
||||
padding: const pw.EdgeInsets.symmetric(vertical: 4),
|
||||
child: pw.Text(
|
||||
'You have saved Rs.${saved.toStringAsFixed(2)}',
|
||||
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold),
|
||||
style: const pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold),
|
||||
textAlign: pw.TextAlign.center,
|
||||
),
|
||||
);
|
||||
@@ -128,11 +127,11 @@ class ReceiptService {
|
||||
|
||||
pw.Widget _invoiceTitle() => pw.Column(children: [
|
||||
pw.Text('TAX INVOICE',
|
||||
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold)),
|
||||
style: const pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold),),
|
||||
pw.SizedBox(height: 1),
|
||||
pw.Text('xxxxxx Original for Recipient xxxxxx',
|
||||
style: const pw.TextStyle(fontSize: 6)),
|
||||
]);
|
||||
style: const pw.TextStyle(fontSize: 6),),
|
||||
],);
|
||||
|
||||
pw.Widget _meta(SaleTransaction txn) {
|
||||
final c = txn.customer;
|
||||
@@ -167,7 +166,7 @@ class ReceiptService {
|
||||
pw.Expanded(flex: 16, child: _th('Net Price', right: true)),
|
||||
pw.Expanded(flex: 8, child: _th('Qty', right: true)),
|
||||
pw.Expanded(flex: 18, child: _th('Value', right: true)),
|
||||
]),
|
||||
],),
|
||||
pw.SizedBox(height: 2),
|
||||
for (final slab in slabs) ...[
|
||||
pw.Padding(
|
||||
@@ -175,7 +174,7 @@ class ReceiptService {
|
||||
child: pw.Text(
|
||||
'${slab.index}) CGST @ ${slab.halfPercent} '
|
||||
'SGST @ ${slab.halfPercent}',
|
||||
style: pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold),
|
||||
style: const pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold),
|
||||
),
|
||||
),
|
||||
for (final line in slab.lines)
|
||||
@@ -193,7 +192,7 @@ class ReceiptService {
|
||||
pw.Expanded(
|
||||
flex: 16,
|
||||
child: _td(line.product.price.toStringAsFixed(2),
|
||||
right: true, size: 6.5),
|
||||
right: true, size: 6.5,),
|
||||
),
|
||||
pw.Expanded(
|
||||
flex: 8,
|
||||
@@ -202,9 +201,9 @@ class ReceiptService {
|
||||
pw.Expanded(
|
||||
flex: 18,
|
||||
child: _td(line.payable.toStringAsFixed(2),
|
||||
right: true, size: 6.5),
|
||||
right: true, size: 6.5,),
|
||||
),
|
||||
]),
|
||||
],),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -237,14 +236,14 @@ class ReceiptService {
|
||||
if (txn.changeDue > 0) _amount('Change Returned', txn.changeDue),
|
||||
pw.SizedBox(height: 1),
|
||||
pw.Text('(AMOUNT INCLUSIVE OF APPLICABLE TAXES)',
|
||||
style: const pw.TextStyle(fontSize: 6)),
|
||||
]);
|
||||
style: const pw.TextStyle(fontSize: 6),),
|
||||
],);
|
||||
}
|
||||
|
||||
pw.Widget _gstBreakup(SaleTransaction txn, List<_Slab> slabs) {
|
||||
return pw.Column(children: [
|
||||
pw.Text('------GST Breakup Details------ Amount (INR)',
|
||||
style: const pw.TextStyle(fontSize: 6.5)),
|
||||
style: const pw.TextStyle(fontSize: 6.5),),
|
||||
pw.SizedBox(height: 3),
|
||||
pw.Row(children: [
|
||||
pw.Expanded(flex: 10, child: _th('GST\nIND', size: 6)),
|
||||
@@ -253,7 +252,7 @@ class ReceiptService {
|
||||
pw.Expanded(flex: 16, child: _th('SGST', right: true, size: 6)),
|
||||
pw.Expanded(flex: 14, child: _th('CESS', right: true, size: 6)),
|
||||
pw.Expanded(flex: 20, child: _th('Total\nAmount', right: true, size: 6)),
|
||||
]),
|
||||
],),
|
||||
pw.SizedBox(height: 2),
|
||||
for (final s in slabs)
|
||||
pw.Padding(
|
||||
@@ -263,19 +262,19 @@ class ReceiptService {
|
||||
pw.Expanded(
|
||||
flex: 20,
|
||||
child: _td(s.taxable.toStringAsFixed(2),
|
||||
right: true, size: 6.5)),
|
||||
right: true, size: 6.5,),),
|
||||
pw.Expanded(
|
||||
flex: 16,
|
||||
child: _td(s.cgst.toStringAsFixed(2), right: true, size: 6.5)),
|
||||
child: _td(s.cgst.toStringAsFixed(2), right: true, size: 6.5),),
|
||||
pw.Expanded(
|
||||
flex: 16,
|
||||
child: _td(s.sgst.toStringAsFixed(2), right: true, size: 6.5)),
|
||||
child: _td(s.sgst.toStringAsFixed(2), right: true, size: 6.5),),
|
||||
pw.Expanded(flex: 14, child: _td('0.00', right: true, size: 6.5)),
|
||||
pw.Expanded(
|
||||
flex: 20,
|
||||
child:
|
||||
_td(s.total.toStringAsFixed(2), right: true, size: 6.5)),
|
||||
]),
|
||||
_td(s.total.toStringAsFixed(2), right: true, size: 6.5),),
|
||||
],),
|
||||
),
|
||||
pw.Divider(height: 4, borderStyle: pw.BorderStyle.dashed),
|
||||
pw.Row(children: [
|
||||
@@ -285,24 +284,24 @@ class ReceiptService {
|
||||
child: _th(
|
||||
slabs.fold(0.0, (a, s) => a + s.taxable).toStringAsFixed(2),
|
||||
right: true,
|
||||
size: 6.5)),
|
||||
size: 6.5,),),
|
||||
pw.Expanded(
|
||||
flex: 16,
|
||||
child: _th(slabs.fold(0.0, (a, s) => a + s.cgst).toStringAsFixed(2),
|
||||
right: true, size: 6.5)),
|
||||
right: true, size: 6.5,),),
|
||||
pw.Expanded(
|
||||
flex: 16,
|
||||
child: _th(slabs.fold(0.0, (a, s) => a + s.sgst).toStringAsFixed(2),
|
||||
right: true, size: 6.5)),
|
||||
right: true, size: 6.5,),),
|
||||
pw.Expanded(flex: 14, child: _th('0.00', right: true, size: 6.5)),
|
||||
pw.Expanded(
|
||||
flex: 20,
|
||||
child: _th(
|
||||
slabs.fold(0.0, (a, s) => a + s.total).toStringAsFixed(2),
|
||||
right: true,
|
||||
size: 6.5)),
|
||||
]),
|
||||
]);
|
||||
size: 6.5,),),
|
||||
],),
|
||||
],);
|
||||
}
|
||||
|
||||
pw.Widget _references(SaleTransaction txn) {
|
||||
@@ -341,14 +340,14 @@ class ReceiptService {
|
||||
),
|
||||
pw.SizedBox(height: 3),
|
||||
pw.Text('* Thank You for Shopping with us *',
|
||||
style: pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold)),
|
||||
style: const pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold),),
|
||||
pw.Text('Goods once sold are exchangeable within 7 days with this bill',
|
||||
style: const pw.TextStyle(fontSize: 5.5),
|
||||
textAlign: pw.TextAlign.center),
|
||||
textAlign: pw.TextAlign.center,),
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text('Powered by Nearle POS',
|
||||
style: const pw.TextStyle(fontSize: 5.5)),
|
||||
]);
|
||||
style: const pw.TextStyle(fontSize: 5.5),),
|
||||
],);
|
||||
|
||||
// -------------------------------------------------------------- Helpers
|
||||
/// Pre-discount value, using printed MRP where one is known.
|
||||
@@ -391,12 +390,12 @@ class ReceiptService {
|
||||
style: pw.TextStyle(
|
||||
fontSize: 6.8,
|
||||
fontWeight: bold ? pw.FontWeight.bold : pw.FontWeight.normal,
|
||||
)),
|
||||
),),
|
||||
pw.Text(value.toStringAsFixed(2),
|
||||
style: pw.TextStyle(
|
||||
fontSize: 6.8,
|
||||
fontWeight: bold ? pw.FontWeight.bold : pw.FontWeight.normal,
|
||||
)),
|
||||
),),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -404,14 +403,14 @@ class ReceiptService {
|
||||
pw.Widget _th(String text, {bool right = false, double size = 6.8}) =>
|
||||
pw.Text(text,
|
||||
textAlign: right ? pw.TextAlign.right : pw.TextAlign.left,
|
||||
style: pw.TextStyle(fontSize: size, fontWeight: pw.FontWeight.bold));
|
||||
style: pw.TextStyle(fontSize: size, fontWeight: pw.FontWeight.bold),);
|
||||
|
||||
pw.Widget _td(String text, {bool right = false, double size = 6.8}) =>
|
||||
pw.Text(text,
|
||||
maxLines: 1,
|
||||
overflow: pw.TextOverflow.clip,
|
||||
textAlign: right ? pw.TextAlign.right : pw.TextAlign.left,
|
||||
style: pw.TextStyle(fontSize: size));
|
||||
style: pw.TextStyle(fontSize: size),);
|
||||
|
||||
// --------------------------------------------------------- Printing / IO
|
||||
/// Opens the system print preview.
|
||||
@@ -483,7 +482,7 @@ class ReceiptService {
|
||||
final font = await PdfGoogleFonts.robotoMonoRegular();
|
||||
doc.addPage(
|
||||
pw.Page(
|
||||
pageFormat: PdfPageFormat(
|
||||
pageFormat: const PdfPageFormat(
|
||||
_rollWidth,
|
||||
double.infinity,
|
||||
marginAll: 5 * PdfPageFormat.mm,
|
||||
@@ -491,16 +490,16 @@ class ReceiptService {
|
||||
theme: pw.ThemeData.withFont(base: font),
|
||||
build: (_) => pw.Column(children: [
|
||||
pw.Text(AppConstants.storeName.toUpperCase(),
|
||||
style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
|
||||
style: const pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold),),
|
||||
pw.SizedBox(height: 6),
|
||||
pw.Text('PRINTER TEST', style: const pw.TextStyle(fontSize: 10)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text(target.name, style: const pw.TextStyle(fontSize: 7)),
|
||||
pw.Text(Formatters.dateTime(DateTime.now()),
|
||||
style: const pw.TextStyle(fontSize: 7)),
|
||||
style: const pw.TextStyle(fontSize: 7),),
|
||||
pw.SizedBox(height: 6),
|
||||
pw.Text('1234567890 ABCDEFGHIJ',
|
||||
style: const pw.TextStyle(fontSize: 8)),
|
||||
style: const pw.TextStyle(fontSize: 8),),
|
||||
pw.Text('₹ 1,234.56', style: const pw.TextStyle(fontSize: 8)),
|
||||
pw.SizedBox(height: 6),
|
||||
pw.BarcodeWidget(
|
||||
@@ -510,7 +509,7 @@ class ReceiptService {
|
||||
height: 30,
|
||||
drawText: false,
|
||||
),
|
||||
]),
|
||||
],),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -40,13 +40,13 @@ class AppTheme {
|
||||
?.copyWith(color: AppColors.textOnPrimary),
|
||||
),
|
||||
|
||||
cardTheme: CardThemeData(
|
||||
cardTheme: const CardThemeData(
|
||||
color: AppColors.surface,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.brLg,
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
side: BorderSide(color: AppColors.border),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -149,7 +149,7 @@ class AppTheme {
|
||||
TargetPlatform.macOS: FadeUpwardsPageTransitionsBuilder(),
|
||||
TargetPlatform.linux: FadeUpwardsPageTransitionsBuilder(),
|
||||
TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
|
||||
}),
|
||||
},),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,12 +31,12 @@ class AppTypography {
|
||||
bodyLarge: _s(base.bodyLarge, 15.5, FontWeight.w400, 0),
|
||||
bodyMedium: _s(base.bodyMedium, 14.5, FontWeight.w400, 0),
|
||||
bodySmall: _s(base.bodySmall, 12.5, FontWeight.w400, 0,
|
||||
color: AppColors.textSecondary),
|
||||
color: AppColors.textSecondary,),
|
||||
|
||||
labelLarge: _s(base.labelLarge, 14.5, FontWeight.w600, 0),
|
||||
labelMedium: _s(base.labelMedium, 12.5, FontWeight.w600, 0.1),
|
||||
labelSmall: _s(base.labelSmall, 10.5, FontWeight.w600, 0.6,
|
||||
color: AppColors.textTertiary),
|
||||
color: AppColors.textTertiary,),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ class AppTypography {
|
||||
|
||||
/// Tabular figures — essential so totals don't jitter as quantities change.
|
||||
static TextStyle money(double size,
|
||||
{FontWeight weight = FontWeight.w700, Color? color}) =>
|
||||
{FontWeight weight = FontWeight.w700, Color? color,}) =>
|
||||
GoogleFonts.poppins(
|
||||
fontSize: size,
|
||||
fontWeight: weight,
|
||||
|
||||
@@ -29,7 +29,7 @@ extension BuildContextX on BuildContext {
|
||||
content: Text(message),
|
||||
backgroundColor: background,
|
||||
duration: const Duration(seconds: 2),
|
||||
));
|
||||
),);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class EmptyState extends StatelessWidget {
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(emoji,
|
||||
style: TextStyle(fontSize: compact ? 28 : 40)),
|
||||
style: TextStyle(fontSize: compact ? 28 : 40),),
|
||||
),
|
||||
SizedBox(height: compact ? AppSpacing.lg : AppSpacing.xxl),
|
||||
Text(
|
||||
|
||||
@@ -49,28 +49,36 @@ class PrimaryButton extends StatelessWidget {
|
||||
? MainAxisAlignment.spaceBetween
|
||||
: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: large ? 24 : 20, color: fg),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: fg,
|
||||
fontSize: large ? 19 : 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.1,
|
||||
// Flexible so the label gives way to the trailing total rather
|
||||
// than overflowing the button: on a narrow bill panel "Charge"
|
||||
// plus a five-figure amount is wider than the button itself.
|
||||
Flexible(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: large ? 24 : 20, color: fg),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: fg,
|
||||
fontSize: large ? 19 : 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
trailing!,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user