second commit

This commit is contained in:
2026-07-29 11:41:53 +05:30
parent fcccf22bac
commit d72522e737
211 changed files with 19260 additions and 0 deletions

View 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';
}

View 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';
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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