second commit
This commit is contained in:
49
lib/core/constants/app_constants.dart
Normal file
49
lib/core/constants/app_constants.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
/// Application-wide configuration constants.
|
||||
class AppConstants {
|
||||
const AppConstants._();
|
||||
|
||||
static const String appName = 'Nearle POS';
|
||||
static const String storeName = 'Nearle Daily';
|
||||
static const String storeAddress = '12 Gandhipuram Main Rd, Coimbatore 641012';
|
||||
static const String storeGstin = '33ABCDE1234F1Z5';
|
||||
static const String storePhone = '+91 90000 12345';
|
||||
static const String currencySymbol = '\u20B9';
|
||||
static const String locale = 'en_IN';
|
||||
|
||||
/// Standard GST slab applied when a product does not declare its own.
|
||||
static const double defaultGstRate = 0.18;
|
||||
|
||||
/// One loyalty point is earned per this many rupees of net sale value.
|
||||
static const double loyaltyRupeesPerPoint = 10;
|
||||
|
||||
/// Rupee value of a single loyalty point when redeemed.
|
||||
static const double loyaltyPointValue = 0.25;
|
||||
|
||||
static const int mobileNumberLength = 10;
|
||||
|
||||
/// Barcode scanners emit keystrokes fast; anything slower is human typing.
|
||||
static const Duration barcodeScanTimeout = Duration(milliseconds: 120);
|
||||
static const int minBarcodeLength = 6;
|
||||
|
||||
/// Idle time after a completed sale before the terminal resets itself.
|
||||
static const Duration postSaleResetDelay = Duration(seconds: 3);
|
||||
|
||||
static const int lowStockThreshold = 10;
|
||||
static const int maxParkedBills = 20;
|
||||
static const int maxCartQuantityPerLine = 999;
|
||||
}
|
||||
|
||||
/// Hive box names.
|
||||
class StorageKeys {
|
||||
const StorageKeys._();
|
||||
|
||||
static const String products = 'box_products';
|
||||
static const String customers = 'box_customers';
|
||||
static const String transactions = 'box_transactions';
|
||||
static const String parkedBills = 'box_parked_bills';
|
||||
static const String settings = 'box_settings';
|
||||
|
||||
static const String cashierName = 'cashier_name';
|
||||
static const String cashierRole = 'cashier_role';
|
||||
static const String terminalId = 'terminal_id';
|
||||
}
|
||||
13
lib/core/constants/asset_paths.dart
Normal file
13
lib/core/constants/asset_paths.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
/// Typed references to bundled assets.
|
||||
///
|
||||
/// Only sounds are bundled: product imagery uses emoji glyphs and the welcome
|
||||
/// artwork is painted in code, so there are no raster or SVG assets to ship.
|
||||
class AssetPaths {
|
||||
const AssetPaths._();
|
||||
|
||||
static const String _snd = 'assets/sounds';
|
||||
|
||||
static const String beepSuccess = '$_snd/beep_success.wav';
|
||||
static const String beepError = '$_snd/beep_error.wav';
|
||||
static const String chargeComplete = '$_snd/charge_complete.wav';
|
||||
}
|
||||
140
lib/core/router/app_router.dart
Normal file
140
lib/core/router/app_router.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../domain/entities/transaction.dart';
|
||||
import '../../presentation/auth/providers/auth_controller.dart';
|
||||
import '../../presentation/auth/screens/login_screen.dart';
|
||||
import '../../presentation/customer/screens/customer_registration_screen.dart';
|
||||
import '../../presentation/customer/screens/existing_customer_screen.dart';
|
||||
import '../../presentation/payment/screens/payment_screen.dart';
|
||||
import '../../presentation/pos/screens/pos_dashboard_screen.dart';
|
||||
import '../../presentation/receipt/screens/receipt_screen.dart';
|
||||
import '../../presentation/welcome/screens/welcome_screen.dart';
|
||||
|
||||
class AppRoutes {
|
||||
const AppRoutes._();
|
||||
|
||||
static const String login = '/login';
|
||||
static const String welcome = '/';
|
||||
static const String registerCustomer = '/customer/new';
|
||||
static const String existingCustomer = '/customer/find';
|
||||
static const String pos = '/pos';
|
||||
static const String payment = '/pos/payment';
|
||||
static const String receipt = '/pos/receipt';
|
||||
}
|
||||
|
||||
/// Router with an authentication guard.
|
||||
///
|
||||
/// Every route except [AppRoutes.login] requires a signed-in store, and an
|
||||
/// already-signed-in terminal is bounced away from the login screen.
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
// GoRouter re-evaluates `redirect` whenever this notifier fires.
|
||||
final authChanged = ValueNotifier<bool>(
|
||||
ref.read(authControllerProvider).isAuthenticated,
|
||||
);
|
||||
ref.listen<AuthState>(
|
||||
authControllerProvider,
|
||||
(_, next) => authChanged.value = next.isAuthenticated,
|
||||
);
|
||||
ref.onDispose(authChanged.dispose);
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: AppRoutes.login,
|
||||
refreshListenable: authChanged,
|
||||
debugLogDiagnostics: false,
|
||||
redirect: (context, state) {
|
||||
final signedIn = ref.read(authControllerProvider).isAuthenticated;
|
||||
final atLogin = state.matchedLocation == AppRoutes.login;
|
||||
|
||||
if (!signedIn) return atLogin ? null : AppRoutes.login;
|
||||
if (atLogin) return AppRoutes.welcome;
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.login,
|
||||
name: 'login',
|
||||
pageBuilder: (context, state) => _fade(state, const LoginScreen()),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.welcome,
|
||||
name: 'welcome',
|
||||
pageBuilder: (context, state) => _fade(state, const WelcomeScreen()),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.registerCustomer,
|
||||
name: 'registerCustomer',
|
||||
pageBuilder: (context, state) => _slide(
|
||||
state,
|
||||
CustomerRegistrationScreen(
|
||||
prefillMobile: state.uri.queryParameters['mobile'],
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.existingCustomer,
|
||||
name: 'existingCustomer',
|
||||
pageBuilder: (context, state) =>
|
||||
_slide(state, const ExistingCustomerScreen()),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.pos,
|
||||
name: 'pos',
|
||||
pageBuilder: (context, state) =>
|
||||
_fade(state, const PosDashboardScreen()),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'payment',
|
||||
name: 'payment',
|
||||
pageBuilder: (context, state) =>
|
||||
_slide(state, const PaymentScreen()),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'receipt',
|
||||
name: 'receipt',
|
||||
pageBuilder: (context, state) => _fade(
|
||||
state,
|
||||
ReceiptScreen(transaction: state.extra! as SaleTransaction),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
errorBuilder: (context, state) => Scaffold(
|
||||
body: Center(child: Text('Route not found: ${state.uri}')),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
CustomTransitionPage<void> _fade(GoRouterState state, Widget child) {
|
||||
return CustomTransitionPage(
|
||||
key: state.pageKey,
|
||||
child: child,
|
||||
transitionDuration: const Duration(milliseconds: 220),
|
||||
transitionsBuilder: (_, animation, __, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
);
|
||||
}
|
||||
|
||||
CustomTransitionPage<void> _slide(GoRouterState state, Widget child) {
|
||||
return CustomTransitionPage(
|
||||
key: state.pageKey,
|
||||
child: child,
|
||||
transitionDuration: const Duration(milliseconds: 260),
|
||||
transitionsBuilder: (_, animation, __, child) {
|
||||
final curved =
|
||||
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic);
|
||||
return FadeTransition(
|
||||
opacity: curved,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.03),
|
||||
end: Offset.zero,
|
||||
).animate(curved),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
91
lib/core/services/barcode_service.dart
Normal file
91
lib/core/services/barcode_service.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../constants/app_constants.dart';
|
||||
|
||||
/// Detects hardware barcode scanners that emulate a keyboard.
|
||||
///
|
||||
/// Such scanners emit an entire code in a few milliseconds and terminate it
|
||||
/// with Enter. We buffer raw key events and only treat the buffer as a scan
|
||||
/// 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});
|
||||
|
||||
final void Function(String code)? onScan;
|
||||
final VoidCallback? onManualKey;
|
||||
|
||||
final StringBuffer _buffer = StringBuffer();
|
||||
DateTime? _lastKeyAt;
|
||||
Timer? _flushTimer;
|
||||
|
||||
bool _attached = false;
|
||||
|
||||
void attach() {
|
||||
if (_attached) return;
|
||||
HardwareKeyboard.instance.addHandler(_handleKey);
|
||||
_attached = true;
|
||||
}
|
||||
|
||||
void detach() {
|
||||
if (!_attached) return;
|
||||
HardwareKeyboard.instance.removeHandler(_handleKey);
|
||||
_flushTimer?.cancel();
|
||||
_attached = false;
|
||||
}
|
||||
|
||||
bool _handleKey(KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return false;
|
||||
|
||||
final now = DateTime.now();
|
||||
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();
|
||||
}
|
||||
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter ||
|
||||
event.logicalKey == LogicalKeyboardKey.numpadEnter) {
|
||||
return _flush();
|
||||
}
|
||||
|
||||
final char = event.character;
|
||||
if (char == null || char.trim().isEmpty) return false;
|
||||
if (!RegExp(r'^[0-9A-Za-z\-]$').hasMatch(char)) return false;
|
||||
|
||||
_buffer.write(char);
|
||||
_scheduleFlush();
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Some scanners are not configured to send a terminating Enter, so we also
|
||||
/// flush on a short idle window.
|
||||
void _scheduleFlush() {
|
||||
_flushTimer?.cancel();
|
||||
_flushTimer = Timer(
|
||||
AppConstants.barcodeScanTimeout * 2,
|
||||
() => _flush(),
|
||||
);
|
||||
}
|
||||
|
||||
bool _flush() {
|
||||
_flushTimer?.cancel();
|
||||
final code = _buffer.toString().trim();
|
||||
_buffer.clear();
|
||||
|
||||
if (code.length >= AppConstants.minBarcodeLength) {
|
||||
onScan?.call(code);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (code.isNotEmpty) onManualKey?.call();
|
||||
return false;
|
||||
}
|
||||
|
||||
void dispose() => detach();
|
||||
}
|
||||
335
lib/core/services/receipt_service.dart
Normal file
335
lib/core/services/receipt_service.dart
Normal file
@@ -0,0 +1,335 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:printing/printing.dart';
|
||||
|
||||
import '../../domain/entities/transaction.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
import '../utils/formatters.dart';
|
||||
|
||||
/// Builds and prints an 80mm thermal GST invoice.
|
||||
class ReceiptService {
|
||||
ReceiptService._();
|
||||
|
||||
static final ReceiptService instance = ReceiptService._();
|
||||
|
||||
/// 80mm roll with a small safety margin.
|
||||
static const double _rollWidth = 78 * PdfPageFormat.mm;
|
||||
|
||||
Future<Uint8List> build(SaleTransaction txn) async {
|
||||
final doc = pw.Document(title: txn.invoiceNumber);
|
||||
final font = await PdfGoogleFonts.interRegular();
|
||||
final bold = await PdfGoogleFonts.interSemiBold();
|
||||
|
||||
final cart = txn.cart;
|
||||
|
||||
doc.addPage(
|
||||
pw.Page(
|
||||
pageFormat: PdfPageFormat(
|
||||
_rollWidth,
|
||||
double.infinity,
|
||||
marginAll: 6 * PdfPageFormat.mm,
|
||||
),
|
||||
theme: pw.ThemeData.withFont(base: font, bold: bold),
|
||||
build: (context) => pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_header(txn),
|
||||
_divider(),
|
||||
_meta(txn),
|
||||
_divider(),
|
||||
_itemsTable(txn),
|
||||
_divider(),
|
||||
_totals(txn),
|
||||
_divider(),
|
||||
_taxSummary(txn),
|
||||
_divider(),
|
||||
_payments(txn),
|
||||
if (cart.customer != null) ...[
|
||||
_divider(),
|
||||
_loyalty(txn),
|
||||
],
|
||||
pw.SizedBox(height: 8),
|
||||
_footer(txn),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- Sections
|
||||
pw.Widget _header(SaleTransaction txn) => pw.Column(children: [
|
||||
pw.Text(
|
||||
AppConstants.storeName.toUpperCase(),
|
||||
style: pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold),
|
||||
textAlign: pw.TextAlign.center,
|
||||
),
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(
|
||||
AppConstants.storeAddress,
|
||||
style: const pw.TextStyle(fontSize: 7),
|
||||
textAlign: pw.TextAlign.center,
|
||||
),
|
||||
pw.Text(
|
||||
'GSTIN: ${AppConstants.storeGstin} | ${AppConstants.storePhone}',
|
||||
style: const pw.TextStyle(fontSize: 7),
|
||||
textAlign: pw.TextAlign.center,
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text(
|
||||
'TAX INVOICE',
|
||||
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold),
|
||||
),
|
||||
]);
|
||||
|
||||
pw.Widget _meta(SaleTransaction txn) {
|
||||
final c = txn.customer;
|
||||
return pw.Column(children: [
|
||||
_row('Invoice', txn.invoiceNumber),
|
||||
_row('Date', Formatters.receiptStamp(txn.createdAt)),
|
||||
_row('Cashier', txn.cashierName),
|
||||
_row('Terminal', txn.terminalId),
|
||||
_row('Customer', c == null ? 'Walk-in' : c.name),
|
||||
if (c != null) _row('Mobile', Formatters.mobile(c.mobile)),
|
||||
]);
|
||||
}
|
||||
|
||||
pw.Widget _itemsTable(SaleTransaction txn) {
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
pw.Row(children: [
|
||||
pw.Expanded(flex: 5, child: _th('Item')),
|
||||
pw.Expanded(flex: 2, child: _th('Qty', align: pw.TextAlign.center)),
|
||||
pw.Expanded(flex: 3, child: _th('Rate', align: pw.TextAlign.right)),
|
||||
pw.Expanded(flex: 3, child: _th('Amt', align: pw.TextAlign.right)),
|
||||
]),
|
||||
pw.SizedBox(height: 2),
|
||||
...txn.cart.lines.map((line) => pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(vertical: 1.5),
|
||||
child: pw.Column(children: [
|
||||
pw.Row(children: [
|
||||
pw.Expanded(flex: 5, child: _td(line.product.name)),
|
||||
pw.Expanded(
|
||||
flex: 2,
|
||||
child: _td(
|
||||
_qty(line.quantity),
|
||||
align: pw.TextAlign.center,
|
||||
),
|
||||
),
|
||||
pw.Expanded(
|
||||
flex: 3,
|
||||
child: _td(
|
||||
line.product.price.toStringAsFixed(2),
|
||||
align: pw.TextAlign.right,
|
||||
),
|
||||
),
|
||||
pw.Expanded(
|
||||
flex: 3,
|
||||
child: _td(
|
||||
line.payable.toStringAsFixed(2),
|
||||
align: pw.TextAlign.right,
|
||||
),
|
||||
),
|
||||
]),
|
||||
if (line.discount.isActive)
|
||||
pw.Row(children: [
|
||||
pw.Expanded(
|
||||
child: _td(
|
||||
' ${line.discount.label} '
|
||||
'-${line.discountAmount.toStringAsFixed(2)}',
|
||||
size: 6.5,
|
||||
),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _totals(SaleTransaction txn) {
|
||||
final cart = txn.cart;
|
||||
return pw.Column(children: [
|
||||
_row('Items', '${cart.lineCount} (Qty ${_qty(cart.totalQuantity)})'),
|
||||
_row('Subtotal', cart.subtotal.toStringAsFixed(2)),
|
||||
if (cart.membershipDiscountAmount > 0)
|
||||
_row(
|
||||
'${cart.customer!.tier.label} discount',
|
||||
'-${cart.membershipDiscountAmount.toStringAsFixed(2)}',
|
||||
),
|
||||
if (cart.manualBillDiscountAmount > 0)
|
||||
_row('Discount', '-${cart.manualBillDiscountAmount.toStringAsFixed(2)}'),
|
||||
if (cart.loyaltyRedemptionValue > 0)
|
||||
_row(
|
||||
'Points redeemed (${cart.pointsRedeemed})',
|
||||
'-${cart.loyaltyRedemptionValue.toStringAsFixed(2)}',
|
||||
),
|
||||
_row('Taxable value', cart.taxableAmount.toStringAsFixed(2)),
|
||||
_row('CGST', cart.cgst.toStringAsFixed(2)),
|
||||
_row('SGST', cart.sgst.toStringAsFixed(2)),
|
||||
if (cart.roundOff != 0)
|
||||
_row('Round off', cart.roundOff.toStringAsFixed(2)),
|
||||
pw.SizedBox(height: 3),
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text('TOTAL',
|
||||
style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
|
||||
pw.Text(
|
||||
'${AppConstants.currencySymbol}${txn.total.toStringAsFixed(2)}',
|
||||
style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (cart.totalSavings > 0) ...[
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(
|
||||
'You saved ${AppConstants.currencySymbol}'
|
||||
'${cart.totalSavings.toStringAsFixed(2)} on this bill',
|
||||
style: pw.TextStyle(fontSize: 7.5, fontWeight: pw.FontWeight.bold),
|
||||
textAlign: pw.TextAlign.center,
|
||||
),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
pw.Widget _taxSummary(SaleTransaction txn) {
|
||||
final breakdown = txn.cart.taxBreakdown.entries
|
||||
.where((e) => e.value > 0)
|
||||
.toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
|
||||
if (breakdown.isEmpty) {
|
||||
return _td('All items zero-rated', size: 7);
|
||||
}
|
||||
|
||||
return pw.Column(children: [
|
||||
_th('GST Summary'),
|
||||
...breakdown.map((e) => _row(
|
||||
'GST @ ${(e.key * 100).toStringAsFixed(0)}%',
|
||||
e.value.toStringAsFixed(2),
|
||||
size: 7,
|
||||
)),
|
||||
]);
|
||||
}
|
||||
|
||||
pw.Widget _payments(SaleTransaction txn) => pw.Column(children: [
|
||||
...txn.payments.map((p) => _row(
|
||||
p.method.label +
|
||||
(p.reference != null ? ' (${p.reference})' : ''),
|
||||
p.amount.toStringAsFixed(2),
|
||||
)),
|
||||
if (txn.changeDue > 0)
|
||||
_row('Change returned', txn.changeDue.toStringAsFixed(2)),
|
||||
]);
|
||||
|
||||
pw.Widget _loyalty(SaleTransaction txn) {
|
||||
final c = txn.customer!;
|
||||
final balance = c.loyaltyPoints - txn.pointsRedeemed + txn.pointsEarned;
|
||||
return pw.Column(children: [
|
||||
_row('Points earned', '+${txn.pointsEarned}'),
|
||||
if (txn.pointsRedeemed > 0)
|
||||
_row('Points redeemed', '-${txn.pointsRedeemed}'),
|
||||
_row('Points balance', '$balance'),
|
||||
_row('Membership', c.tier.label),
|
||||
]);
|
||||
}
|
||||
|
||||
pw.Widget _footer(SaleTransaction txn) => pw.Column(children: [
|
||||
pw.BarcodeWidget(
|
||||
barcode: pw.Barcode.code128(),
|
||||
data: txn.invoiceNumber,
|
||||
width: 140,
|
||||
height: 34,
|
||||
drawText: false,
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text(txn.invoiceNumber, style: const pw.TextStyle(fontSize: 7)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text('Thank you for shopping with us!',
|
||||
style: pw.TextStyle(fontSize: 8, fontWeight: pw.FontWeight.bold)),
|
||||
pw.Text('Goods once sold are exchangeable within 7 days with this bill.',
|
||||
style: const pw.TextStyle(fontSize: 6),
|
||||
textAlign: pw.TextAlign.center),
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text('Powered by Nearle POS', style: const pw.TextStyle(fontSize: 6)),
|
||||
]);
|
||||
|
||||
// -------------------------------------------------------------- Helpers
|
||||
String _qty(double q) =>
|
||||
q % 1 == 0 ? q.toStringAsFixed(0) : q.toStringAsFixed(3);
|
||||
|
||||
pw.Widget _divider() => pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(vertical: 3),
|
||||
child: pw.Divider(height: 0.5, borderStyle: pw.BorderStyle.dashed),
|
||||
);
|
||||
|
||||
pw.Widget _th(String text, {pw.TextAlign align = pw.TextAlign.left}) =>
|
||||
pw.Text(text,
|
||||
textAlign: align,
|
||||
style: pw.TextStyle(fontSize: 7.5, fontWeight: pw.FontWeight.bold));
|
||||
|
||||
pw.Widget _td(String text,
|
||||
{pw.TextAlign align = pw.TextAlign.left, double size = 7.5}) =>
|
||||
pw.Text(text, textAlign: align, style: pw.TextStyle(fontSize: size));
|
||||
|
||||
pw.Widget _row(String label, String value, {double size = 7.5}) => pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(vertical: 0.8),
|
||||
child: pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text(label, style: pw.TextStyle(fontSize: size)),
|
||||
pw.Text(value, style: pw.TextStyle(fontSize: size)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// --------------------------------------------------------- Printing / IO
|
||||
/// Silent print to the default roll printer — no OS dialog, so the cashier
|
||||
/// is never blocked between sales.
|
||||
Future<bool> printDirect(SaleTransaction txn) async {
|
||||
try {
|
||||
final bytes = await build(txn);
|
||||
final printers = await Printing.listPrinters();
|
||||
final target = printers.where((p) => p.isDefault).firstOrNull ??
|
||||
(printers.isNotEmpty ? printers.first : null);
|
||||
|
||||
if (target == null) return false;
|
||||
|
||||
return await Printing.directPrintPdf(
|
||||
printer: target,
|
||||
onLayout: (_) async => bytes,
|
||||
name: txn.invoiceNumber,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Direct print failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Falls back to the system print preview.
|
||||
Future<void> printWithDialog(SaleTransaction txn) async {
|
||||
final bytes = await build(txn);
|
||||
await Printing.layoutPdf(
|
||||
onLayout: (_) async => bytes,
|
||||
name: txn.invoiceNumber,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> share(SaleTransaction txn) async {
|
||||
final bytes = await build(txn);
|
||||
await Printing.sharePdf(bytes: bytes, filename: '${txn.invoiceNumber}.pdf');
|
||||
}
|
||||
|
||||
/// Opens the cash drawer via the ESC/POS kick pulse on pin 2.
|
||||
Future<void> openCashDrawer() async {
|
||||
// ESC p m t1 t2 — sent to the receipt printer's serial passthrough.
|
||||
// Wired up here as a no-op placeholder for the concrete driver.
|
||||
debugPrint('Cash drawer kick: ESC p 0 25 250');
|
||||
}
|
||||
}
|
||||
62
lib/core/services/sound_service.dart
Normal file
62
lib/core/services/sound_service.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../constants/asset_paths.dart';
|
||||
|
||||
/// Audible feedback for scanner billing.
|
||||
///
|
||||
/// A cashier scanning at speed watches the customer, not the screen, so the
|
||||
/// beep is the primary confirmation that an item registered.
|
||||
class SoundService {
|
||||
SoundService._();
|
||||
|
||||
static final SoundService instance = SoundService._();
|
||||
|
||||
final AudioPlayer _player = AudioPlayer(playerId: 'nearle_pos_sfx');
|
||||
bool _enabled = true;
|
||||
|
||||
bool get enabled => _enabled;
|
||||
set enabled(bool value) => _enabled = value;
|
||||
|
||||
Future<void> preload() async {
|
||||
try {
|
||||
await _player.setReleaseMode(ReleaseMode.stop);
|
||||
} catch (e) {
|
||||
debugPrint('SoundService preload failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> scanSuccess() => _play(AssetPaths.beepSuccess, haptic: true);
|
||||
|
||||
Future<void> scanError() => _play(AssetPaths.beepError, heavy: true);
|
||||
|
||||
Future<void> saleComplete() => _play(AssetPaths.chargeComplete);
|
||||
|
||||
Future<void> _play(
|
||||
String asset, {
|
||||
bool haptic = false,
|
||||
bool heavy = false,
|
||||
}) async {
|
||||
if (!_enabled) return;
|
||||
|
||||
// Haptics matter on tablets where the speaker may be muted on the floor.
|
||||
if (heavy) {
|
||||
unawaited(HapticFeedback.heavyImpact());
|
||||
} else if (haptic) {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
}
|
||||
|
||||
try {
|
||||
await _player.stop();
|
||||
await _player.play(AssetSource(asset.replaceFirst('assets/', '')));
|
||||
} catch (e) {
|
||||
// Never let a missing sound file break the billing flow.
|
||||
debugPrint('SoundService play failed for $asset: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dispose() => _player.dispose();
|
||||
}
|
||||
84
lib/core/theme/app_colors.dart
Normal file
84
lib/core/theme/app_colors.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Central colour palette for Nearle POS.
|
||||
///
|
||||
/// Everything is derived from the brand primary `#662582` so that a single
|
||||
/// change here re-skins the entire application.
|
||||
class AppColors {
|
||||
const AppColors._();
|
||||
|
||||
// ---------------------------------------------------------------- Brand
|
||||
static const Color primary = Color(0xFF662582);
|
||||
static const Color primaryDark = Color(0xFF4B1A60);
|
||||
static const Color primaryLight = Color(0xFF8B4BA6);
|
||||
static const Color primarySurface = Color(0xFFF4EDF7);
|
||||
static const Color primaryBorder = Color(0xFFE3D3EC);
|
||||
|
||||
static const MaterialColor primarySwatch = MaterialColor(0xFF662582, {
|
||||
50: Color(0xFFF4EDF7),
|
||||
100: Color(0xFFE3D3EC),
|
||||
200: Color(0xFFCDB1DC),
|
||||
300: Color(0xFFB68FCC),
|
||||
400: Color(0xFFA475C0),
|
||||
500: Color(0xFF662582),
|
||||
600: Color(0xFF5C2175),
|
||||
700: Color(0xFF4B1A60),
|
||||
800: Color(0xFF3B144B),
|
||||
900: Color(0xFF2A0E36),
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- Neutrals
|
||||
static const Color background = Color(0xFFF7F7F9);
|
||||
static const Color surface = Color(0xFFFFFFFF);
|
||||
static const Color surfaceAlt = Color(0xFFFAFAFC);
|
||||
static const Color border = Color(0xFFE8E8EE);
|
||||
static const Color divider = Color(0xFFEFEFF4);
|
||||
|
||||
static const Color textPrimary = Color(0xFF17131C);
|
||||
static const Color textSecondary = Color(0xFF6B6577);
|
||||
static const Color textTertiary = Color(0xFF9A94A6);
|
||||
static const Color textOnPrimary = Color(0xFFFFFFFF);
|
||||
|
||||
// ------------------------------------------------------------- Semantic
|
||||
static const Color success = Color(0xFF16A34A);
|
||||
static const Color successSurface = Color(0xFFEAF7EF);
|
||||
static const Color warning = Color(0xFFF59E0B);
|
||||
static const Color warningSurface = Color(0xFFFEF6E7);
|
||||
static const Color danger = Color(0xFFDC2626);
|
||||
static const Color dangerSurface = Color(0xFFFDECEC);
|
||||
static const Color info = Color(0xFF2563EB);
|
||||
static const Color infoSurface = Color(0xFFEAF0FE);
|
||||
|
||||
// ------------------------------------------------------- Membership tiers
|
||||
static const Color tierBronze = Color(0xFFB08D57);
|
||||
static const Color tierSilver = Color(0xFF8E96A3);
|
||||
static const Color tierGold = Color(0xFFD4A017);
|
||||
static const Color tierPlatinum = Color(0xFF4C5A6B);
|
||||
|
||||
// ---------------------------------------------------------------- Effects
|
||||
static const Color glassTint = Color(0x14662582);
|
||||
|
||||
static const List<BoxShadow> shadowSm = [
|
||||
BoxShadow(color: Color(0x0D17131C), blurRadius: 6, offset: Offset(0, 2)),
|
||||
];
|
||||
|
||||
static const List<BoxShadow> shadowMd = [
|
||||
BoxShadow(color: Color(0x1417131C), blurRadius: 16, offset: Offset(0, 6)),
|
||||
];
|
||||
|
||||
static const List<BoxShadow> shadowLg = [
|
||||
BoxShadow(color: Color(0x1F17131C), blurRadius: 32, offset: Offset(0, 12)),
|
||||
];
|
||||
|
||||
static const LinearGradient primaryGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF7A2E9A), Color(0xFF4B1A60)],
|
||||
);
|
||||
|
||||
static const LinearGradient glassGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0x40FFFFFF), Color(0x0DFFFFFF)],
|
||||
);
|
||||
}
|
||||
83
lib/core/theme/app_dimens.dart
Normal file
83
lib/core/theme/app_dimens.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Spacing scale — 4pt grid.
|
||||
class AppSpacing {
|
||||
const AppSpacing._();
|
||||
|
||||
static const double xxs = 2;
|
||||
static const double xs = 4;
|
||||
static const double sm = 8;
|
||||
static const double md = 12;
|
||||
static const double lg = 16;
|
||||
static const double xl = 20;
|
||||
static const double xxl = 24;
|
||||
static const double xxxl = 32;
|
||||
static const double huge = 40;
|
||||
static const double giant = 56;
|
||||
|
||||
static const EdgeInsets pageDesktop = EdgeInsets.all(xxl);
|
||||
static const EdgeInsets pageTablet = EdgeInsets.all(lg);
|
||||
static const EdgeInsets card = EdgeInsets.all(lg);
|
||||
}
|
||||
|
||||
/// Corner radii — brand standard is 16.
|
||||
class AppRadius {
|
||||
const AppRadius._();
|
||||
|
||||
static const double xs = 6;
|
||||
static const double sm = 10;
|
||||
static const double md = 12;
|
||||
static const double lg = 16;
|
||||
static const double xl = 20;
|
||||
static const double xxl = 28;
|
||||
static const double pill = 999;
|
||||
|
||||
static const BorderRadius brXs = BorderRadius.all(Radius.circular(xs));
|
||||
static const BorderRadius brSm = BorderRadius.all(Radius.circular(sm));
|
||||
static const BorderRadius brMd = BorderRadius.all(Radius.circular(md));
|
||||
static const BorderRadius brLg = BorderRadius.all(Radius.circular(lg));
|
||||
static const BorderRadius brXl = BorderRadius.all(Radius.circular(xl));
|
||||
static const BorderRadius brPill = BorderRadius.all(Radius.circular(pill));
|
||||
}
|
||||
|
||||
/// Minimum sizes tuned for finger targets on tablets.
|
||||
class AppSizes {
|
||||
const AppSizes._();
|
||||
|
||||
static const double touchTarget = 48;
|
||||
static const double buttonHeight = 52;
|
||||
static const double buttonHeightLarge = 64;
|
||||
static const double inputHeight = 56;
|
||||
static const double headerHeight = 76;
|
||||
static const double customerBarHeight = 72;
|
||||
static const double navItemHeight = 46;
|
||||
static const double productCardAspect = 0.86;
|
||||
static const double keypadKeySize = 76;
|
||||
}
|
||||
|
||||
/// Motion durations & curves.
|
||||
class AppMotion {
|
||||
const AppMotion._();
|
||||
|
||||
static const Duration instant = Duration(milliseconds: 90);
|
||||
static const Duration fast = Duration(milliseconds: 160);
|
||||
static const Duration normal = Duration(milliseconds: 240);
|
||||
static const Duration slow = Duration(milliseconds: 400);
|
||||
|
||||
static const Curve emphasized = Curves.easeOutCubic;
|
||||
static const Curve standard = Curves.easeInOut;
|
||||
static const Curve bouncy = Curves.easeOutBack;
|
||||
}
|
||||
|
||||
/// Responsive breakpoints.
|
||||
class AppBreakpoints {
|
||||
const AppBreakpoints._();
|
||||
|
||||
static const double tabletPortrait = 920;
|
||||
static const double tabletLandscape = 1300;
|
||||
static const double desktop = 1650;
|
||||
|
||||
static bool isCompact(double w) => w < tabletPortrait;
|
||||
static bool isMedium(double w) => w >= tabletPortrait && w < desktop;
|
||||
static bool isExpanded(double w) => w >= desktop;
|
||||
}
|
||||
98
lib/core/theme/app_layout.dart
Normal file
98
lib/core/theme/app_layout.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// How the left navigation should render at the current width.
|
||||
enum SidebarMode {
|
||||
/// Off-canvas; reachable through the page-header menu button.
|
||||
drawer,
|
||||
|
||||
/// Icons only, 84px.
|
||||
rail,
|
||||
|
||||
/// Icons plus labels and section headers, 252px.
|
||||
expanded,
|
||||
}
|
||||
|
||||
/// How the bill should render at the current width.
|
||||
enum BillingMode {
|
||||
/// Bottom sheet, opened from the floating cart button.
|
||||
sheet,
|
||||
|
||||
/// Docked column on the right.
|
||||
docked,
|
||||
}
|
||||
|
||||
/// Resolves every layout decision for the POS dashboard from a single width.
|
||||
///
|
||||
/// Keeping this in one place means the sidebar, product grid and billing panel
|
||||
/// can never disagree about which breakpoint they are in.
|
||||
class PosLayout {
|
||||
const PosLayout({
|
||||
required this.sidebar,
|
||||
required this.billing,
|
||||
required this.billingWidth,
|
||||
required this.gridTileExtent,
|
||||
required this.contentPadding,
|
||||
});
|
||||
|
||||
final SidebarMode sidebar;
|
||||
final BillingMode billing;
|
||||
final double billingWidth;
|
||||
|
||||
/// Maximum width of a product card; the grid fits as many as will fit.
|
||||
final double gridTileExtent;
|
||||
|
||||
final double contentPadding;
|
||||
|
||||
static const double railWidth = 84;
|
||||
static const double expandedWidth = 252;
|
||||
|
||||
/// Below this the sidebar goes off-canvas.
|
||||
static const double drawerBelow = 920;
|
||||
|
||||
/// Below this the sidebar is icons-only.
|
||||
static const double railBelow = 1300;
|
||||
|
||||
/// Below this the bill becomes a bottom sheet.
|
||||
static const double sheetBelow = 1120;
|
||||
|
||||
/// Above this we have room for a wider bill and larger cards.
|
||||
static const double wideAbove = 1650;
|
||||
|
||||
factory PosLayout.of(BuildContext context) =>
|
||||
PosLayout.forWidth(MediaQuery.sizeOf(context).width);
|
||||
|
||||
factory PosLayout.forWidth(double width) {
|
||||
final sidebar = width < drawerBelow
|
||||
? SidebarMode.drawer
|
||||
: (width < railBelow ? SidebarMode.rail : SidebarMode.expanded);
|
||||
|
||||
final billing =
|
||||
width < sheetBelow ? BillingMode.sheet : BillingMode.docked;
|
||||
|
||||
final billingWidth = width >= wideAbove ? 440.0 : 380.0;
|
||||
|
||||
// Cards stay finger-sized on tablets and grow a little on large desktops.
|
||||
final tile = width < drawerBelow
|
||||
? 168.0
|
||||
: (width >= wideAbove ? 208.0 : 186.0);
|
||||
|
||||
final padding = width < drawerBelow ? 16.0 : 24.0;
|
||||
|
||||
return PosLayout(
|
||||
sidebar: sidebar,
|
||||
billing: billing,
|
||||
billingWidth: billingWidth,
|
||||
gridTileExtent: tile,
|
||||
contentPadding: padding,
|
||||
);
|
||||
}
|
||||
|
||||
double get sidebarWidth => switch (sidebar) {
|
||||
SidebarMode.drawer => 0,
|
||||
SidebarMode.rail => railWidth,
|
||||
SidebarMode.expanded => expandedWidth,
|
||||
};
|
||||
|
||||
bool get sidebarIsDrawer => sidebar == SidebarMode.drawer;
|
||||
bool get billingIsSheet => billing == BillingMode.sheet;
|
||||
}
|
||||
162
lib/core/theme/app_theme.dart
Normal file
162
lib/core/theme/app_theme.dart
Normal file
@@ -0,0 +1,162 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'app_colors.dart';
|
||||
import 'app_dimens.dart';
|
||||
import 'app_typography.dart';
|
||||
|
||||
/// Builds the single source of truth [ThemeData] for Nearle POS.
|
||||
class AppTheme {
|
||||
const AppTheme._();
|
||||
|
||||
static ThemeData get light {
|
||||
final colorScheme = ColorScheme.fromSeed(
|
||||
seedColor: AppColors.primary,
|
||||
primary: AppColors.primary,
|
||||
onPrimary: AppColors.textOnPrimary,
|
||||
secondary: AppColors.primaryLight,
|
||||
surface: AppColors.surface,
|
||||
onSurface: AppColors.textPrimary,
|
||||
error: AppColors.danger,
|
||||
brightness: Brightness.light,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: colorScheme,
|
||||
scaffoldBackgroundColor: AppColors.background,
|
||||
textTheme: AppTypography.textTheme,
|
||||
splashFactory: InkSparkle.splashFactory,
|
||||
visualDensity: VisualDensity.standard,
|
||||
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: AppColors.textOnPrimary,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
toolbarHeight: AppSizes.headerHeight,
|
||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||
titleTextStyle: AppTypography.textTheme.titleLarge
|
||||
?.copyWith(color: AppColors.textOnPrimary),
|
||||
),
|
||||
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.surface,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.brLg,
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
),
|
||||
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: AppColors.divider,
|
||||
thickness: 1,
|
||||
space: 1,
|
||||
),
|
||||
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: AppColors.textOnPrimary,
|
||||
disabledBackgroundColor: AppColors.border,
|
||||
disabledForegroundColor: AppColors.textTertiary,
|
||||
minimumSize: const Size(0, AppSizes.buttonHeight),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xxl),
|
||||
elevation: 0,
|
||||
shape: const RoundedRectangleBorder(borderRadius: AppRadius.brMd),
|
||||
textStyle: AppTypography.textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.primary,
|
||||
minimumSize: const Size(0, AppSizes.buttonHeight),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xxl),
|
||||
side: const BorderSide(color: AppColors.primaryBorder, width: 1.5),
|
||||
shape: const RoundedRectangleBorder(borderRadius: AppRadius.brMd),
|
||||
textStyle: AppTypography.textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.primary,
|
||||
minimumSize: const Size(0, AppSizes.touchTarget),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
||||
shape: const RoundedRectangleBorder(borderRadius: AppRadius.brSm),
|
||||
textStyle: AppTypography.textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.lg,
|
||||
),
|
||||
hintStyle: AppTypography.textTheme.bodyMedium
|
||||
?.copyWith(color: AppColors.textTertiary),
|
||||
labelStyle: AppTypography.textTheme.bodyMedium
|
||||
?.copyWith(color: AppColors.textSecondary),
|
||||
border: _inputBorder(AppColors.border),
|
||||
enabledBorder: _inputBorder(AppColors.border),
|
||||
focusedBorder: _inputBorder(AppColors.primary, width: 1.8),
|
||||
errorBorder: _inputBorder(AppColors.danger),
|
||||
focusedErrorBorder: _inputBorder(AppColors.danger, width: 1.8),
|
||||
),
|
||||
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: AppColors.surface,
|
||||
selectedColor: AppColors.primary,
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
labelStyle: AppTypography.textTheme.labelMedium,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
shape: const RoundedRectangleBorder(borderRadius: AppRadius.brPill),
|
||||
),
|
||||
|
||||
dialogTheme: DialogThemeData(
|
||||
backgroundColor: AppColors.surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
),
|
||||
titleTextStyle: AppTypography.textTheme.headlineSmall,
|
||||
),
|
||||
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
backgroundColor: AppColors.textPrimary,
|
||||
contentTextStyle: AppTypography.textTheme.bodyMedium
|
||||
?.copyWith(color: Colors.white),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: const RoundedRectangleBorder(borderRadius: AppRadius.brMd),
|
||||
),
|
||||
|
||||
scrollbarTheme: ScrollbarThemeData(
|
||||
thickness: WidgetStateProperty.all(6),
|
||||
radius: const Radius.circular(AppRadius.pill),
|
||||
thumbColor: WidgetStateProperty.all(AppColors.primaryBorder),
|
||||
),
|
||||
|
||||
pageTransitionsTheme: const PageTransitionsTheme(builders: {
|
||||
TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
|
||||
TargetPlatform.macOS: FadeUpwardsPageTransitionsBuilder(),
|
||||
TargetPlatform.linux: FadeUpwardsPageTransitionsBuilder(),
|
||||
TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static OutlineInputBorder _inputBorder(Color color, {double width = 1}) {
|
||||
return OutlineInputBorder(
|
||||
borderRadius: AppRadius.brMd,
|
||||
borderSide: BorderSide(color: color, width: width),
|
||||
);
|
||||
}
|
||||
}
|
||||
85
lib/core/theme/app_typography.dart
Normal file
85
lib/core/theme/app_typography.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import 'app_colors.dart';
|
||||
|
||||
/// Poppins-based type scale.
|
||||
///
|
||||
/// Poppins is geometric and runs slightly wide, so headings get negative
|
||||
/// tracking to keep them compact in the sidebar and page header. POS screens
|
||||
/// are read at arm's length, so body text starts at 15 rather than the
|
||||
/// Material default of 14.
|
||||
class AppTypography {
|
||||
const AppTypography._();
|
||||
|
||||
static TextTheme get textTheme {
|
||||
final base = GoogleFonts.poppinsTextTheme();
|
||||
|
||||
return base.copyWith(
|
||||
displayLarge: _s(base.displayLarge, 44, FontWeight.w700, -1.2),
|
||||
displayMedium: _s(base.displayMedium, 36, FontWeight.w700, -1.0),
|
||||
displaySmall: _s(base.displaySmall, 30, FontWeight.w700, -0.8),
|
||||
|
||||
headlineLarge: _s(base.headlineLarge, 28, FontWeight.w600, -0.7),
|
||||
headlineMedium: _s(base.headlineMedium, 24, FontWeight.w600, -0.6),
|
||||
headlineSmall: _s(base.headlineSmall, 20, FontWeight.w600, -0.4),
|
||||
|
||||
titleLarge: _s(base.titleLarge, 18, FontWeight.w600, -0.3),
|
||||
titleMedium: _s(base.titleMedium, 15.5, FontWeight.w600, -0.2),
|
||||
titleSmall: _s(base.titleSmall, 13.5, FontWeight.w600, -0.1),
|
||||
|
||||
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),
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
static TextStyle _s(
|
||||
TextStyle? base,
|
||||
double size,
|
||||
FontWeight weight,
|
||||
double tracking, {
|
||||
Color color = AppColors.textPrimary,
|
||||
}) {
|
||||
return (base ?? const TextStyle()).copyWith(
|
||||
fontSize: size,
|
||||
fontWeight: weight,
|
||||
letterSpacing: tracking,
|
||||
color: color,
|
||||
height: 1.35,
|
||||
);
|
||||
}
|
||||
|
||||
/// Tabular figures — essential so totals don't jitter as quantities change.
|
||||
static TextStyle money(double size,
|
||||
{FontWeight weight = FontWeight.w700, Color? color}) =>
|
||||
GoogleFonts.poppins(
|
||||
fontSize: size,
|
||||
fontWeight: weight,
|
||||
color: color ?? AppColors.textPrimary,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.15,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
);
|
||||
|
||||
/// Fixed-pitch, used only for the receipt preview.
|
||||
static TextStyle mono(double size, {Color? color}) => GoogleFonts.robotoMono(
|
||||
fontSize: size,
|
||||
color: color ?? AppColors.textTertiary,
|
||||
letterSpacing: 0.2,
|
||||
);
|
||||
|
||||
/// Small uppercase caption for sidebar section dividers.
|
||||
static TextStyle sectionLabel() => GoogleFonts.poppins(
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 1.0,
|
||||
color: AppColors.textTertiary,
|
||||
);
|
||||
}
|
||||
59
lib/core/utils/extensions.dart
Normal file
59
lib/core/utils/extensions.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_dimens.dart';
|
||||
|
||||
extension BuildContextX on BuildContext {
|
||||
ThemeData get theme => Theme.of(this);
|
||||
TextTheme get text => Theme.of(this).textTheme;
|
||||
ColorScheme get colors => Theme.of(this).colorScheme;
|
||||
|
||||
Size get screen => MediaQuery.sizeOf(this);
|
||||
double get screenWidth => MediaQuery.sizeOf(this).width;
|
||||
double get screenHeight => MediaQuery.sizeOf(this).height;
|
||||
|
||||
bool get isCompact => AppBreakpoints.isCompact(screenWidth);
|
||||
bool get isMedium => AppBreakpoints.isMedium(screenWidth);
|
||||
bool get isExpanded => AppBreakpoints.isExpanded(screenWidth);
|
||||
|
||||
/// Picks a value appropriate to the current breakpoint.
|
||||
T responsive<T>({required T compact, T? medium, required T expanded}) {
|
||||
if (isCompact) return compact;
|
||||
if (isExpanded) return expanded;
|
||||
return medium ?? expanded;
|
||||
}
|
||||
|
||||
void showSnack(String message, {Color? background}) {
|
||||
ScaffoldMessenger.of(this)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: background,
|
||||
duration: const Duration(seconds: 2),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
extension DoubleX on double {
|
||||
/// Rounds to two decimals to avoid floating point drift in money maths.
|
||||
double get asMoney => (this * 100).roundToDouble() / 100;
|
||||
|
||||
/// Indian retail round-off to the nearest rupee.
|
||||
double get roundedToRupee => roundToDouble();
|
||||
}
|
||||
|
||||
extension StringX on String {
|
||||
String get capitalized =>
|
||||
isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}';
|
||||
|
||||
bool containsIgnoreCase(String other) =>
|
||||
toLowerCase().contains(other.toLowerCase());
|
||||
}
|
||||
|
||||
extension IterableX<T> on Iterable<T> {
|
||||
T? firstWhereOrNull(bool Function(T) test) {
|
||||
for (final element in this) {
|
||||
if (test(element)) return element;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
67
lib/core/utils/formatters.dart
Normal file
67
lib/core/utils/formatters.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../constants/app_constants.dart';
|
||||
|
||||
/// Currency, date and number formatting helpers.
|
||||
class Formatters {
|
||||
const Formatters._();
|
||||
|
||||
static final NumberFormat _currency = NumberFormat.currency(
|
||||
locale: AppConstants.locale,
|
||||
symbol: AppConstants.currencySymbol,
|
||||
decimalDigits: 2,
|
||||
);
|
||||
|
||||
static final NumberFormat _compactCurrency = NumberFormat.compactCurrency(
|
||||
locale: AppConstants.locale,
|
||||
symbol: AppConstants.currencySymbol,
|
||||
decimalDigits: 1,
|
||||
);
|
||||
|
||||
static final DateFormat _time = DateFormat('hh:mm a');
|
||||
static final DateFormat _date = DateFormat('dd MMM yyyy');
|
||||
static final DateFormat _dateTime = DateFormat('dd MMM yyyy, hh:mm a');
|
||||
static final DateFormat _receiptStamp = DateFormat('dd/MM/yyyy HH:mm:ss');
|
||||
|
||||
static String money(num value) => _currency.format(value);
|
||||
static String moneyCompact(num value) => _compactCurrency.format(value);
|
||||
|
||||
/// Amount without the symbol — used where the symbol is styled separately.
|
||||
static String amount(num value) => value.toStringAsFixed(2);
|
||||
|
||||
static String time(DateTime dt) => _time.format(dt);
|
||||
static String date(DateTime dt) => _date.format(dt);
|
||||
static String dateTime(DateTime dt) => _dateTime.format(dt);
|
||||
static String receiptStamp(DateTime dt) => _receiptStamp.format(dt);
|
||||
|
||||
static String percent(double fraction) =>
|
||||
'${(fraction * 100).toStringAsFixed(fraction * 100 % 1 == 0 ? 0 : 1)}%';
|
||||
|
||||
/// `9876543210` -> `98765 43210`
|
||||
static String mobile(String raw) {
|
||||
final digits = raw.replaceAll(RegExp(r'\D'), '');
|
||||
if (digits.length != 10) return raw;
|
||||
return '${digits.substring(0, 5)} ${digits.substring(5)}';
|
||||
}
|
||||
|
||||
/// Masks all but the last four digits for on-screen privacy.
|
||||
static String maskedMobile(String raw) {
|
||||
final digits = raw.replaceAll(RegExp(r'\D'), '');
|
||||
if (digits.length < 4) return raw;
|
||||
return '${'\u2022' * (digits.length - 4)}${digits.substring(digits.length - 4)}';
|
||||
}
|
||||
|
||||
static String initials(String name) {
|
||||
final parts = name.trim().split(RegExp(r'\s+')).where((p) => p.isNotEmpty);
|
||||
if (parts.isEmpty) return '?';
|
||||
if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase();
|
||||
return (parts.first.substring(0, 1) + parts.last.substring(0, 1))
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
static String invoiceNumber(int sequence, DateTime date) {
|
||||
final y = date.year.toString().substring(2);
|
||||
final m = date.month.toString().padLeft(2, '0');
|
||||
return 'INV-$y$m-${sequence.toString().padLeft(5, '0')}';
|
||||
}
|
||||
}
|
||||
56
lib/core/utils/validators.dart
Normal file
56
lib/core/utils/validators.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import '../constants/app_constants.dart';
|
||||
|
||||
/// Form field validators returning a message, or null when valid.
|
||||
class Validators {
|
||||
const Validators._();
|
||||
|
||||
static final RegExp _emailRe =
|
||||
RegExp(r'^[\w.+-]+@([\w-]+\.)+[A-Za-z]{2,}$');
|
||||
static final RegExp _mobileRe = RegExp(r'^[6-9]\d{9}$');
|
||||
|
||||
static String? mobile(String? value) {
|
||||
final v = (value ?? '').replaceAll(RegExp(r'\D'), '');
|
||||
if (v.isEmpty) return 'Mobile number is required';
|
||||
if (v.length != AppConstants.mobileNumberLength) {
|
||||
return 'Enter all ${AppConstants.mobileNumberLength} digits';
|
||||
}
|
||||
if (!_mobileRe.hasMatch(v)) return 'Enter a valid Indian mobile number';
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? name(String? value) {
|
||||
final v = (value ?? '').trim();
|
||||
if (v.isEmpty) return 'Customer name is required';
|
||||
if (v.length < 2) return 'Name is too short';
|
||||
if (v.length > 60) return 'Name is too long';
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? emailOptional(String? value) {
|
||||
final v = (value ?? '').trim();
|
||||
if (v.isEmpty) return null;
|
||||
if (!_emailRe.hasMatch(v)) return 'Enter a valid email address';
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? dobOptional(DateTime? value) {
|
||||
if (value == null) return null;
|
||||
final now = DateTime.now();
|
||||
if (value.isAfter(now)) return 'Date of birth cannot be in the future';
|
||||
if (now.year - value.year > 120) return 'Enter a valid date of birth';
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? positiveAmount(String? value) {
|
||||
final v = double.tryParse((value ?? '').trim());
|
||||
if (v == null) return 'Enter a valid amount';
|
||||
if (v <= 0) return 'Amount must be greater than zero';
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool isLikelyBarcode(String value) {
|
||||
final v = value.trim();
|
||||
return v.length >= AppConstants.minBarcodeLength &&
|
||||
RegExp(r'^\d+$').hasMatch(v);
|
||||
}
|
||||
}
|
||||
72
lib/core/widgets/empty_state.dart
Normal file
72
lib/core/widgets/empty_state.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_dimens.dart';
|
||||
|
||||
class EmptyState extends StatelessWidget {
|
||||
const EmptyState({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.message,
|
||||
this.emoji = '🛒',
|
||||
this.action,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? message;
|
||||
final String emoji;
|
||||
final Widget? action;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.xxl),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: compact ? 64 : 96,
|
||||
height: compact ? 64 : 96,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primarySurface,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(emoji,
|
||||
style: TextStyle(fontSize: compact ? 28 : 40)),
|
||||
),
|
||||
SizedBox(height: compact ? AppSpacing.lg : AppSpacing.xxl),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 15 : 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
message!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
action!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
85
lib/core/widgets/glass_card.dart
Normal file
85
lib/core/widgets/glass_card.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_dimens.dart';
|
||||
|
||||
/// Glassmorphism-inspired surface used for elevated panels and product cards.
|
||||
class GlassCard extends StatelessWidget {
|
||||
const GlassCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = AppSpacing.card,
|
||||
this.radius = AppRadius.lg,
|
||||
this.blur = 0,
|
||||
this.tinted = false,
|
||||
this.borderColor,
|
||||
this.shadows,
|
||||
this.onTap,
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final double radius;
|
||||
|
||||
/// Backdrop blur strength. Zero renders an opaque card, which is cheaper and
|
||||
/// is the right default for the dense product grid.
|
||||
final double blur;
|
||||
|
||||
final bool tinted;
|
||||
final Color? borderColor;
|
||||
final List<BoxShadow>? shadows;
|
||||
final VoidCallback? onTap;
|
||||
final double? width;
|
||||
final double? height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final borderRadius = BorderRadius.circular(radius);
|
||||
|
||||
Widget surface = AnimatedContainer(
|
||||
duration: AppMotion.fast,
|
||||
width: width,
|
||||
height: height,
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: tinted
|
||||
? AppColors.primarySurface.withValues(alpha: blur > 0 ? 0.75 : 1)
|
||||
: AppColors.surface.withValues(alpha: blur > 0 ? 0.78 : 1),
|
||||
borderRadius: borderRadius,
|
||||
border: Border.all(
|
||||
color: borderColor ??
|
||||
(tinted ? AppColors.primaryBorder : AppColors.border),
|
||||
),
|
||||
boxShadow: shadows ?? AppColors.shadowSm,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
if (blur > 0) {
|
||||
surface = ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||
child: surface,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (onTap == null) return surface;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: borderRadius,
|
||||
splashColor: AppColors.primary.withValues(alpha: 0.08),
|
||||
highlightColor: AppColors.primary.withValues(alpha: 0.04),
|
||||
child: surface,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
146
lib/core/widgets/numeric_keypad.dart
Normal file
146
lib/core/widgets/numeric_keypad.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_dimens.dart';
|
||||
|
||||
/// Large on-screen keypad for mobile numbers and cash amounts.
|
||||
class NumericKeypad extends StatelessWidget {
|
||||
const NumericKeypad({
|
||||
super.key,
|
||||
required this.onKey,
|
||||
required this.onBackspace,
|
||||
this.onClear,
|
||||
this.onSubmit,
|
||||
this.submitLabel,
|
||||
this.allowDecimal = false,
|
||||
this.maxWidth = 360,
|
||||
});
|
||||
|
||||
final ValueChanged<String> onKey;
|
||||
final VoidCallback onBackspace;
|
||||
final VoidCallback? onClear;
|
||||
final VoidCallback? onSubmit;
|
||||
final String? submitLabel;
|
||||
final bool allowDecimal;
|
||||
final double maxWidth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final row in const [
|
||||
['1', '2', '3'],
|
||||
['4', '5', '6'],
|
||||
['7', '8', '9'],
|
||||
])
|
||||
_row(row.map((d) => _digit(d)).toList()),
|
||||
_row([
|
||||
allowDecimal
|
||||
? _digit('.')
|
||||
: _action(
|
||||
icon: Icons.clear_all_rounded,
|
||||
onTap: onClear,
|
||||
tone: AppColors.textSecondary,
|
||||
),
|
||||
_digit('0'),
|
||||
_action(
|
||||
icon: Icons.backspace_outlined,
|
||||
onTap: onBackspace,
|
||||
tone: AppColors.danger,
|
||||
),
|
||||
]),
|
||||
if (onSubmit != null) ...[
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: AppSizes.buttonHeight,
|
||||
child: FilledButton.icon(
|
||||
onPressed: onSubmit,
|
||||
icon: const Icon(Icons.check_rounded),
|
||||
label: Text(submitLabel ?? 'Done'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(List<Widget> children) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.md),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < children.length; i++) ...[
|
||||
Expanded(child: children[i]),
|
||||
if (i != children.length - 1)
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _digit(String value) => _Key(
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
onKey(value);
|
||||
},
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _action({
|
||||
required IconData icon,
|
||||
required VoidCallback? onTap,
|
||||
required Color tone,
|
||||
}) =>
|
||||
_Key(
|
||||
onTap: onTap == null
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onTap();
|
||||
},
|
||||
child: Icon(icon, size: 24, color: tone),
|
||||
);
|
||||
}
|
||||
|
||||
class _Key extends StatelessWidget {
|
||||
const _Key({required this.child, this.onTap});
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: AppSizes.keypadKeySize,
|
||||
child: Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: AppRadius.brMd,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: AppRadius.brMd,
|
||||
splashColor: AppColors.primary.withValues(alpha: 0.1),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: AppRadius.brMd,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
126
lib/core/widgets/primary_button.dart
Normal file
126
lib/core/widgets/primary_button.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_dimens.dart';
|
||||
|
||||
enum ButtonTone { primary, neutral, success, danger, ghost }
|
||||
|
||||
/// Large, touch-first action button with built-in busy state.
|
||||
class PrimaryButton extends StatelessWidget {
|
||||
const PrimaryButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.icon,
|
||||
this.tone = ButtonTone.primary,
|
||||
this.expanded = true,
|
||||
this.large = false,
|
||||
this.busy = false,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final IconData? icon;
|
||||
final ButtonTone tone;
|
||||
final bool expanded;
|
||||
final bool large;
|
||||
final bool busy;
|
||||
|
||||
/// Optional right-aligned widget, typically the bill total.
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = onPressed != null && !busy;
|
||||
final (bg, fg, border) = _palette;
|
||||
final height =
|
||||
large ? AppSizes.buttonHeightLarge : AppSizes.buttonHeight;
|
||||
|
||||
final content = busy
|
||||
? SizedBox(
|
||||
height: 22,
|
||||
width: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.4, color: fg),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: expanded ? MainAxisSize.max : MainAxisSize.min,
|
||||
mainAxisAlignment: trailing != null
|
||||
? 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
width: expanded ? double.infinity : null,
|
||||
height: height,
|
||||
child: Material(
|
||||
color: enabled ? bg : AppColors.border,
|
||||
borderRadius: AppRadius.brMd,
|
||||
child: InkWell(
|
||||
onTap: enabled ? onPressed : null,
|
||||
borderRadius: AppRadius.brMd,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: large ? AppSpacing.xxl : AppSpacing.xl,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: AppRadius.brMd,
|
||||
border: border == null
|
||||
? null
|
||||
: Border.all(color: border, width: 1.5),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: DefaultTextStyle.merge(
|
||||
style: TextStyle(color: enabled ? fg : AppColors.textTertiary),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
(Color, Color, Color?) get _palette => switch (tone) {
|
||||
ButtonTone.primary => (
|
||||
AppColors.primary,
|
||||
AppColors.textOnPrimary,
|
||||
null
|
||||
),
|
||||
ButtonTone.success => (AppColors.success, Colors.white, null),
|
||||
ButtonTone.danger => (AppColors.danger, Colors.white, null),
|
||||
ButtonTone.neutral => (
|
||||
AppColors.surface,
|
||||
AppColors.textPrimary,
|
||||
AppColors.border
|
||||
),
|
||||
ButtonTone.ghost => (
|
||||
AppColors.primarySurface,
|
||||
AppColors.primary,
|
||||
AppColors.primaryBorder
|
||||
),
|
||||
};
|
||||
}
|
||||
95
lib/core/widgets/status_pill.dart
Normal file
95
lib/core/widgets/status_pill.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_dimens.dart';
|
||||
import '../../domain/entities/customer.dart';
|
||||
|
||||
/// Compact labelled badge — stock states, tiers, live indicator.
|
||||
class StatusPill extends StatelessWidget {
|
||||
const StatusPill({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.color = AppColors.primary,
|
||||
this.background,
|
||||
this.icon,
|
||||
this.dense = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color color;
|
||||
final Color? background;
|
||||
final IconData? icon;
|
||||
final bool dense;
|
||||
|
||||
factory StatusPill.tier(MembershipTier tier, {bool dense = false}) {
|
||||
final color = switch (tier) {
|
||||
MembershipTier.bronze => AppColors.tierBronze,
|
||||
MembershipTier.silver => AppColors.tierSilver,
|
||||
MembershipTier.gold => AppColors.tierGold,
|
||||
MembershipTier.platinum => AppColors.tierPlatinum,
|
||||
};
|
||||
return StatusPill(
|
||||
label: tier.label.toUpperCase(),
|
||||
color: color,
|
||||
background: color.withValues(alpha: 0.12),
|
||||
dense: dense,
|
||||
);
|
||||
}
|
||||
|
||||
factory StatusPill.stock(double stock, {required int lowThreshold}) {
|
||||
if (stock <= 0) {
|
||||
return const StatusPill(
|
||||
label: 'Out of stock',
|
||||
color: AppColors.danger,
|
||||
background: AppColors.dangerSurface,
|
||||
dense: true,
|
||||
);
|
||||
}
|
||||
if (stock <= lowThreshold) {
|
||||
return StatusPill(
|
||||
label: '${stock.toStringAsFixed(0)} left',
|
||||
color: AppColors.warning,
|
||||
background: AppColors.warningSurface,
|
||||
dense: true,
|
||||
);
|
||||
}
|
||||
return StatusPill(
|
||||
label: '${stock.toStringAsFixed(0)} in stock',
|
||||
color: AppColors.textTertiary,
|
||||
background: Colors.transparent,
|
||||
dense: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: dense ? AppSpacing.sm : AppSpacing.md,
|
||||
vertical: dense ? 3 : AppSpacing.xs + 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: background ?? color.withValues(alpha: 0.12),
|
||||
borderRadius: AppRadius.brPill,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: dense ? 11 : 13, color: color),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
],
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: dense ? 10.5 : 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user