From d9886880cc47a1b19d8c3a167f8e04985ad1dd8a Mon Sep 17 00:00:00 2001 From: Anbarasu22004 Date: Wed, 29 Jul 2026 13:06:39 +0530 Subject: [PATCH] added local db --- .metadata | 15 - lib/app/providers.dart | 6 +- lib/core/router/app_router.dart | 67 +- lib/data/datasources/local_store.dart | 212 +++-- .../datasources/remote_catalogue_source.dart | 32 +- lib/data/local/app_database.dart | 263 ++++++ lib/data/local/catalogue_dao.dart | 248 ++++++ lib/data/local/order_dao.dart | 367 +++++++++ .../customer_repository_impl.dart | 6 +- .../repositories/product_repository_impl.dart | 31 +- .../repositories/sync_repository_impl.dart | 238 ++++-- .../transaction_repository_impl.dart | 35 +- lib/domain/repositories/sync_repository.dart | 77 +- lib/main.dart | 2 + .../auth/screens/login_screen.dart | 2 +- .../screens/customer_registration_screen.dart | 321 -------- .../screens/existing_customer_screen.dart | 504 ------------ .../widgets/customer_capture_sheet.dart | 561 +++++++++++++ .../modules/screens/events_view.dart | 341 ++++---- .../modules/screens/settings_view.dart | 22 +- .../payment/providers/payment_controller.dart | 5 +- .../payment/screens/payment_screen.dart | 770 +++++++++++------- lib/presentation/pos/widgets/app_sidebar.dart | 6 +- .../pos/widgets/billing_panel.dart | 149 ++-- lib/presentation/pos/widgets/cart_fab.dart | 24 +- .../pos/widgets/customer_bar.dart | 6 +- .../pos/widgets/discount_sheet.dart | 4 +- lib/presentation/pos/widgets/page_header.dart | 13 +- .../pos/widgets/product_card.dart | 14 +- .../receipt/screens/receipt_screen.dart | 2 +- .../sync/providers/sync_controller.dart | 153 ++-- .../sync/widgets/sign_out_dialog.dart | 56 +- .../welcome/screens/welcome_screen.dart | 337 -------- .../welcome/widgets/welcome_illustration.dart | 156 ---- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 74 +- pubspec.yaml | 5 + test/widget_test.dart | 30 + 38 files changed, 2895 insertions(+), 2261 deletions(-) create mode 100644 lib/data/local/app_database.dart create mode 100644 lib/data/local/catalogue_dao.dart create mode 100644 lib/data/local/order_dao.dart delete mode 100644 lib/presentation/customer/screens/customer_registration_screen.dart delete mode 100644 lib/presentation/customer/screens/existing_customer_screen.dart create mode 100644 lib/presentation/customer/widgets/customer_capture_sheet.dart delete mode 100644 lib/presentation/welcome/screens/welcome_screen.dart delete mode 100644 lib/presentation/welcome/widgets/welcome_illustration.dart create mode 100644 test/widget_test.dart diff --git a/.metadata b/.metadata index 768732a..32850fc 100644 --- a/.metadata +++ b/.metadata @@ -15,24 +15,9 @@ migration: - platform: root create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - - platform: android - create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - - platform: ios - create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - - platform: linux - create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - platform: macos create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - - platform: web - create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - - platform: windows - create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 # User provided section diff --git a/lib/app/providers.dart b/lib/app/providers.dart index a51b93c..a9a3000 100644 --- a/lib/app/providers.dart +++ b/lib/app/providers.dart @@ -35,14 +35,14 @@ final transactionRepositoryProvider = Provider( final remoteCatalogueProvider = Provider((ref) => RemoteCatalogueSource()); -final remoteReportSinkProvider = - Provider((ref) => RemoteReportSink()); +final remoteOrderSinkProvider = + Provider((ref) => RemoteOrderSink()); final syncRepositoryProvider = Provider( (ref) => SyncRepositoryImpl( ref.watch(localStoreProvider), ref.watch(remoteCatalogueProvider), - ref.watch(remoteReportSinkProvider), + ref.watch(remoteOrderSinkProvider), ), ); diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 1e2685b..0680aed 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -5,23 +5,20 @@ 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'; + + /// The terminal itself. Signing in lands here directly — customer capture + /// happens at checkout, not before the sale. + static const String pos = '/'; + static const String payment = '/payment'; + static const String receipt = '/receipt'; } /// Router with an authentication guard. @@ -48,7 +45,7 @@ final routerProvider = Provider((ref) { final atLogin = state.matchedLocation == AppRoutes.login; if (!signedIn) return atLogin ? null : AppRoutes.login; - if (atLogin) return AppRoutes.welcome; + if (atLogin) return AppRoutes.pos; return null; }, routes: [ @@ -57,48 +54,24 @@ final routerProvider = Provider((ref) { 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), - ), - ), - ], + ), + GoRoute( + path: AppRoutes.payment, + name: 'payment', + pageBuilder: (context, state) => _slide(state, const PaymentScreen()), + ), + GoRoute( + path: AppRoutes.receipt, + name: 'receipt', + pageBuilder: (context, state) => _fade( + state, + ReceiptScreen(transaction: state.extra! as SaleTransaction), + ), ), ], errorBuilder: (context, state) => Scaffold( diff --git a/lib/data/datasources/local_store.dart b/lib/data/datasources/local_store.dart index ae6c98d..069cfb8 100644 --- a/lib/data/datasources/local_store.dart +++ b/lib/data/datasources/local_store.dart @@ -1,136 +1,172 @@ import '../../domain/entities/customer.dart'; import '../../domain/entities/product.dart'; -import '../../domain/entities/sync_event.dart'; -import '../../domain/entities/transaction.dart'; -import 'seed_data.dart'; +import '../local/app_database.dart'; +import '../local/catalogue_dao.dart'; +import '../local/order_dao.dart'; -/// On-terminal storage. +/// Terminal-side storage facade. /// -/// The terminal starts with an **empty catalogue**: nothing can be billed -/// until the cashier imports products. Everything after that — sales, parked -/// bills, queued events — lives here and survives without a connection. -/// -/// Swapping this for Hive or SQLite changes nothing above the data layer. +/// SQLite is the source of truth. The catalogue is additionally held in memory +/// because barcode resolution happens on every scan and the product grid reads +/// it constantly — but every write goes to disk first, so nothing depends on +/// the process staying alive. class LocalStore { LocalStore._(); static final LocalStore instance = LocalStore._(); + late CatalogueDao catalogue; + late OrderDao orders; + final Map _products = {}; final Map _customers = {}; - final List _transactions = []; - final List _parked = []; - final List _events = []; - - int _invoiceSequence = 0; DateTime? _lastImportAt; String? _catalogueRevision; + int _unsyncedOrders = 0; - /// Nothing to seed — the catalogue arrives via import. - Future init() async {} + bool _ready = false; - /// Test helper. Clears everything and optionally loads the demo catalogue - /// so fixtures don't have to run an import first. + bool get isReady => _ready; + + /// Opens the database and loads the catalogue into memory. + Future init({String? databasePath, bool inMemory = false}) async { + if (inMemory) { + await AppDatabase.instance.openInMemory(); + } else { + await AppDatabase.instance.open(overridePath: databasePath); + } + + catalogue = CatalogueDao(AppDatabase.instance.db); + orders = OrderDao(AppDatabase.instance.db); + + await hydrate(); + _ready = true; + } + + /// Re-reads cached state from disk. Called on start and after an import. + Future hydrate() async { + _products + ..clear() + ..addEntries( + (await catalogue.allProducts()).map((p) => MapEntry(p.id, p)), + ); + _customers + ..clear() + ..addEntries( + (await catalogue.allCustomers()).map((c) => MapEntry(c.id, c)), + ); + + final stamp = await catalogue.meta(MetaKeys.lastImportAt); + _lastImportAt = stamp == null + ? null + : DateTime.fromMillisecondsSinceEpoch(int.parse(stamp)); + _catalogueRevision = await catalogue.meta(MetaKeys.catalogueRevision); + _unsyncedOrders = await orders.unsyncedCount(); + } + + /// Test helper: wipes every table and reloads. Future reset({bool withCatalogue = false}) async { - _products.clear(); - _customers.clear(); - _transactions.clear(); - _parked.clear(); - _events.clear(); - _invoiceSequence = 0; - _lastImportAt = null; - _catalogueRevision = null; + if (!_ready) await init(inMemory: true); + await AppDatabase.instance.clear(); if (withCatalogue) { - importCatalogue( - products: SeedData.products(), - customers: SeedData.customers(), - revision: 'seed', - at: DateTime.now(), + // Imported here rather than at the top of the file so the seed data is + // only pulled in by tests and the simulated remote source. + await catalogue.replaceCatalogue( + products: _seedProducts(), + customers: _seedCustomers(), ); + await catalogue.setMeta( + MetaKeys.lastImportAt, + '${DateTime.now().millisecondsSinceEpoch}', + ); + await catalogue.setMeta(MetaKeys.catalogueRevision, 'seed'); } + + await hydrate(); } // -------------------------------------------------------------- Catalogue - /// True once a catalogue has been pulled. The POS refuses to bill until so. + /// True once products exist on this terminal. Billing is gated on it. bool get hasCatalogue => _products.isNotEmpty; DateTime? get lastImportAt => _lastImportAt; String? get catalogueRevision => _catalogueRevision; + int get unsyncedOrders => _unsyncedOrders; - /// Replaces the catalogue wholesale. - /// - /// Stock levels already adjusted by local sales are preserved for products - /// that survive the re-import, so importing mid-shift does not resurrect - /// stock that has been sold. - void importCatalogue({ + Future importCatalogue({ required List products, required List customers, required String revision, required DateTime at, - }) { - final priorStock = { - for (final p in _products.values) p.id: p.stock, - }; - - _products - ..clear() - ..addEntries(products.map((p) { - final held = priorStock[p.id]; - return MapEntry(p.id, held == null ? p : p.copyWith(stock: held)); - })); - - // Locally registered customers must not be wiped by a server pull. - for (final c in customers) { - _customers.putIfAbsent(c.id, () => c); - } - - _lastImportAt = at; - _catalogueRevision = revision; + }) async { + await catalogue.replaceCatalogue(products: products, customers: customers); + await catalogue.setMeta( + MetaKeys.lastImportAt, + '${at.millisecondsSinceEpoch}', + ); + await catalogue.setMeta(MetaKeys.catalogueRevision, revision); + await hydrate(); } // --------------------------------------------------------------- Products List get products => _products.values.toList(growable: false); - void putProduct(Product p) => _products[p.id] = p; - Product? productById(String id) => _products[id]; + Product? productByBarcode(String barcode) { + final needle = barcode.trim(); + for (final p in _products.values) { + if (p.barcode == needle && p.isActive) return p; + } + return null; + } + + /// Write-through: disk first, then the cache. + Future putProduct(Product p) async { + await catalogue.upsertProduct(p); + _products[p.id] = p; + } + + Future applyStockMovement(Map quantities) async { + await catalogue.decrementStock(quantities); + quantities.forEach((id, qty) { + final p = _products[id]; + if (p == null) return; + _products[id] = + p.copyWith(stock: (p.stock - qty).clamp(0, double.infinity)); + }); + } + // -------------------------------------------------------------- Customers List get customers => _customers.values.toList(growable: false); - void putCustomer(Customer c) => _customers[c.id] = c; - Customer? customerById(String id) => _customers[id]; - // ----------------------------------------------------------- Transactions - List get transactions => - List.unmodifiable(_transactions.reversed); - - void addTransaction(SaleTransaction t) => _transactions.add(t); - - int nextInvoiceSequence() => ++_invoiceSequence; - - // ------------------------------------------------------------ Parked bills - List get parked => List.unmodifiable(_parked); - - void addParked(ParkedBill b) => _parked.add(b); - - void removeParked(String id) => _parked.removeWhere((b) => b.id == id); - - // ------------------------------------------------------------ Sync events - List get events => List.unmodifiable(_events.reversed); - - void addEvent(SyncEvent e) => _events.add(e); - - /// Updates in place. Events are never removed, so a failed push stays - /// visible and retryable. - void updateEvent(SyncEvent e) { - final i = _events.indexWhere((x) => x.id == e.id); - if (i >= 0) _events[i] = e; + Future putCustomer(Customer c) async { + await catalogue.upsertCustomer(c); + _customers[c.id] = c; } - bool get hasUnsyncedEvents => - _events.any((e) => e.status != SyncStatus.synced); + // ----------------------------------------------------------------- Orders + /// Refreshes the cached unsynced tally after a write or a sync. + Future refreshUnsyncedCount() async { + _unsyncedOrders = await orders.unsyncedCount(); + return _unsyncedOrders; + } + + // ------------------------------------------------------------- Seed data + // Kept behind these hooks so production code never reaches for them. + static List Function() _seedProducts = () => const []; + static List Function() _seedCustomers = () => const []; + + /// Lets the simulated remote source and tests supply demo rows. + static void registerSeed({ + required List Function() products, + required List Function() customers, + }) { + _seedProducts = products; + _seedCustomers = customers; + } } diff --git a/lib/data/datasources/remote_catalogue_source.dart b/lib/data/datasources/remote_catalogue_source.dart index 73832bb..7285269 100644 --- a/lib/data/datasources/remote_catalogue_source.dart +++ b/lib/data/datasources/remote_catalogue_source.dart @@ -1,5 +1,6 @@ import '../../domain/entities/customer.dart'; import '../../domain/entities/product.dart'; +import 'local_store.dart'; import 'seed_data.dart'; /// What one catalogue pull returns. @@ -35,7 +36,12 @@ class CatalogueSyncException implements Exception { /// The real implementation would issue an HTTP request; the contract is the /// same, so only this class changes. class RemoteCatalogueSource { - RemoteCatalogueSource(); + RemoteCatalogueSource() { + LocalStore.registerSeed( + products: SeedData.products, + customers: SeedData.customers, + ); + } /// Flipped from Settings to exercise the offline path. bool simulateOffline = false; @@ -75,24 +81,28 @@ class RemoteCatalogueSource { } } -/// Stands in for the back-office reporting API. -class RemoteReportSink { - RemoteReportSink(); +/// Stands in for the back-office order intake API. +class RemoteOrderSink { + RemoteOrderSink(); bool simulateOffline = false; - /// Pushes one payload. Throws on failure so the caller can keep the event - /// queued rather than marking it sent. - Future push(Map payload) async { - await Future.delayed(const Duration(milliseconds: 900)); + /// Uploads a batch of orders and returns the ids the server accepted. + /// + /// Throws on transport failure so the caller leaves every row at + /// `sync_status = 0` rather than marking anything sent. + Future> pushOrders(List> orders) async { + await Future.delayed( + Duration(milliseconds: 400 + orders.length * 60), + ); if (simulateOffline) { throw const CatalogueSyncException( - 'Could not reach the reporting server. ' - 'The report is still saved on this terminal.', + 'Could not reach the order server. Every bill is still stored on ' + 'this terminal and will upload on the next attempt.', ); } - return 'ACK-${DateTime.now().millisecondsSinceEpoch % 1000000}'; + return orders.map((o) => o['id']! as String).toList(); } } diff --git a/lib/data/local/app_database.dart b/lib/data/local/app_database.dart new file mode 100644 index 0000000..79d4ae3 --- /dev/null +++ b/lib/data/local/app_database.dart @@ -0,0 +1,263 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite/sqflite.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// SQLite database for the terminal. +/// +/// This is the source of truth for everything the cashier produces. Orders are +/// written here the moment a sale completes, so a crash, a power cut or a +/// closed app cannot lose a bill that has not yet reached the server. +class AppDatabase { + AppDatabase._(); + + static final AppDatabase instance = AppDatabase._(); + + static const String _fileName = 'nearle_pos.db'; + static const int _version = 1; + + Database? _db; + + Database get db { + final database = _db; + if (database == null) { + throw StateError('AppDatabase.open() must be called before use.'); + } + return database; + } + + bool get isOpen => _db != null; + + /// Opens the database, creating the schema on first run. + /// + /// Desktop needs the FFI factory installed first; Android and iOS use the + /// platform implementation that ships with sqflite. + Future open({String? overridePath}) async { + if (_db != null) return; + + if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + } + + final path = overridePath ?? + p.join(await databaseFactory.getDatabasesPath(), _fileName); + + _db = await databaseFactory.openDatabase( + path, + options: OpenDatabaseOptions( + version: _version, + onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'), + onCreate: (db, version) async => _createSchema(db), + onUpgrade: (db, from, to) async { + // Single version so far; migrations land here as the schema grows. + }, + ), + ); + } + + /// In-memory database for tests. + Future openInMemory() async { + if (_db != null) await close(); + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + _db = await databaseFactory.openDatabase( + inMemoryDatabasePath, + options: OpenDatabaseOptions( + version: _version, + onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'), + onCreate: (db, version) async => _createSchema(db), + ), + ); + } + + Future close() async { + await _db?.close(); + _db = null; + } + + /// Drops every row without touching the schema. Used by tests. + Future clear() async { + final batch = db.batch(); + for (final t in const [ + Tables.orderItems, + Tables.orders, + Tables.products, + Tables.customers, + Tables.parkedBills, + Tables.syncLog, + Tables.meta, + ]) { + batch.delete(t); + } + await batch.commit(noResult: true); + } + + Future _createSchema(Database db) async { + // ------------------------------------------------------------ products + await db.execute(''' + CREATE TABLE ${Tables.products} ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + barcode TEXT NOT NULL, + sku TEXT NOT NULL, + category TEXT NOT NULL, + price REAL NOT NULL, + mrp REAL, + stock REAL NOT NULL DEFAULT 0, + emoji TEXT, + image_url TEXT, + unit TEXT NOT NULL DEFAULT 'piece', + gst_rate REAL NOT NULL DEFAULT 0.18, + brand TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL + ) + '''); + // Barcode lookup is the hot path during scanning. + await db.execute( + 'CREATE UNIQUE INDEX idx_products_barcode ON ${Tables.products}(barcode)', + ); + await db.execute( + 'CREATE INDEX idx_products_category ON ${Tables.products}(category)', + ); + + // ----------------------------------------------------------- customers + await db.execute(''' + CREATE TABLE ${Tables.customers} ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + mobile TEXT NOT NULL, + email TEXT, + gender TEXT NOT NULL DEFAULT 'unspecified', + date_of_birth INTEGER, + loyalty_points INTEGER NOT NULL DEFAULT 0, + lifetime_spend REAL NOT NULL DEFAULT 0, + visit_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER, + last_visit_at INTEGER + ) + '''); + await db.execute( + 'CREATE UNIQUE INDEX idx_customers_mobile ON ${Tables.customers}(mobile)', + ); + + // -------------------------------------------------------------- orders + await db.execute(''' + CREATE TABLE ${Tables.orders} ( + id TEXT PRIMARY KEY, + invoice_number TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + business_date TEXT NOT NULL, + cashier_name TEXT NOT NULL, + terminal_id TEXT NOT NULL, + customer_id TEXT, + customer_mobile TEXT, + customer_name TEXT, + subtotal REAL NOT NULL, + line_discount REAL NOT NULL DEFAULT 0, + bill_discount REAL NOT NULL DEFAULT 0, + loyalty_value REAL NOT NULL DEFAULT 0, + taxable_amount REAL NOT NULL DEFAULT 0, + tax_amount REAL NOT NULL DEFAULT 0, + round_off REAL NOT NULL DEFAULT 0, + total REAL NOT NULL, + points_earned INTEGER NOT NULL DEFAULT 0, + points_redeemed INTEGER NOT NULL DEFAULT 0, + payments_json TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'completed', + + -- 0 = held on this terminal, 1 = accepted by the server + sync_status INTEGER NOT NULL DEFAULT 0, + synced_at INTEGER, + sync_attempts INTEGER NOT NULL DEFAULT 0, + sync_error TEXT + ) + '''); + // The end-of-day upload selects on this, so it must be indexed. + await db.execute( + 'CREATE INDEX idx_orders_sync ON ${Tables.orders}(sync_status)', + ); + await db.execute( + 'CREATE INDEX idx_orders_date ON ${Tables.orders}(business_date)', + ); + + // --------------------------------------------------------- order items + await db.execute(''' + CREATE TABLE ${Tables.orderItems} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id TEXT NOT NULL, + product_id TEXT NOT NULL, + name TEXT NOT NULL, + barcode TEXT NOT NULL, + sku TEXT NOT NULL, + unit TEXT NOT NULL, + unit_price REAL NOT NULL, + quantity REAL NOT NULL, + discount REAL NOT NULL DEFAULT 0, + gst_rate REAL NOT NULL DEFAULT 0, + tax_amount REAL NOT NULL DEFAULT 0, + line_total REAL NOT NULL, + FOREIGN KEY (order_id) REFERENCES ${Tables.orders}(id) + ON DELETE CASCADE + ) + '''); + await db.execute( + 'CREATE INDEX idx_items_order ON ${Tables.orderItems}(order_id)', + ); + + // --------------------------------------------------------- parked bills + await db.execute(''' + CREATE TABLE ${Tables.parkedBills} ( + id TEXT PRIMARY KEY, + label TEXT, + parked_at INTEGER NOT NULL, + cart_json TEXT NOT NULL + ) + '''); + + // -------------------------------------------------------------- syncLog + await db.execute(''' + CREATE TABLE ${Tables.syncLog} ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + status TEXT NOT NULL, + created_at INTEGER NOT NULL, + synced_at INTEGER, + summary TEXT NOT NULL, + error TEXT, + attempts INTEGER NOT NULL DEFAULT 0 + ) + '''); + + // ----------------------------------------------------------------- meta + await db.execute(''' + CREATE TABLE ${Tables.meta} ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + '''); + } +} + +class Tables { + const Tables._(); + + static const String products = 'products'; + static const String customers = 'customers'; + static const String orders = 'orders'; + static const String orderItems = 'order_items'; + static const String parkedBills = 'parked_bills'; + static const String syncLog = 'sync_log'; + static const String meta = 'app_meta'; +} + +class MetaKeys { + const MetaKeys._(); + + static const String lastImportAt = 'last_import_at'; + static const String catalogueRevision = 'catalogue_revision'; + static const String invoiceSequence = 'invoice_sequence'; +} diff --git a/lib/data/local/catalogue_dao.dart b/lib/data/local/catalogue_dao.dart new file mode 100644 index 0000000..5ead417 --- /dev/null +++ b/lib/data/local/catalogue_dao.dart @@ -0,0 +1,248 @@ +import 'package:sqflite/sqflite.dart'; + +import '../../domain/entities/customer.dart'; +import '../../domain/entities/product.dart'; +import 'app_database.dart'; + +/// Reads and writes the imported catalogue. +class CatalogueDao { + const CatalogueDao(this._db); + + final Database _db; + + // --------------------------------------------------------------- Mapping + static Map productToRow(Product p) => { + 'id': p.id, + 'name': p.name, + 'barcode': p.barcode, + 'sku': p.sku, + 'category': p.category.name, + 'price': p.price, + 'mrp': p.mrp, + 'stock': p.stock, + 'emoji': p.emoji, + 'image_url': p.imageUrl, + 'unit': p.unit.name, + 'gst_rate': p.gstRate, + 'brand': p.brand, + 'is_active': p.isActive ? 1 : 0, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }; + + static Product productFromRow(Map r) => Product( + id: r['id']! as String, + name: r['name']! as String, + barcode: r['barcode']! as String, + sku: r['sku']! as String, + category: ProductCategory.values.byName(r['category']! as String), + price: (r['price']! as num).toDouble(), + mrp: (r['mrp'] as num?)?.toDouble(), + stock: (r['stock']! as num).toDouble(), + emoji: (r['emoji'] as String?) ?? '📦', + imageUrl: r['image_url'] as String?, + unit: UnitOfMeasure.values.byName((r['unit'] as String?) ?? 'piece'), + gstRate: (r['gst_rate']! as num).toDouble(), + brand: r['brand'] as String?, + isActive: (r['is_active']! as int) == 1, + ); + + static Map customerToRow(Customer c) => { + 'id': c.id, + 'name': c.name, + 'mobile': c.mobile, + 'email': c.email, + 'gender': c.gender.name, + 'date_of_birth': c.dateOfBirth?.millisecondsSinceEpoch, + 'loyalty_points': c.loyaltyPoints, + 'lifetime_spend': c.lifetimeSpend, + 'visit_count': c.visitCount, + 'created_at': c.createdAt?.millisecondsSinceEpoch, + 'last_visit_at': c.lastVisitAt?.millisecondsSinceEpoch, + }; + + static Customer customerFromRow(Map r) => Customer( + id: r['id']! as String, + name: r['name']! as String, + mobile: r['mobile']! as String, + email: r['email'] as String?, + gender: Gender.values.byName((r['gender'] as String?) ?? 'unspecified'), + dateOfBirth: _date(r['date_of_birth']), + loyaltyPoints: (r['loyalty_points'] as int?) ?? 0, + lifetimeSpend: (r['lifetime_spend'] as num?)?.toDouble() ?? 0, + visitCount: (r['visit_count'] as int?) ?? 0, + createdAt: _date(r['created_at']), + lastVisitAt: _date(r['last_visit_at']), + ); + + static DateTime? _date(Object? millis) => millis == null + ? null + : DateTime.fromMillisecondsSinceEpoch(millis! as int); + + // -------------------------------------------------------------- Products + Future> allProducts() async { + final rows = await _db.query( + Tables.products, + where: 'is_active = 1', + orderBy: 'category, name', + ); + return rows.map(productFromRow).toList(); + } + + Future productByBarcode(String barcode) async { + final rows = await _db.query( + Tables.products, + where: 'barcode = ? AND is_active = 1', + whereArgs: [barcode.trim()], + limit: 1, + ); + return rows.isEmpty ? null : productFromRow(rows.first); + } + + Future productById(String id) async { + final rows = await _db.query( + Tables.products, + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + return rows.isEmpty ? null : productFromRow(rows.first); + } + + Future productCount() async { + final r = await _db.rawQuery( + 'SELECT COUNT(*) AS c FROM ${Tables.products} WHERE is_active = 1', + ); + return (r.first['c']! as int); + } + + Future upsertProduct(Product p) async { + await _db.insert( + Tables.products, + productToRow(p), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + /// Writes the pulled catalogue in one transaction. + /// + /// Stock already decremented by local sales is preserved for products that + /// survive the import, so re-importing mid-shift cannot resurrect sold units. + Future replaceCatalogue({ + required List products, + required List customers, + }) async { + await _db.transaction((txn) async { + final existing = await txn.query( + Tables.products, + columns: ['id', 'stock'], + ); + final heldStock = { + for (final r in existing) r['id']! as String: (r['stock']! as num).toDouble(), + }; + + final batch = txn.batch(); + for (final p in products) { + final held = heldStock[p.id]; + batch.insert( + Tables.products, + productToRow(held == null ? p : p.copyWith(stock: held)), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + // Locally registered customers must survive a server pull, so this + // ignores rows that already exist rather than replacing them. + for (final c in customers) { + batch.insert( + Tables.customers, + customerToRow(c), + conflictAlgorithm: ConflictAlgorithm.ignore, + ); + } + + await batch.commit(noResult: true); + }); + } + + /// Applies stock movement after a sale, clamped at zero. + Future decrementStock(Map quantities) async { + if (quantities.isEmpty) return; + await _db.transaction((txn) async { + final batch = txn.batch(); + quantities.forEach((id, qty) { + batch.rawUpdate( + 'UPDATE ${Tables.products} ' + 'SET stock = MAX(0, stock - ?), updated_at = ? WHERE id = ?', + [qty, DateTime.now().millisecondsSinceEpoch, id], + ); + }); + await batch.commit(noResult: true); + }); + } + + // ------------------------------------------------------------- Customers + Future> allCustomers({int limit = 500}) async { + final rows = await _db.query( + Tables.customers, + orderBy: 'last_visit_at DESC', + limit: limit, + ); + return rows.map(customerFromRow).toList(); + } + + Future customerByMobile(String mobile) async { + final digits = mobile.replaceAll(RegExp(r'\D'), ''); + final rows = await _db.query( + Tables.customers, + where: 'mobile = ?', + whereArgs: [digits], + limit: 1, + ); + return rows.isEmpty ? null : customerFromRow(rows.first); + } + + Future customerById(String id) async { + final rows = await _db.query( + Tables.customers, + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + return rows.isEmpty ? null : customerFromRow(rows.first); + } + + Future upsertCustomer(Customer c) async { + await _db.insert( + Tables.customers, + customerToRow(c), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + // ------------------------------------------------------------------ Meta + Future meta(String key) async { + final rows = await _db.query( + Tables.meta, + where: 'key = ?', + whereArgs: [key], + limit: 1, + ); + return rows.isEmpty ? null : rows.first['value'] as String?; + } + + Future setMeta(String key, String value) async { + await _db.insert( + Tables.meta, + {'key': key, 'value': value}, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + /// Monotonic invoice counter held in the meta table. + Future nextInvoiceSequence() async { + final current = int.tryParse(await meta(MetaKeys.invoiceSequence) ?? '0') ?? 0; + final next = current + 1; + await setMeta(MetaKeys.invoiceSequence, '$next'); + return next; + } +} diff --git a/lib/data/local/order_dao.dart b/lib/data/local/order_dao.dart new file mode 100644 index 0000000..c6ce57e --- /dev/null +++ b/lib/data/local/order_dao.dart @@ -0,0 +1,367 @@ +import 'dart:convert'; + +import 'package:sqflite/sqflite.dart'; + +import '../../domain/entities/cart.dart'; +import '../../domain/entities/customer.dart'; +import '../../domain/entities/product.dart'; +import '../../domain/entities/transaction.dart'; +import 'app_database.dart'; +import 'catalogue_dao.dart'; + +/// Persists bills. +/// +/// Every completed sale lands here with `sync_status = 0`. The end-of-day +/// upload selects those rows, sends them, and flips the accepted ones to 1. +/// Nothing is ever deleted as part of syncing. +class OrderDao { + const OrderDao(this._db); + + final Database _db; + + static const int pending = 0; + static const int synced = 1; + + static String businessDateOf(DateTime dt) => + '${dt.year.toString().padLeft(4, '0')}-' + '${dt.month.toString().padLeft(2, '0')}-' + '${dt.day.toString().padLeft(2, '0')}'; + + // ----------------------------------------------------------------- Write + /// Writes the bill and its lines atomically. + Future insertOrder(SaleTransaction t) async { + final cart = t.cart; + + await _db.transaction((txn) async { + await txn.insert( + Tables.orders, + { + 'id': t.id, + 'invoice_number': t.invoiceNumber, + 'created_at': t.createdAt.millisecondsSinceEpoch, + 'business_date': businessDateOf(t.createdAt), + 'cashier_name': t.cashierName, + 'terminal_id': t.terminalId, + 'customer_id': cart.customer?.id, + 'customer_mobile': cart.customer?.mobile, + 'customer_name': cart.customer?.name, + 'subtotal': cart.subtotal, + 'line_discount': cart.lineDiscountTotal, + 'bill_discount': cart.billDiscountTotal, + 'loyalty_value': cart.loyaltyRedemptionValue, + 'taxable_amount': cart.taxableAmount, + 'tax_amount': cart.taxAmount, + 'round_off': cart.roundOff, + 'total': t.total, + 'points_earned': cart.pointsEarned, + 'points_redeemed': cart.pointsRedeemed, + 'payments_json': jsonEncode([ + for (final p in t.payments) + { + 'method': p.method.name, + 'amount': p.amount, + 'tendered': p.tendered, + 'reference': p.reference, + }, + ]), + 'status': t.status.name, + 'sync_status': pending, + 'sync_attempts': 0, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + + final batch = txn.batch(); + for (final line in cart.lines) { + batch.insert(Tables.orderItems, { + 'order_id': t.id, + 'product_id': line.product.id, + 'name': line.product.name, + 'barcode': line.product.barcode, + 'sku': line.product.sku, + 'unit': line.product.unit.name, + 'unit_price': line.product.price, + 'quantity': line.quantity, + 'discount': line.discountAmount, + 'gst_rate': line.product.gstRate, + 'tax_amount': line.taxAmount, + 'line_total': line.payable, + }); + } + await batch.commit(noResult: true); + }); + } + + // ------------------------------------------------------------------ Read + Future> recent({int limit = 100}) => + _query(orderBy: 'created_at DESC', limit: limit); + + Future> forBusinessDate(DateTime day) => + _query(where: 'business_date = ?', whereArgs: [businessDateOf(day)]); + + /// The end-of-day upload set. + Future> unsynced({int limit = 500}) => _query( + where: 'sync_status = ?', + whereArgs: [pending], + orderBy: 'created_at ASC', + limit: limit, + ); + + Future byInvoice(String invoiceNumber) async { + final rows = await _query( + where: 'invoice_number = ?', + whereArgs: [invoiceNumber], + limit: 1, + ); + return rows.isEmpty ? null : rows.first; + } + + Future unsyncedCount() async { + final r = await _db.rawQuery( + 'SELECT COUNT(*) AS c FROM ${Tables.orders} WHERE sync_status = ?', + [pending], + ); + return r.first['c']! as int; + } + + Future salesTotalForDay(DateTime day) async { + final r = await _db.rawQuery( + 'SELECT COALESCE(SUM(total), 0) AS t FROM ${Tables.orders} ' + "WHERE business_date = ? AND status = 'completed'", + [businessDateOf(day)], + ); + return (r.first['t']! as num).toDouble(); + } + + /// Sync state of an order, for display in the events log. + Future?> syncRow(String orderId) async { + final rows = await _db.query( + Tables.orders, + columns: [ + 'id', + 'invoice_number', + 'total', + 'created_at', + 'sync_status', + 'synced_at', + 'sync_attempts', + 'sync_error', + ], + where: 'id = ?', + whereArgs: [orderId], + limit: 1, + ); + return rows.isEmpty ? null : rows.first; + } + + Future>> syncRows({int limit = 200}) => _db.query( + Tables.orders, + columns: [ + 'id', + 'invoice_number', + 'total', + 'created_at', + 'sync_status', + 'synced_at', + 'sync_attempts', + 'sync_error', + ], + orderBy: 'created_at DESC', + limit: limit, + ); + + // ----------------------------------------------------------------- Sync + /// Flips accepted orders to `sync_status = 1`. + Future markSynced(List orderIds) async { + if (orderIds.isEmpty) return; + final now = DateTime.now().millisecondsSinceEpoch; + final placeholders = List.filled(orderIds.length, '?').join(','); + + await _db.rawUpdate( + 'UPDATE ${Tables.orders} SET sync_status = ?, synced_at = ?, ' + 'sync_error = NULL WHERE id IN ($placeholders)', + [synced, now, ...orderIds], + ); + } + + /// Records a failed attempt. The rows stay at `sync_status = 0`. + Future markFailed(List orderIds, String error) async { + if (orderIds.isEmpty) return; + final placeholders = List.filled(orderIds.length, '?').join(','); + + await _db.rawUpdate( + 'UPDATE ${Tables.orders} SET sync_attempts = sync_attempts + 1, ' + 'sync_error = ? WHERE id IN ($placeholders)', + [error, ...orderIds], + ); + } + + // ---------------------------------------------------------- Parked bills + Future park(ParkedBill bill) async { + await _db.insert( + Tables.parkedBills, + { + 'id': bill.id, + 'label': bill.label, + 'parked_at': bill.parkedAt.millisecondsSinceEpoch, + 'cart_json': jsonEncode(_cartToJson(bill.cart)), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Future> parkedBills() async { + final rows = + await _db.query(Tables.parkedBills, orderBy: 'parked_at DESC'); + return rows + .map((r) => ParkedBill( + id: r['id']! as String, + label: r['label'] as String?, + parkedAt: + DateTime.fromMillisecondsSinceEpoch(r['parked_at']! as int), + cart: _cartFromJson( + jsonDecode(r['cart_json']! as String) as Map, + ), + )) + .toList(); + } + + Future removeParked(String id) async { + await _db.delete(Tables.parkedBills, where: 'id = ?', whereArgs: [id]); + } + + // -------------------------------------------------------------- Internals + Future> _query({ + String? where, + List? whereArgs, + String orderBy = 'created_at DESC', + int? limit, + }) async { + final orders = await _db.query( + Tables.orders, + where: where, + whereArgs: whereArgs, + orderBy: orderBy, + limit: limit, + ); + if (orders.isEmpty) return const []; + + final ids = orders.map((o) => o['id']! as String).toList(); + final placeholders = List.filled(ids.length, '?').join(','); + final items = await _db.query( + Tables.orderItems, + where: 'order_id IN ($placeholders)', + whereArgs: ids, + ); + + final byOrder = >>{}; + for (final i in items) { + byOrder.putIfAbsent(i['order_id']! as String, () => []).add(i); + } + + return orders + .map((o) => _transactionFromRows(o, byOrder[o['id']] ?? const [])) + .toList(); + } + + SaleTransaction _transactionFromRows( + Map o, + List> items, + ) { + // Lines are rebuilt from the snapshot taken at sale time, not from the + // current catalogue, so a reprint always shows what was actually charged. + final lines = items.map((i) { + final product = Product( + id: i['product_id']! as String, + name: i['name']! as String, + barcode: i['barcode']! as String, + sku: i['sku']! as String, + category: ProductCategory.grocery, + price: (i['unit_price']! as num).toDouble(), + stock: 0, + unit: UnitOfMeasure.values.byName(i['unit']! as String), + gstRate: (i['gst_rate']! as num).toDouble(), + ); + return CartLine( + product: product, + quantity: (i['quantity']! as num).toDouble(), + discount: (i['discount']! as num) > 0 + ? Discount( + type: DiscountType.flat, + value: (i['discount']! as num).toDouble(), + ) + : Discount.none, + ); + }).toList(); + + final customerId = o['customer_id'] as String?; + final customer = customerId == null + ? null + : Customer( + id: customerId, + name: (o['customer_name'] as String?) ?? 'Customer', + mobile: (o['customer_mobile'] as String?) ?? '', + ); + + final payments = (jsonDecode(o['payments_json']! as String) as List) + .cast>() + .map((p) => PaymentSplit( + method: PaymentMethod.values.byName(p['method']! as String), + amount: (p['amount']! as num).toDouble(), + tendered: (p['tendered'] as num?)?.toDouble(), + reference: p['reference'] as String?, + )) + .toList(); + + return SaleTransaction( + id: o['id']! as String, + invoiceNumber: o['invoice_number']! as String, + cart: Cart(lines: lines, customer: customer), + payments: payments, + createdAt: DateTime.fromMillisecondsSinceEpoch(o['created_at']! as int), + cashierName: o['cashier_name']! as String, + terminalId: o['terminal_id']! as String, + status: TransactionStatus.values.byName(o['status']! as String), + ); + } + + Map _cartToJson(Cart cart) => { + 'customer': cart.customer == null + ? null + : CatalogueDao.customerToRow(cart.customer!), + 'points_redeemed': cart.pointsRedeemed, + 'note': cart.note, + 'lines': [ + for (final l in cart.lines) + { + 'product': CatalogueDao.productToRow(l.product), + 'quantity': l.quantity, + 'discount_type': l.discount.type.name, + 'discount_value': l.discount.value, + }, + ], + }; + + Cart _cartFromJson(Map j) { + final customerRow = j['customer'] as Map?; + return Cart( + customer: customerRow == null + ? null + : CatalogueDao.customerFromRow(customerRow), + pointsRedeemed: (j['points_redeemed'] as int?) ?? 0, + note: j['note'] as String?, + lines: (j['lines'] as List).cast>().map((l) { + return CartLine( + product: CatalogueDao.productFromRow( + l['product']! as Map, + ), + quantity: (l['quantity']! as num).toDouble(), + discount: Discount( + type: DiscountType.values.byName(l['discount_type']! as String), + value: (l['discount_value']! as num).toDouble(), + ), + ); + }).toList(), + ); + } +} diff --git a/lib/data/repositories/customer_repository_impl.dart b/lib/data/repositories/customer_repository_impl.dart index 83d543b..1ab993a 100644 --- a/lib/data/repositories/customer_repository_impl.dart +++ b/lib/data/repositories/customer_repository_impl.dart @@ -43,13 +43,13 @@ class CustomerRepositoryImpl implements CustomerRepository { visitCount: 0, createdAt: DateTime.now(), ); - _store.putCustomer(created); + await _store.putCustomer(created); return created; } @override Future update(Customer customer) async { - _store.putCustomer(customer); + await _store.putCustomer(customer); return customer; } @@ -72,7 +72,7 @@ class CustomerRepositoryImpl implements CustomerRepository { visitCount: current.visitCount + 1, lastVisitAt: DateTime.now(), ); - _store.putCustomer(updated); + await _store.putCustomer(updated); return updated; } diff --git a/lib/data/repositories/product_repository_impl.dart b/lib/data/repositories/product_repository_impl.dart index 442aca0..d509a5f 100644 --- a/lib/data/repositories/product_repository_impl.dart +++ b/lib/data/repositories/product_repository_impl.dart @@ -1,8 +1,9 @@ -import '../../core/utils/extensions.dart'; import '../../domain/entities/product.dart'; import '../../domain/repositories/product_repository.dart'; import '../datasources/local_store.dart'; +/// Reads come from the in-memory catalogue for speed; writes go through the +/// store, which persists to SQLite before touching the cache. class ProductRepositoryImpl implements ProductRepository { ProductRepositoryImpl(this._store); @@ -19,12 +20,8 @@ class ProductRepositoryImpl implements ProductRepository { .toList(); @override - Future findByBarcode(String barcode) async { - final needle = barcode.trim(); - return _store.products.firstWhereOrNull( - (p) => p.barcode == needle && p.isActive, - ); - } + Future findByBarcode(String barcode) async => + _store.productByBarcode(barcode); @override Future findById(String id) async => _store.productById(id); @@ -34,15 +31,13 @@ class ProductRepositoryImpl implements ProductRepository { final q = query.trim(); if (q.isEmpty) return getAll(); - final results = _store.products.where((p) => p.isActive && p.matches(q)); - - // Rank exact barcode and SKU hits above fuzzy name matches so the top - // result is the one the cashier almost certainly meant. - final ranked = results.toList() + final ranked = _store.products.where((p) => p.isActive && p.matches(q)).toList() ..sort((a, b) => _score(b, q).compareTo(_score(a, q))); return ranked; } + /// Exact barcode and SKU hits rank above fuzzy name matches, so the top + /// result is the one the cashier almost certainly meant. int _score(Product p, String q) { final lq = q.toLowerCase(); if (p.barcode == q) return 100; @@ -54,15 +49,9 @@ class ProductRepositoryImpl implements ProductRepository { } @override - Future decrementStock(Map quantities) async { - quantities.forEach((id, qty) { - final p = _store.productById(id); - if (p == null) return; - final next = (p.stock - qty).clamp(0, double.infinity).toDouble(); - _store.putProduct(p.copyWith(stock: next)); - }); - } + Future decrementStock(Map quantities) => + _store.applyStockMovement(quantities); @override - Future upsert(Product product) async => _store.putProduct(product); + Future upsert(Product product) => _store.putProduct(product); } diff --git a/lib/data/repositories/sync_repository_impl.dart b/lib/data/repositories/sync_repository_impl.dart index 6f16c1b..bd9bb97 100644 --- a/lib/data/repositories/sync_repository_impl.dart +++ b/lib/data/repositories/sync_repository_impl.dart @@ -3,19 +3,25 @@ import 'package:uuid/uuid.dart'; import '../../core/utils/formatters.dart'; import '../../domain/entities/shift_report.dart'; import '../../domain/entities/sync_event.dart'; +import '../../domain/entities/transaction.dart'; import '../../domain/repositories/sync_repository.dart'; import '../datasources/local_store.dart'; import '../datasources/remote_catalogue_source.dart'; +import '../local/order_dao.dart'; class SyncRepositoryImpl implements SyncRepository { - SyncRepositoryImpl(this._store, this._catalogue, this._reports); + SyncRepositoryImpl(this._store, this._catalogue, this._orderSink); final LocalStore _store; final RemoteCatalogueSource _catalogue; - final RemoteReportSink _reports; + final RemoteOrderSink _orderSink; static const _uuid = Uuid(); + /// In-session event log. Order sync state itself lives on the order rows, + /// so this is only a human-readable history of attempts. + final List _events = []; + @override bool get hasCatalogue => _store.hasCatalogue; @@ -26,134 +32,216 @@ class SyncRepositoryImpl implements SyncRepository { String? get catalogueRevision => _store.catalogueRevision; @override - List get events => _store.events; + List get events => List.unmodifiable(_events.reversed); - @override - bool get hasUnsyncedEvents => _store.hasUnsyncedEvents; + void _log(SyncEvent e) => _events.add(e); + // ------------------------------------------------------- Morning: import @override Future importCatalogue({ void Function(double progress, String stage)? onProgress, }) async { - final event = SyncEvent( - id: _uuid.v4(), - type: SyncEventType.catalogueImport, - status: SyncStatus.syncing, - createdAt: DateTime.now(), - summary: 'Catalogue import started', - ); - _store.addEvent(event); + final id = _uuid.v4(); + final started = DateTime.now(); try { final snapshot = await _catalogue.fetch(onProgress: onProgress); - _store.importCatalogue( + await _store.importCatalogue( products: snapshot.products, customers: snapshot.customers, revision: snapshot.revision, at: snapshot.fetchedAt, ); - final done = event.copyWith( + final event = SyncEvent( + id: id, + type: SyncEventType.catalogueImport, status: SyncStatus.synced, + createdAt: started, syncedAt: DateTime.now(), - attempts: 1, - ); - final settled = SyncEvent( - id: done.id, - type: done.type, - status: done.status, - createdAt: done.createdAt, - summary: '${snapshot.products.length} products, ' - '${snapshot.customers.length} customers · ${snapshot.revision}', + summary: '${snapshot.products.length} products saved to SQLite ' + '· ${snapshot.revision}', payload: { 'products': snapshot.products.length, 'customers': snapshot.customers.length, 'revision': snapshot.revision, }, - syncedAt: done.syncedAt, attempts: 1, ); - _store.updateEvent(settled); - return settled; + _log(event); + return event; } catch (e) { - final failed = event.copyWith( + final event = SyncEvent( + id: id, + type: SyncEventType.catalogueImport, status: SyncStatus.failed, + createdAt: started, + summary: 'Catalogue import failed', error: e.toString(), attempts: 1, ); - _store.updateEvent(failed); - return failed; + _log(event); + return event; } } + // --------------------------------------------------- Business hours: read @override - ShiftReport buildShiftReport({ - required DateTime businessDate, + Future unsyncedCount() => _store.orders.unsyncedCount(); + + @override + Future> unsyncedOrders() => _store.orders.unsynced(); + + @override + Future todayReport({ required String terminalId, required String cashierName, - }) { + }) async { + final today = DateTime.now(); + final orders = await _store.orders.forBusinessDate(today); + return ShiftReport.fromTransactions( - transactions: _store.transactions, - businessDate: businessDate, + transactions: orders, + businessDate: today, terminalId: terminalId, cashierName: cashierName, ); } @override - Future pushShiftReport(ShiftReport report) async { - // Queued first, so the data is durable before the network is touched. - final queued = SyncEvent( - id: _uuid.v4(), - type: SyncEventType.shiftReport, - status: SyncStatus.pending, - createdAt: DateTime.now(), - summary: '${report.billCount} bills · ' - '${Formatters.money(report.grossSales)} · ' - '${Formatters.date(report.businessDate)}', - payload: report.toPayload(), - ); - _store.addEvent(queued); - - return _attempt(queued); + Future> orderSyncRows({int limit = 200}) async { + final rows = await _store.orders.syncRows(limit: limit); + return rows + .map((r) => OrderSyncRow( + orderId: r['id']! as String, + invoiceNumber: r['invoice_number']! as String, + total: (r['total']! as num).toDouble(), + createdAt: + DateTime.fromMillisecondsSinceEpoch(r['created_at']! as int), + isSynced: (r['sync_status']! as int) == OrderDao.synced, + syncedAt: r['synced_at'] == null + ? null + : DateTime.fromMillisecondsSinceEpoch(r['synced_at']! as int), + attempts: (r['sync_attempts'] as int?) ?? 0, + error: r['sync_error'] as String?, + )) + .toList(); } + // ------------------------------------------------------ End of day: sync @override - Future retry(String eventId) async { - final matches = _store.events.where((e) => e.id == eventId).toList(); - if (matches.isEmpty) { - throw StateError('No queued event with id $eventId'); - } - final event = matches.first; - if (event.type == SyncEventType.catalogueImport) { - return importCatalogue(); - } - return _attempt(event); - } + Future syncOrders({ + void Function(double progress, String stage)? onProgress, + }) async { + onProgress?.call(0.05, 'Collecting unsynced bills…'); - /// Sends one event, leaving it queued if the push fails. - Future _attempt(SyncEvent event) async { - _store.updateEvent(event.copyWith(status: SyncStatus.syncing)); + final pending = await _store.orders.unsynced(); + if (pending.isEmpty) { + onProgress?.call(1, 'Nothing to upload'); + return const SyncOutcome(attempted: 0, uploaded: 0); + } + + final started = DateTime.now(); + final ids = pending.map((o) => o.id).toList(); + + onProgress?.call(0.35, 'Uploading ${pending.length} bills…'); try { - await _reports.push(event.payload); - final done = event.copyWith( + final accepted = await _orderSink.pushOrders( + pending.map(_orderToPayload).toList(), + ); + + onProgress?.call(0.85, 'Marking bills as synced…'); + + // Only what the server confirmed is flipped to 1. + await _store.orders.markSynced(accepted); + await _store.refreshUnsyncedCount(); + + final rejected = ids.where((id) => !accepted.contains(id)).toList(); + if (rejected.isNotEmpty) { + await _store.orders.markFailed(rejected, 'Rejected by server'); + } + + onProgress?.call(1, 'Done'); + + _log(SyncEvent( + id: _uuid.v4(), + type: SyncEventType.shiftReport, status: SyncStatus.synced, + createdAt: started, syncedAt: DateTime.now(), - attempts: event.attempts + 1, - clearError: true, - ); - _store.updateEvent(done); - return done.copyWith(); + summary: '${accepted.length} of ${pending.length} bills uploaded', + attempts: 1, + )); + + return SyncOutcome(attempted: pending.length, uploaded: accepted.length); } catch (e) { - final failed = event.copyWith( + // Transport failed: record the attempt but leave every row at 0. + await _store.orders.markFailed(ids, e.toString()); + await _store.refreshUnsyncedCount(); + + _log(SyncEvent( + id: _uuid.v4(), + type: SyncEventType.shiftReport, status: SyncStatus.failed, + createdAt: started, + summary: '${pending.length} bills still pending ' + '(${Formatters.money(pending.fold(0, (s, o) => s + o.total))})', + error: e.toString(), + attempts: 1, + )); + + return SyncOutcome( + attempted: pending.length, + uploaded: 0, error: e.toString(), - attempts: event.attempts + 1, ); - _store.updateEvent(failed); - return failed; } } + + /// The JSON body sent per order. + Map _orderToPayload(SaleTransaction t) => { + 'id': t.id, + 'invoice_number': t.invoiceNumber, + 'created_at': t.createdAt.toIso8601String(), + 'terminal_id': t.terminalId, + 'cashier': t.cashierName, + 'customer': t.customer == null + ? null + : { + 'id': t.customer!.id, + 'mobile': t.customer!.mobile, + 'name': t.customer!.name, + }, + 'subtotal': t.cart.subtotal, + 'discount': t.cart.billDiscountTotal + t.cart.lineDiscountTotal, + 'tax': t.cart.taxAmount, + 'round_off': t.cart.roundOff, + 'total': t.total, + 'points_earned': t.pointsEarned, + 'points_redeemed': t.pointsRedeemed, + 'payments': [ + for (final p in t.payments) + { + 'method': p.method.name, + 'amount': p.amount, + 'reference': p.reference, + }, + ], + 'items': [ + for (final l in t.cart.lines) + { + 'product_id': l.product.id, + 'barcode': l.product.barcode, + 'name': l.product.name, + 'quantity': l.quantity, + 'unit_price': l.product.price, + 'discount': l.discountAmount, + 'gst_rate': l.product.gstRate, + 'tax': l.taxAmount, + 'line_total': l.payable, + }, + ], + }; } diff --git a/lib/data/repositories/transaction_repository_impl.dart b/lib/data/repositories/transaction_repository_impl.dart index e23584b..5bd390a 100644 --- a/lib/data/repositories/transaction_repository_impl.dart +++ b/lib/data/repositories/transaction_repository_impl.dart @@ -1,8 +1,8 @@ -import '../../core/utils/extensions.dart'; import '../../domain/entities/transaction.dart'; import '../../domain/repositories/transaction_repository.dart'; import '../datasources/local_store.dart'; +/// All bill persistence goes straight to SQLite. class TransactionRepositoryImpl implements TransactionRepository { TransactionRepositoryImpl(this._store); @@ -10,40 +10,33 @@ class TransactionRepositoryImpl implements TransactionRepository { @override Future save(SaleTransaction transaction) async { - _store.addTransaction(transaction); + // Written with sync_status = 0; the end-of-day upload picks it up. + await _store.orders.insertOrder(transaction); + await _store.refreshUnsyncedCount(); return transaction; } @override - Future> history({int limit = 50}) async => - _store.transactions.take(limit).toList(); + Future> history({int limit = 50}) => + _store.orders.recent(limit: limit); @override - Future findByInvoice(String invoiceNumber) async => - _store.transactions - .firstWhereOrNull((t) => t.invoiceNumber == invoiceNumber); + Future findByInvoice(String invoiceNumber) => + _store.orders.byInvoice(invoiceNumber); @override - Future nextInvoiceSequence() async => _store.nextInvoiceSequence(); + Future nextInvoiceSequence() => _store.catalogue.nextInvoiceSequence(); @override - Future park(ParkedBill bill) async => _store.addParked(bill); + Future park(ParkedBill bill) => _store.orders.park(bill); @override - Future> parkedBills() async => _store.parked; + Future> parkedBills() => _store.orders.parkedBills(); @override - Future removeParked(String id) async => _store.removeParked(id); + Future removeParked(String id) => _store.orders.removeParked(id); @override - Future salesTotalForDay(DateTime day) async { - return _store.transactions - .where((t) => - t.status == TransactionStatus.completed && - t.createdAt.year == day.year && - t.createdAt.month == day.month && - t.createdAt.day == day.day) - .fold(0.0, (sum, t) => sum + t.total) - .asMoney; - } + Future salesTotalForDay(DateTime day) => + _store.orders.salesTotalForDay(day); } diff --git a/lib/domain/repositories/sync_repository.dart b/lib/domain/repositories/sync_repository.dart index 799c7ed..ba3d591 100644 --- a/lib/domain/repositories/sync_repository.dart +++ b/lib/domain/repositories/sync_repository.dart @@ -1,38 +1,79 @@ import '../entities/shift_report.dart'; import '../entities/sync_event.dart'; +import '../entities/transaction.dart'; -/// The terminal's two network touchpoints, plus the durable event log. +/// Result of one end-of-day upload. +class SyncOutcome { + const SyncOutcome({ + required this.attempted, + required this.uploaded, + this.error, + }); + + final int attempted; + final int uploaded; + final String? error; + + bool get isSuccess => error == null; + bool get hadNothingToDo => attempted == 0; + int get remaining => attempted - uploaded; +} + +/// One row of the order sync log. +class OrderSyncRow { + const OrderSyncRow({ + required this.orderId, + required this.invoiceNumber, + required this.total, + required this.createdAt, + required this.isSynced, + this.syncedAt, + this.attempts = 0, + this.error, + }); + + final String orderId; + final String invoiceNumber; + final double total; + final DateTime createdAt; + final bool isSynced; + final DateTime? syncedAt; + final int attempts; + final String? error; +} + +/// The terminal's two network touchpoints. +/// +/// Morning: pull the catalogue. End of day: upload every order still at +/// `sync_status = 0`. Nothing else leaves the device. abstract class SyncRepository { - /// True once products have been pulled onto this terminal. bool get hasCatalogue; - DateTime? get lastImportAt; String? get catalogueRevision; - /// Pulls the catalogue and writes it locally. - /// - /// Records a [SyncEventType.catalogueImport] event whether or not it - /// succeeds, so the log reflects every attempt. + /// Morning step — downloads products and writes them to SQLite. Future importCatalogue({ void Function(double progress, String stage)? onProgress, }); - /// Builds the day's report from locally stored sales. - ShiftReport buildShiftReport({ - required DateTime businessDate, + /// How many bills are still held locally. + Future unsyncedCount(); + + Future> unsyncedOrders(); + + /// Today's trading totals, read back from SQLite. + Future todayReport({ required String terminalId, required String cashierName, }); - /// Queues the report and attempts to push it. - /// - /// On failure the event is kept in [SyncStatus.failed] so nothing is lost. - Future pushShiftReport(ShiftReport report); + /// End-of-day step — uploads pending orders and flips the accepted ones to + /// `sync_status = 1`. Failures leave every row untouched at 0. + Future syncOrders({ + void Function(double progress, String stage)? onProgress, + }); - /// Retries a previously failed or pending push. - Future retry(String eventId); + Future> orderSyncRows({int limit = 200}); List get events; - - bool get hasUnsyncedEvents; } diff --git a/lib/main.dart b/lib/main.dart index 9ecd689..0155b97 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,8 @@ Future main() async { await _configureChrome(); + // Opens SQLite and loads the catalogue into memory. Any bill written on a + // previous run is still on disk and still counted as unsynced. await LocalStore.instance.init(); await SoundService.instance.preload(); diff --git a/lib/presentation/auth/screens/login_screen.dart b/lib/presentation/auth/screens/login_screen.dart index 6a68ac1..b98e6f0 100644 --- a/lib/presentation/auth/screens/login_screen.dart +++ b/lib/presentation/auth/screens/login_screen.dart @@ -44,7 +44,7 @@ class _LoginScreenState extends ConsumerState { password: _password.text, ); - if (ok && mounted) context.go(AppRoutes.welcome); + if (ok && mounted) context.go(AppRoutes.pos); } @override diff --git a/lib/presentation/customer/screens/customer_registration_screen.dart b/lib/presentation/customer/screens/customer_registration_screen.dart deleted file mode 100644 index c7785bd..0000000 --- a/lib/presentation/customer/screens/customer_registration_screen.dart +++ /dev/null @@ -1,321 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../../app/providers.dart'; -import '../../../core/router/app_router.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_dimens.dart'; -import '../../../core/utils/extensions.dart'; -import '../../../core/utils/formatters.dart'; -import '../../../core/utils/validators.dart'; -import '../../../core/widgets/glass_card.dart'; -import '../../../core/widgets/primary_button.dart'; -import '../../../domain/entities/customer.dart'; -import '../../pos/providers/cart_controller.dart'; -import '../providers/customer_providers.dart'; - -/// Screen 2 — registers a shopper and drops straight into billing. -class CustomerRegistrationScreen extends ConsumerStatefulWidget { - const CustomerRegistrationScreen({super.key, this.prefillMobile}); - - final String? prefillMobile; - - @override - ConsumerState createState() => - _CustomerRegistrationScreenState(); -} - -class _CustomerRegistrationScreenState - extends ConsumerState { - final _formKey = GlobalKey(); - late final TextEditingController _mobile; - final _name = TextEditingController(); - final _email = TextEditingController(); - - Gender _gender = Gender.unspecified; - DateTime? _dob; - bool _saving = false; - String? _serverError; - - @override - void initState() { - super.initState(); - _mobile = TextEditingController(text: widget.prefillMobile ?? ''); - } - - @override - void dispose() { - _mobile.dispose(); - _name.dispose(); - _email.dispose(); - super.dispose(); - } - - Future _save() async { - setState(() => _serverError = null); - if (!(_formKey.currentState?.validate() ?? false)) return; - - setState(() => _saving = true); - try { - final customer = await ref.read(customerRepositoryProvider).create( - Customer( - id: '', - name: _name.text, - mobile: _mobile.text, - email: _email.text, - gender: _gender, - dateOfBirth: _dob, - ), - ); - - ref.read(cartControllerProvider.notifier).attachCustomer(customer); - ref.invalidate(recentCustomersProvider); - - if (!mounted) return; - context.go(AppRoutes.pos); - } catch (e) { - if (!mounted) return; - setState(() { - _saving = false; - _serverError = e is StateError ? e.message : 'Could not save customer.'; - }); - } - } - - Future _pickDob() async { - final now = DateTime.now(); - final picked = await showDatePicker( - context: context, - initialDate: _dob ?? DateTime(now.year - 25), - firstDate: DateTime(now.year - 100), - lastDate: now, - helpText: 'Date of birth', - ); - if (picked != null) setState(() => _dob = picked); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.background, - appBar: AppBar( - title: const Text('New Customer'), - leading: IconButton( - icon: const Icon(Icons.arrow_back_rounded), - onPressed: () => context.pop(), - ), - ), - body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.xxl), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 640), - child: GlassCard( - padding: const EdgeInsets.all(AppSpacing.xxxl), - radius: AppRadius.xl, - shadows: AppColors.shadowMd, - child: Form( - key: _formKey, - autovalidateMode: AutovalidateMode.onUserInteraction, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Register a shopper', - style: context.text.headlineSmall), - const SizedBox(height: AppSpacing.xs), - Text( - 'Only the mobile number and name are required.', - style: context.text.bodySmall, - ), - const SizedBox(height: AppSpacing.xxl), - - _Field( - label: 'Mobile Number', - required: true, - child: TextFormField( - controller: _mobile, - autofocus: true, - keyboardType: TextInputType.phone, - maxLength: 10, - validator: Validators.mobile, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - ], - decoration: const InputDecoration( - hintText: '10-digit mobile number', - prefixText: '+91 ', - counterText: '', - prefixIcon: Icon(Icons.phone_outlined), - ), - ), - ), - - _Field( - label: 'Customer Name', - required: true, - child: TextFormField( - controller: _name, - textCapitalization: TextCapitalization.words, - validator: Validators.name, - decoration: const InputDecoration( - hintText: 'Full name', - prefixIcon: Icon(Icons.person_outline_rounded), - ), - ), - ), - - _Field( - label: 'Email', - child: TextFormField( - controller: _email, - keyboardType: TextInputType.emailAddress, - validator: Validators.emailOptional, - decoration: const InputDecoration( - hintText: 'name@example.com', - prefixIcon: Icon(Icons.mail_outline_rounded), - ), - ), - ), - - _Field( - label: 'Gender', - child: Wrap( - spacing: AppSpacing.sm, - children: Gender.values - .map((g) => ChoiceChip( - label: Text(g.label), - selected: _gender == g, - onSelected: (_) => - setState(() => _gender = g), - labelStyle: TextStyle( - color: _gender == g - ? Colors.white - : AppColors.textSecondary, - fontWeight: FontWeight.w600, - ), - )) - .toList(), - ), - ), - - _Field( - label: 'Date of Birth', - child: InkWell( - onTap: _pickDob, - borderRadius: AppRadius.brMd, - child: InputDecorator( - decoration: const InputDecoration( - prefixIcon: Icon(Icons.cake_outlined), - ), - child: Text( - _dob == null - ? 'Select a date (optional)' - : Formatters.date(_dob!), - style: TextStyle( - color: _dob == null - ? AppColors.textTertiary - : AppColors.textPrimary, - fontSize: 15, - ), - ), - ), - ), - ), - - if (_serverError != null) ...[ - const SizedBox(height: AppSpacing.sm), - Container( - width: double.infinity, - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppColors.dangerSurface, - borderRadius: AppRadius.brSm, - ), - child: Row(children: [ - const Icon(Icons.error_outline_rounded, - color: AppColors.danger, size: 18), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - _serverError!, - style: const TextStyle( - color: AppColors.danger, - fontSize: 13.5, - ), - ), - ), - ]), - ), - ], - - const SizedBox(height: AppSpacing.xxl), - Row(children: [ - Expanded( - child: PrimaryButton( - label: 'Cancel', - tone: ButtonTone.neutral, - onPressed: - _saving ? null : () => context.pop(), - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - flex: 2, - child: PrimaryButton( - label: 'Save & Continue', - icon: Icons.check_rounded, - busy: _saving, - onPressed: _save, - ), - ), - ]), - ], - ), - ), - ), - ), - ), - ), - ); - } -} - -class _Field extends StatelessWidget { - const _Field({ - required this.label, - required this.child, - this.required = false, - }); - - final String label; - final Widget child; - final bool required; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Text(label, - style: context.text.labelMedium - ?.copyWith(color: AppColors.textSecondary)), - if (required) - const Text(' *', - style: TextStyle(color: AppColors.danger, fontSize: 13)), - if (!required) - Text(' optional', - style: context.text.labelSmall - ?.copyWith(color: AppColors.textTertiary)), - ]), - const SizedBox(height: AppSpacing.sm), - child, - ], - ), - ); - } -} diff --git a/lib/presentation/customer/screens/existing_customer_screen.dart b/lib/presentation/customer/screens/existing_customer_screen.dart deleted file mode 100644 index 69f5bc9..0000000 --- a/lib/presentation/customer/screens/existing_customer_screen.dart +++ /dev/null @@ -1,504 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../../core/constants/app_constants.dart'; -import '../../../core/router/app_router.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_dimens.dart'; -import '../../../core/utils/extensions.dart'; -import '../../../core/utils/formatters.dart'; -import '../../../core/widgets/empty_state.dart'; -import '../../../core/widgets/glass_card.dart'; -import '../../../core/widgets/numeric_keypad.dart'; -import '../../../core/widgets/primary_button.dart'; -import '../../../core/widgets/status_pill.dart'; -import '../../../domain/entities/customer.dart'; -import '../../pos/providers/cart_controller.dart'; -import '../providers/customer_providers.dart'; - -/// Screen 3 — mobile lookup that auto-searches on the tenth digit. -class ExistingCustomerScreen extends ConsumerStatefulWidget { - const ExistingCustomerScreen({super.key}); - - @override - ConsumerState createState() => - _ExistingCustomerScreenState(); -} - -class _ExistingCustomerScreenState - extends ConsumerState { - String _digits = ''; - - void _append(String d) { - if (_digits.length >= AppConstants.mobileNumberLength) return; - setState(() => _digits += d); - if (_digits.length == AppConstants.mobileNumberLength) _search(); - } - - void _backspace() { - if (_digits.isEmpty) return; - setState(() => _digits = _digits.substring(0, _digits.length - 1)); - ref.read(customerLookupProvider.notifier).reset(); - } - - void _clear() { - setState(() => _digits = ''); - ref.read(customerLookupProvider.notifier).reset(); - } - - void _search() => ref.read(customerLookupProvider.notifier).search(_digits); - - void _continueWith(Customer customer) { - ref.read(cartControllerProvider.notifier).attachCustomer(customer); - context.go(AppRoutes.pos); - } - - @override - Widget build(BuildContext context) { - final lookup = ref.watch(customerLookupProvider); - - return Scaffold( - backgroundColor: AppColors.background, - appBar: AppBar( - title: const Text('Find Customer'), - leading: IconButton( - icon: const Icon(Icons.arrow_back_rounded), - onPressed: () => context.pop(), - ), - actions: [ - TextButton.icon( - onPressed: () { - ref.read(cartControllerProvider.notifier).attachCustomer(null); - context.go(AppRoutes.pos); - }, - icon: const Icon(Icons.directions_walk_rounded, - color: Colors.white, size: 18), - label: const Text('Continue as Walk-in', - style: TextStyle(color: Colors.white)), - ), - const SizedBox(width: AppSpacing.lg), - ], - ), - body: Padding( - padding: const EdgeInsets.all(AppSpacing.xxl), - child: context.isCompact - ? SingleChildScrollView( - child: Column(children: [ - _entryPanel(), - const SizedBox(height: AppSpacing.xxl), - // Bound the height: the panel uses Expanded/Spacer - // internally, which a scroll view cannot supply. - SizedBox(height: 480, child: _resultPanel(lookup)), - ]), - ) - : Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded(flex: 4, child: _entryPanel()), - const SizedBox(width: AppSpacing.xxl), - Expanded(flex: 5, child: _resultPanel(lookup)), - ], - ), - ), - ); - } - - Widget _entryPanel() { - return GlassCard( - padding: const EdgeInsets.all(AppSpacing.xxl), - radius: AppRadius.xl, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('Mobile number', style: context.text.labelMedium), - const SizedBox(height: AppSpacing.md), - _display(), - const SizedBox(height: AppSpacing.xxl), - Center( - child: NumericKeypad( - onKey: _append, - onBackspace: _backspace, - onClear: _clear, - onSubmit: - _digits.length == AppConstants.mobileNumberLength - ? _search - : null, - submitLabel: 'Search', - ), - ), - ], - ), - ); - } - - /// Ten slots so the cashier can see progress at a glance. - Widget _display() { - return Container( - height: 72, - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), - decoration: BoxDecoration( - color: AppColors.primarySurface, - borderRadius: AppRadius.brLg, - border: Border.all(color: AppColors.primaryBorder), - ), - child: Row(children: [ - const Text('+91', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, - color: AppColors.textSecondary, - )), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: List.generate( - AppConstants.mobileNumberLength, - (i) { - final filled = i < _digits.length; - return AnimatedContainer( - duration: AppMotion.fast, - width: 22, - alignment: Alignment.center, - child: Text( - filled ? _digits[i] : '–', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w700, - color: filled - ? AppColors.textPrimary - : AppColors.textTertiary.withValues(alpha: 0.5), - ), - ), - ); - }, - ), - ), - ), - if (_digits.isNotEmpty) - IconButton( - onPressed: _clear, - icon: const Icon(Icons.close_rounded, size: 20), - color: AppColors.textTertiary, - tooltip: 'Clear', - ), - ]), - ); - } - - Widget _resultPanel(CustomerLookupState state) { - return GlassCard( - padding: const EdgeInsets.all(AppSpacing.xxl), - radius: AppRadius.xl, - child: switch (state) { - LookupIdle() => _idle(), - LookupSearching() => const Center( - child: CircularProgressIndicator(color: AppColors.primary), - ), - LookupFound(:final customer) => _found(customer), - LookupNotFound(:final mobile) => _notFound(mobile), - LookupError(:final message) => EmptyState( - title: 'Something went wrong', - message: message, - emoji: '⚠️', - ), - }, - ); - } - - Widget _idle() { - final recent = ref.watch(recentCustomersProvider); - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('Recent customers', style: context.text.titleMedium), - const SizedBox(height: AppSpacing.xs), - Text('Tap to select, or key in a mobile number.', - style: context.text.bodySmall), - const SizedBox(height: AppSpacing.lg), - Expanded( - child: recent.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => EmptyState( - title: 'Could not load customers', - message: '$e', - emoji: '⚠️', - compact: true, - ), - data: (customers) => customers.isEmpty - ? const EmptyState( - title: 'No customers yet', - message: 'Register the first one from the welcome screen.', - emoji: '👤', - compact: true, - ) - : ListView.separated( - itemCount: customers.length, - separatorBuilder: (_, __) => - const SizedBox(height: AppSpacing.sm), - itemBuilder: (_, i) => _RecentTile( - customer: customers[i], - onTap: () => _continueWith(customers[i]), - ), - ), - ), - ), - ], - ); - } - - Widget _found(Customer c) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row(children: [ - CircleAvatar( - radius: 30, - backgroundColor: AppColors.primarySurface, - child: Text( - Formatters.initials(c.name), - style: const TextStyle( - color: AppColors.primary, - fontSize: 22, - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(width: AppSpacing.lg), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Flexible( - child: Text(c.name, - style: context.text.headlineSmall, - overflow: TextOverflow.ellipsis), - ), - const SizedBox(width: AppSpacing.sm), - StatusPill.tier(c.tier), - ]), - const SizedBox(height: 2), - Text('+91 ${Formatters.mobile(c.mobile)}', - style: context.text.bodyMedium - ?.copyWith(color: AppColors.textSecondary)), - ], - ), - ), - ]), - - if (c.isBirthdayToday) ...[ - const SizedBox(height: AppSpacing.lg), - Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppColors.warningSurface, - borderRadius: AppRadius.brSm, - ), - child: const Row(children: [ - Text('🎂', style: TextStyle(fontSize: 18)), - SizedBox(width: AppSpacing.sm), - Text("It's their birthday today — wish them!", - style: TextStyle( - color: AppColors.warning, - fontWeight: FontWeight.w600, - )), - ]), - ), - ], - - const SizedBox(height: AppSpacing.xxl), - Row(children: [ - Expanded( - child: _Stat( - label: 'Loyalty Points', - value: '${c.loyaltyPoints}', - caption: 'Worth ${Formatters.money(c.redeemableValue)}', - icon: Icons.stars_rounded, - color: AppColors.tierGold, - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: _Stat( - label: 'Lifetime Spend', - value: Formatters.moneyCompact(c.lifetimeSpend), - caption: '${c.visitCount} visits', - icon: Icons.receipt_long_rounded, - color: AppColors.primary, - ), - ), - ]), - - if (c.tier.discountRate > 0) ...[ - const SizedBox(height: AppSpacing.md), - Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppColors.successSurface, - borderRadius: AppRadius.brSm, - ), - child: Row(children: [ - const Icon(Icons.local_offer_rounded, - color: AppColors.success, size: 18), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - '${c.tier.label} members get ' - '${Formatters.percent(c.tier.discountRate)} off ' - 'automatically on every bill.', - style: const TextStyle( - color: AppColors.success, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - ), - ]), - ), - ], - - const Spacer(), - PrimaryButton( - label: 'Continue to Billing', - icon: Icons.point_of_sale_rounded, - large: true, - onPressed: () => _continueWith(c), - ), - ], - ).animate().fadeIn(duration: 200.ms); - } - - Widget _notFound(String mobile) { - return Column( - children: [ - Expanded( - child: EmptyState( - title: 'No customer found', - message: 'Nobody is registered against ' - '+91 ${Formatters.mobile(mobile)}.', - emoji: '🔍', - ), - ), - PrimaryButton( - label: 'Register Customer', - icon: Icons.person_add_alt_1_rounded, - onPressed: () => context.push( - '${AppRoutes.registerCustomer}?mobile=$mobile', - ), - ), - const SizedBox(height: AppSpacing.md), - PrimaryButton( - label: 'Continue as Walk-in', - icon: Icons.directions_walk_rounded, - tone: ButtonTone.neutral, - onPressed: () { - ref.read(cartControllerProvider.notifier).attachCustomer(null); - context.go(AppRoutes.pos); - }, - ), - ], - ).animate().fadeIn(duration: 200.ms); - } -} - -class _RecentTile extends StatelessWidget { - const _RecentTile({required this.customer, required this.onTap}); - - final Customer customer; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return Material( - color: AppColors.surfaceAlt, - borderRadius: AppRadius.brMd, - child: InkWell( - onTap: onTap, - borderRadius: AppRadius.brMd, - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Row(children: [ - CircleAvatar( - radius: 18, - backgroundColor: AppColors.primarySurface, - child: Text( - Formatters.initials(customer.name), - style: const TextStyle( - color: AppColors.primary, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(customer.name, - style: context.text.titleSmall, - overflow: TextOverflow.ellipsis), - Text(Formatters.maskedMobile(customer.mobile), - style: context.text.bodySmall), - ], - ), - ), - StatusPill.tier(customer.tier, dense: true), - const SizedBox(width: AppSpacing.sm), - const Icon(Icons.chevron_right_rounded, - color: AppColors.textTertiary), - ]), - ), - ), - ); - } -} - -class _Stat extends StatelessWidget { - const _Stat({ - required this.label, - required this.value, - required this.caption, - required this.icon, - required this.color, - }); - - final String label; - final String value; - final String caption; - final IconData icon; - final Color color; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(AppSpacing.lg), - decoration: BoxDecoration( - color: AppColors.surfaceAlt, - borderRadius: AppRadius.brMd, - border: Border.all(color: AppColors.border), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Icon(icon, size: 16, color: color), - const SizedBox(width: AppSpacing.xs), - Text(label, - style: context.text.labelSmall - ?.copyWith(color: AppColors.textSecondary)), - ]), - const SizedBox(height: AppSpacing.sm), - Text(value, - style: context.text.headlineSmall?.copyWith(color: color)), - Text(caption, style: context.text.bodySmall), - ], - ), - ); - } -} diff --git a/lib/presentation/customer/widgets/customer_capture_sheet.dart b/lib/presentation/customer/widgets/customer_capture_sheet.dart new file mode 100644 index 0000000..a53795e --- /dev/null +++ b/lib/presentation/customer/widgets/customer_capture_sheet.dart @@ -0,0 +1,561 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../app/providers.dart'; +import '../../../core/constants/app_constants.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/utils/formatters.dart'; +import '../../../core/widgets/numeric_keypad.dart'; +import '../../../core/widgets/primary_button.dart'; +import '../../../core/widgets/status_pill.dart'; +import '../../../domain/entities/customer.dart'; +import '../../pos/providers/cart_controller.dart'; +import '../providers/customer_providers.dart'; + +/// Attaches a customer to the current bill using nothing but a mobile number. +/// +/// Registration is deliberately minimal: an unknown number can be saved with +/// just a name, or the whole step skipped. Nothing here blocks the sale. +Future showCustomerCaptureSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => const _CustomerCaptureSheet(), + ); +} + +class _CustomerCaptureSheet extends ConsumerStatefulWidget { + const _CustomerCaptureSheet(); + + @override + ConsumerState<_CustomerCaptureSheet> createState() => + _CustomerCaptureSheetState(); +} + +class _CustomerCaptureSheetState + extends ConsumerState<_CustomerCaptureSheet> { + String _digits = ''; + final _name = TextEditingController(); + bool _saving = false; + String? _error; + + @override + void dispose() { + _name.dispose(); + super.dispose(); + } + + void _append(String d) { + if (_digits.length >= AppConstants.mobileNumberLength) return; + setState(() { + _digits += d; + _error = null; + }); + if (_digits.length == AppConstants.mobileNumberLength) { + ref.read(customerLookupProvider.notifier).search(_digits); + } + } + + void _backspace() { + if (_digits.isEmpty) return; + setState(() => _digits = _digits.substring(0, _digits.length - 1)); + ref.read(customerLookupProvider.notifier).reset(); + } + + void _clear() { + setState(() { + _digits = ''; + _error = null; + }); + ref.read(customerLookupProvider.notifier).reset(); + } + + void _attachAndClose(Customer? customer) { + ref.read(cartControllerProvider.notifier).attachCustomer(customer); + Navigator.of(context).pop(); + } + + Future _quickRegister() async { + final name = _name.text.trim(); + if (name.length < 2) { + setState(() => _error = 'Enter a name to save this customer'); + return; + } + + setState(() { + _saving = true; + _error = null; + }); + + try { + final created = await ref.read(customerRepositoryProvider).create( + Customer(id: '', name: name, mobile: _digits), + ); + ref.invalidate(recentCustomersProvider); + if (mounted) _attachAndClose(created); + } catch (e) { + if (!mounted) return; + setState(() { + _saving = false; + _error = e is StateError ? e.message : 'Could not save customer.'; + }); + } + } + + @override + Widget build(BuildContext context) { + final lookup = ref.watch(customerLookupProvider); + final attached = ref.watch( + cartControllerProvider.select((c) => c.customer), + ); + + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.9, + ), + decoration: const BoxDecoration( + color: AppColors.surface, + borderRadius: + BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _grabber(), + _header(attached), + const Divider(height: 1), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.xxl), + child: LayoutBuilder( + builder: (context, constraints) { + // Side by side once there is room for both columns. + final wide = constraints.maxWidth >= 720; + final entry = _entryColumn(); + final result = _resultColumn(lookup); + + if (!wide) { + return Column( + children: [ + entry, + const SizedBox(height: AppSpacing.xl), + result, + ], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: entry), + const SizedBox(width: AppSpacing.xxl), + Expanded(child: result), + ], + ); + }, + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _grabber() => Container( + width: 40, + height: 4, + margin: const EdgeInsets.symmetric(vertical: AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.border, + borderRadius: AppRadius.brPill, + ), + ); + + Widget _header(Customer? attached) => Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xxl, + 0, + AppSpacing.md, + AppSpacing.lg, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + attached == null ? 'Add customer' : 'Change customer', + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleLarge, + ), + const Text( + 'Optional — for loyalty points and tier discounts', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.5, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(width: AppSpacing.sm), + TextButton( + onPressed: () => _attachAndClose(null), + style: TextButton.styleFrom( + foregroundColor: AppColors.textSecondary, + ), + child: const Text('Skip'), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close_rounded), + tooltip: 'Close', + ), + ], + ), + ); + + Widget _entryColumn() => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _display(), + const SizedBox(height: AppSpacing.xl), + Center( + child: NumericKeypad( + maxWidth: 340, + onKey: _append, + onBackspace: _backspace, + onClear: _clear, + ), + ), + ], + ); + + Widget _display() => Container( + height: 68, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.primarySurface, + borderRadius: AppRadius.brLg, + border: Border.all(color: AppColors.primaryBorder), + ), + child: Row( + children: [ + const Text( + '+91', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + const SizedBox(width: AppSpacing.md), + // FittedBox guarantees ten digits fit at any sheet width. + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + _digits.isEmpty + ? '– – – – – – – – – –' + : _digits.split('').join(' '), + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + letterSpacing: 1, + color: _digits.isEmpty + ? AppColors.textTertiary + : AppColors.textPrimary, + ), + ), + ), + ), + if (_digits.isNotEmpty) + IconButton( + onPressed: _clear, + icon: const Icon(Icons.close_rounded, size: 20), + color: AppColors.textTertiary, + tooltip: 'Clear', + ), + ], + ), + ); + + Widget _resultColumn(CustomerLookupState state) => switch (state) { + LookupIdle() => _idle(), + LookupSearching() => const Padding( + padding: EdgeInsets.symmetric(vertical: AppSpacing.giant), + child: Center( + child: CircularProgressIndicator(color: AppColors.primary), + ), + ), + LookupFound(:final customer) => _found(customer), + LookupNotFound() => _notFound(), + LookupError(:final message) => _message( + Icons.error_outline_rounded, + AppColors.danger, + message, + ), + }; + + Widget _idle() { + final recent = ref.watch(recentCustomersProvider).value ?? const []; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + _message( + Icons.dialpad_rounded, + AppColors.textTertiary, + 'Key in a 10-digit mobile number. The lookup runs automatically.', + ), + if (recent.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.xl), + const Align( + alignment: Alignment.centerLeft, + child: Text( + 'Recent', + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + for (final c in recent.take(4)) + ActionChip( + avatar: CircleAvatar( + radius: 11, + backgroundColor: AppColors.primarySurface, + child: Text( + Formatters.initials(c.name), + style: const TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + label: Text( + c.name, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12.5), + ), + onPressed: () => _attachAndClose(c), + ), + ], + ), + ], + ], + ); + } + + Widget _found(Customer c) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.successSurface, + borderRadius: AppRadius.brLg, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + CircleAvatar( + radius: 22, + backgroundColor: AppColors.surface, + child: Text( + Formatters.initials(c.name), + style: const TextStyle( + color: AppColors.primary, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + c.name, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + Text( + Formatters.mobile(c.mobile), + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12.5, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(width: AppSpacing.sm), + StatusPill.tier(c.tier, dense: true), + ], + ), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + Expanded( + child: _miniStat( + '${c.loyaltyPoints}', 'points held'), + ), + Expanded( + child: _miniStat( + Formatters.money(c.redeemableValue), + 'redeemable', + ), + ), + if (c.tier.discountRate > 0) + Expanded( + child: _miniStat( + Formatters.percent(c.tier.discountRate), + 'auto discount', + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + PrimaryButton( + label: 'Use this customer', + icon: Icons.check_rounded, + large: true, + onPressed: () => _attachAndClose(c), + ), + ], + ); + + Widget _notFound() => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + _message( + Icons.person_search_rounded, + AppColors.warning, + 'Not registered yet. Add a name to save them, or carry on ' + 'without.', + ), + const SizedBox(height: AppSpacing.lg), + TextField( + controller: _name, + textCapitalization: TextCapitalization.words, + enabled: !_saving, + onSubmitted: (_) => _quickRegister(), + inputFormatters: [LengthLimitingTextInputFormatter(60)], + decoration: const InputDecoration( + labelText: 'Customer name', + hintText: 'Full name', + prefixIcon: Icon(Icons.person_outline_rounded), + ), + ), + if (_error != null) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + _error!, + style: const TextStyle(color: AppColors.danger, fontSize: 12.5), + ), + ], + const SizedBox(height: AppSpacing.lg), + PrimaryButton( + label: 'Save & use', + icon: Icons.person_add_alt_1_rounded, + large: true, + busy: _saving, + onPressed: _quickRegister, + ), + const SizedBox(height: AppSpacing.sm), + PrimaryButton( + label: 'Continue without customer', + tone: ButtonTone.neutral, + onPressed: _saving ? null : () => _attachAndClose(null), + ), + ], + ); + + Widget _miniStat(String value, String label) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + value, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.success, + ), + ), + ), + Text( + label, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ); + + Widget _message(IconData icon, Color color, String text) => Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.surfaceAlt, + borderRadius: AppRadius.brMd, + border: Border.all(color: AppColors.border), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 19, color: color), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Text( + text, + style: const TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + ), + ], + ), + ); +} diff --git a/lib/presentation/modules/screens/events_view.dart b/lib/presentation/modules/screens/events_view.dart index 85455bc..22debde 100644 --- a/lib/presentation/modules/screens/events_view.dart +++ b/lib/presentation/modules/screens/events_view.dart @@ -5,27 +5,28 @@ import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/utils/formatters.dart'; import '../../../core/widgets/primary_button.dart'; -import '../../../domain/entities/sync_event.dart'; import '../../../domain/entities/transaction.dart'; +import '../../../domain/repositories/sync_repository.dart'; import '../../sync/providers/sync_controller.dart'; import '../widgets/module_widgets.dart'; -/// The terminal's outbound half: what today produced, and what has been sent. +/// End-of-day sync. +/// +/// Shows what the terminal produced today and uploads every bill still at +/// `sync_status = 0`. Accepted bills flip to 1; anything that fails stays at 0 +/// and is retried on the next tap. class EventsView extends ConsumerWidget { const EventsView({super.key}); - static Color statusColor(SyncStatus s) => switch (s) { - SyncStatus.synced => AppColors.success, - SyncStatus.failed => AppColors.danger, - SyncStatus.syncing => AppColors.info, - SyncStatus.pending => AppColors.warning, - }; - @override Widget build(BuildContext context, WidgetRef ref) { - final report = ref.watch(shiftReportProvider); + final report = ref.watch(todayReportProvider); + final pending = ref.watch(unsyncedCountProvider).value ?? 0; + final rows = ref.watch(orderSyncRowsProvider).value ?? const []; + final syncState = ref.watch(orderSyncProvider); final events = ref.watch(syncEventsProvider); - final pushing = ref.watch(reportPushProvider); + + final r = report.value; return ModulePage( children: [ @@ -35,112 +36,110 @@ class EventsView extends ConsumerWidget { children: [ StatTile( label: 'Bills Today', - value: '${report.billCount}', + value: '${r?.billCount ?? 0}', icon: Icons.receipt_long_rounded, - caption: report.firstBillAt == null + caption: r?.firstBillAt == null ? 'no sales yet' - : '${Formatters.time(report.firstBillAt!)} – ' - '${Formatters.time(report.lastBillAt!)}', + : '${Formatters.time(r!.firstBillAt!)} – ' + '${Formatters.time(r.lastBillAt!)}', ), StatTile( label: 'Items Sold', - value: report.itemCount.toStringAsFixed(0), + value: (r?.itemCount ?? 0).toStringAsFixed(0), icon: Icons.shopping_basket_rounded, color: AppColors.info, caption: 'units across all bills', ), StatTile( label: "Today's Sales", - value: Formatters.money(report.grossSales), + value: Formatters.money(r?.grossSales ?? 0), icon: Icons.payments_rounded, color: AppColors.success, caption: 'gross takings', ), StatTile( - label: 'Average Basket', - value: Formatters.money(report.averageBasket), - icon: Icons.trending_up_rounded, - color: AppColors.tierGold, - caption: 'per bill', + label: 'Awaiting Sync', + value: '$pending', + icon: Icons.cloud_off_rounded, + color: pending > 0 ? AppColors.warning : AppColors.success, + caption: pending > 0 + ? 'held on this terminal' + : 'everything uploaded', ), ], ), const SizedBox(height: AppSpacing.lg), PanelCard( - title: 'Shift report', - subtitle: '${Formatters.date(report.businessDate)} · ' - '${report.cashierName} · ${report.terminalId}', + title: 'Upload bills to server', + subtitle: r == null + ? 'Reading today\u2019s trading from SQLite\u2026' + : '${Formatters.date(r.businessDate)} \u00b7 ${r.cashierName} ' + '\u00b7 ${r.terminalId}', action: TagChip( - report.isEmpty ? 'Nothing to send' : 'Ready to push', - color: report.isEmpty ? AppColors.textSecondary : AppColors.warning, + pending > 0 ? '$pending pending' : 'All synced', + color: pending > 0 ? AppColors.warning : AppColors.success, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - _row('Bills', '${report.billCount}'), - _row('Items sold', report.itemCount.toStringAsFixed(0)), - _row('Gross sales', Formatters.money(report.grossSales)), - _row('Net of tax', Formatters.money(report.netOfTax)), - _row('GST collected', Formatters.money(report.taxCollected)), - _row('Discount given', Formatters.money(report.discountGiven)), - _row('Round off', Formatters.money(report.roundOff)), - _row('Points issued', '${report.loyaltyPointsIssued}'), - _row('Points redeemed', '${report.loyaltyPointsRedeemed}'), - - if (report.paymentBreakdown.isNotEmpty) ...[ - const Divider(height: AppSpacing.xxl), - const Align( - alignment: Alignment.centerLeft, - child: Text( - 'By payment method', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppColors.textSecondary, + if (r != null && !r.isEmpty) ...[ + _row('Bills', '${r.billCount}'), + _row('Items sold', r.itemCount.toStringAsFixed(0)), + _row('Gross sales', Formatters.money(r.grossSales)), + _row('GST collected', Formatters.money(r.taxCollected)), + _row('Discount given', Formatters.money(r.discountGiven)), + _row('Average basket', Formatters.money(r.averageBasket)), + if (r.paymentBreakdown.isNotEmpty) ...[ + const Divider(height: AppSpacing.xxl), + for (final e in r.paymentBreakdown.entries) + ProgressRow( + label: '${e.key.emoji} ${e.key.label}', + value: Formatters.money(e.value), + fraction: + r.grossSales <= 0 ? 0 : e.value / r.grossSales, + color: _methodColor(e.key), ), + ], + const SizedBox(height: AppSpacing.lg), + ], + + if (syncState is SyncRunning) ...[ + Text( + syncState.stage, + style: const TextStyle( + fontSize: 13, + color: AppColors.textSecondary, ), ), const SizedBox(height: AppSpacing.sm), - for (final e in report.paymentBreakdown.entries) - ProgressRow( - label: '${e.key.emoji} ${e.key.label}', - value: Formatters.money(e.value), - fraction: report.grossSales <= 0 - ? 0 - : e.value / report.grossSales, - color: _methodColor(e.key), + ClipRRect( + borderRadius: AppRadius.brPill, + child: LinearProgressIndicator( + value: syncState.progress, + minHeight: 8, + backgroundColor: AppColors.divider, + valueColor: + const AlwaysStoppedAnimation(AppColors.primary), ), + ), + const SizedBox(height: AppSpacing.lg), ], - const SizedBox(height: AppSpacing.xl), + if (syncState is SyncFinished) + _outcomeBanner(syncState.outcome), + PrimaryButton( - label: 'Push report to server', + label: pending > 0 + ? 'Sync $pending bill${pending == 1 ? '' : 's'}' + : 'Nothing to sync', icon: Icons.cloud_upload_rounded, large: true, - busy: pushing, - onPressed: report.isEmpty + busy: syncState is SyncRunning, + onPressed: pending == 0 ? null - : () async { - final event = await ref - .read(reportPushProvider.notifier) - .pushToday(); - if (!context.mounted) return; - final ok = event.status == SyncStatus.synced; - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar(SnackBar( - backgroundColor: - ok ? AppColors.success : AppColors.danger, - content: Text( - ok - ? 'Shift report sent.' - : 'Push failed — the report is still saved ' - 'on this terminal.', - ), - )); - }, + : () => ref.read(orderSyncProvider.notifier).run(), ), const SizedBox(height: AppSpacing.md), const Row( @@ -151,8 +150,9 @@ class EventsView extends ConsumerWidget { SizedBox(width: AppSpacing.sm), Expanded( child: Text( - 'A failed push never discards data. The report stays ' - 'queued below and can be retried at any time.', + 'Bills are written to SQLite the moment a sale ' + 'completes. A failed upload changes nothing on disk — ' + 'every bill stays until the server confirms it.', style: TextStyle( fontSize: 12, color: AppColors.textTertiary, @@ -168,64 +168,113 @@ class EventsView extends ConsumerWidget { const SizedBox(height: AppSpacing.lg), PanelCard( - title: 'Event log', - subtitle: '${events.length} recorded · ' - '${events.where((e) => e.status != SyncStatus.synced).length} ' - 'outstanding', - child: events.isEmpty - ? const Padding( - padding: EdgeInsets.symmetric(vertical: AppSpacing.lg), - child: Text( - 'No sync activity yet. Importing the catalogue or pushing ' - 'a report will appear here.', - style: TextStyle(color: AppColors.textTertiary), - ), - ) - : ResponsiveTable( - columns: const [ - TableCol('Event', flex: 3), - TableCol('Detail', flex: 5, priority: 1), - TableCol('Time', flex: 2, numeric: true, priority: 1), - TableCol('Status', flex: 2, numeric: true), - ], - rows: events - .map((e) => [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - e.type.isInbound - ? Icons.cloud_download_rounded - : Icons.cloud_upload_rounded, - size: 15, - color: AppColors.textSecondary, - ), - const SizedBox(width: AppSpacing.sm), - Flexible(child: Cell(e.type.label, bold: true)), - ], - ), - Cell( - e.error ?? e.summary, - color: e.error != null - ? AppColors.danger - : AppColors.textSecondary, - ), - Cell(Formatters.time(e.createdAt), - color: AppColors.textTertiary), - e.status == SyncStatus.failed - ? _RetryButton(eventId: e.id) - : TagChip( - e.status.label, - color: statusColor(e.status), - ), - ]) - .toList(), - ), + title: 'Orders', + subtitle: '${rows.length} stored \u00b7 $pending awaiting upload', + child: ResponsiveTable( + columns: const [ + TableCol('Invoice', flex: 3), + TableCol('Time', flex: 2, priority: 1), + TableCol('Total', flex: 2, numeric: true), + TableCol('Sync', flex: 2, numeric: true), + ], + rows: rows + .map((o) => [ + Cell(o.invoiceNumber, bold: true, mono: true), + Cell(Formatters.time(o.createdAt), + color: AppColors.textTertiary), + Cell(Formatters.money(o.total), mono: true, bold: true), + TagChip( + o.isSynced ? 'Synced' : 'Pending', + color: + o.isSynced ? AppColors.success : AppColors.warning, + ), + ]) + .toList(), + ), ), + + if (events.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.lg), + PanelCard( + title: 'Sync history', + subtitle: 'This session', + child: ResponsiveTable( + columns: const [ + TableCol('Event', flex: 3), + TableCol('Detail', flex: 5, priority: 1), + TableCol('Time', flex: 2, numeric: true), + ], + rows: events + .map((e) => [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + e.type.isInbound + ? Icons.cloud_download_rounded + : Icons.cloud_upload_rounded, + size: 15, + color: AppColors.textSecondary, + ), + const SizedBox(width: AppSpacing.sm), + Flexible(child: Cell(e.type.label, bold: true)), + ], + ), + Cell( + e.error ?? e.summary, + color: e.error != null + ? AppColors.danger + : AppColors.textSecondary, + ), + Cell(Formatters.time(e.createdAt), + color: AppColors.textTertiary), + ]) + .toList(), + ), + ), + ], ], ); } + Widget _outcomeBanner(SyncOutcome outcome) { + final ok = outcome.isSuccess; + final uploaded = outcome.uploaded; + final attempted = outcome.attempted; + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.lg), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: ok ? AppColors.successSurface : AppColors.dangerSurface, + borderRadius: AppRadius.brSm, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + ok ? Icons.check_circle_outline_rounded : Icons.wifi_off_rounded, + size: 18, + color: ok ? AppColors.success : AppColors.danger, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + ok + ? '$uploaded of $attempted bills uploaded and marked synced.' + : '${outcome.error}', + style: TextStyle( + fontSize: 13, + height: 1.45, + color: ok ? AppColors.success : AppColors.danger, + ), + ), + ), + ], + ), + ); + } + static Color _methodColor(PaymentMethod m) => switch (m) { PaymentMethod.cash => AppColors.success, PaymentMethod.card => AppColors.info, @@ -242,6 +291,7 @@ class EventsView extends ConsumerWidget { Expanded( child: Text( label, + overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 13.5, color: AppColors.textSecondary, @@ -254,34 +304,9 @@ class EventsView extends ConsumerWidget { style: const TextStyle( fontSize: 13.5, fontWeight: FontWeight.w600, - color: AppColors.textPrimary, ), ), ], ), ); } - -class _RetryButton extends ConsumerWidget { - const _RetryButton({required this.eventId}); - - final String eventId; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final busy = ref.watch(reportPushProvider); - - return TextButton.icon( - onPressed: busy - ? null - : () => ref.read(reportPushProvider.notifier).retry(eventId), - icon: const Icon(Icons.refresh_rounded, size: 15), - label: const Text('Retry', style: TextStyle(fontSize: 12.5)), - style: TextButton.styleFrom( - foregroundColor: AppColors.danger, - minimumSize: const Size(0, 30), - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), - ), - ); - } -} diff --git a/lib/presentation/modules/screens/settings_view.dart b/lib/presentation/modules/screens/settings_view.dart index 0c052c3..c98792a 100644 --- a/lib/presentation/modules/screens/settings_view.dart +++ b/lib/presentation/modules/screens/settings_view.dart @@ -7,7 +7,6 @@ import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/utils/formatters.dart'; import '../../../domain/entities/store_account.dart'; -import '../../../domain/entities/sync_event.dart'; import '../../auth/providers/auth_controller.dart'; import '../../sync/providers/sync_controller.dart'; import '../widgets/module_widgets.dart'; @@ -196,10 +195,7 @@ class _SettingsViewState extends ConsumerState { Widget _connectivityCard() { final ready = ref.watch(catalogueReadyProvider); final lastImport = ref.watch(lastImportAtProvider); - final outstanding = ref - .watch(syncEventsProvider) - .where((e) => e.status != SyncStatus.synced) - .length; + final outstanding = ref.watch(unsyncedCountProvider).value ?? 0; return PanelCard( title: 'Connectivity & sync', @@ -213,7 +209,7 @@ class _SettingsViewState extends ConsumerState { 'Last import', lastImport == null ? 'Never' : Formatters.dateTime(lastImport), ), - _row('Outstanding pushes', '$outstanding'), + _row('Unsynced bills', '$outstanding'), _toggle( 'Simulate offline', 'Forces import and push to fail, so you can confirm nothing is ' @@ -222,7 +218,7 @@ class _SettingsViewState extends ConsumerState { (v) { setState(() => _offline = v); ref.read(remoteCatalogueProvider).simulateOffline = v; - ref.read(remoteReportSinkProvider).simulateOffline = v; + ref.read(remoteOrderSinkProvider).simulateOffline = v; }, ), ], @@ -237,7 +233,7 @@ class _SettingsViewState extends ConsumerState { children: [ _row('Application', '${AppConstants.appName} 1.0.0'), _row('Terminal', 'TERM-01'), - _row('Data store', 'Local — offline first'), + _row('Data store', 'SQLite (on device)'), const SizedBox(height: AppSpacing.md), SizedBox( width: double.infinity, @@ -256,20 +252,24 @@ class _SettingsViewState extends ConsumerState { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - width: 148, + Flexible( + flex: 3, child: Text( label, + overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 13, color: AppColors.textSecondary, ), ), ), - Expanded( + const SizedBox(width: AppSpacing.md), + Flexible( + flex: 4, child: Text( value, textAlign: TextAlign.right, + overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, diff --git a/lib/presentation/payment/providers/payment_controller.dart b/lib/presentation/payment/providers/payment_controller.dart index 9fe2638..c3e796c 100644 --- a/lib/presentation/payment/providers/payment_controller.dart +++ b/lib/presentation/payment/providers/payment_controller.dart @@ -8,6 +8,7 @@ import '../../../domain/entities/transaction.dart'; import '../../../domain/usecases/checkout_sale.dart'; import '../../pos/providers/cart_controller.dart'; import '../../pos/providers/catalog_providers.dart'; +import '../../sync/providers/sync_controller.dart'; /// UI state for the payment screen. class PaymentState { @@ -161,9 +162,11 @@ class PaymentController extends StateNotifier { unawaited(receipts.openCashDrawer()); unawaited(_ref.read(soundServiceProvider).saleComplete()); - // Stock changed, so the grid must refresh. + // Stock changed, so the grid must refresh; the new order changes the + // unsynced tally and today's totals. _ref.invalidate(allProductsProvider); _ref.invalidate(visibleProductsProvider); + _ref.read(orderVersionProvider.notifier).state++; return result; } on CheckoutFailure catch (e) { diff --git a/lib/presentation/payment/screens/payment_screen.dart b/lib/presentation/payment/screens/payment_screen.dart index ece1e8d..4222b4b 100644 --- a/lib/presentation/payment/screens/payment_screen.dart +++ b/lib/presentation/payment/screens/payment_screen.dart @@ -7,18 +7,26 @@ import '../../../core/router/app_router.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_typography.dart'; -import '../../../core/utils/extensions.dart'; import '../../../core/utils/formatters.dart'; import '../../../core/widgets/glass_card.dart'; import '../../../core/widgets/numeric_keypad.dart'; import '../../../core/widgets/primary_button.dart'; +import '../../../core/widgets/status_pill.dart'; import '../../../domain/entities/transaction.dart'; +import '../../customer/widgets/customer_capture_sheet.dart'; import '../../pos/providers/cart_controller.dart'; import '../providers/payment_controller.dart'; +/// Tender capture and sale completion. +/// +/// Customer identification happens here rather than at the start of the sale: +/// a mobile number is all that is asked for, and the step can be skipped. class PaymentScreen extends ConsumerStatefulWidget { const PaymentScreen({super.key}); + /// Below this the two columns stack into one scrolling page. + static const double twoColumnAbove = 1080; + @override ConsumerState createState() => _PaymentScreenState(); } @@ -27,8 +35,9 @@ class _PaymentScreenState extends ConsumerState { String _cashBuffer = ''; void _syncCash() { - final value = double.tryParse(_cashBuffer) ?? 0; - ref.read(paymentControllerProvider.notifier).setCashTendered(value); + ref + .read(paymentControllerProvider.notifier) + .setCashTendered(double.tryParse(_cashBuffer) ?? 0); } void _appendCash(String d) { @@ -40,7 +49,8 @@ class _PaymentScreenState extends ConsumerState { void _backspaceCash() { if (_cashBuffer.isEmpty) return; - setState(() => _cashBuffer = _cashBuffer.substring(0, _cashBuffer.length - 1)); + setState( + () => _cashBuffer = _cashBuffer.substring(0, _cashBuffer.length - 1)); _syncCash(); } @@ -53,7 +63,6 @@ class _PaymentScreenState extends ConsumerState { final result = await ref.read(paymentControllerProvider.notifier).confirm(); if (result == null || !mounted) return; - // Sale is banked — clear the terminal and show the receipt. ref.read(cartControllerProvider.notifier).reset(); context.go(AppRoutes.receipt, extra: result.transaction); } @@ -71,221 +80,321 @@ class _PaymentScreenState extends ConsumerState { leading: IconButton( icon: const Icon(Icons.arrow_back_rounded), onPressed: () => context.pop(), + tooltip: 'Back to bill', ), ), - body: Padding( - padding: const EdgeInsets.all(AppSpacing.xxl), - child: context.isCompact - ? SingleChildScrollView( - child: Column(children: [ - _amountCard(controller, state), - const SizedBox(height: AppSpacing.lg), - _methodsCard(controller, state), - const SizedBox(height: AppSpacing.lg), - _tenderCard(controller, state), - ]), - ) - : Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: 4, - child: Column(children: [ - _amountCard(controller, state), - const SizedBox(height: AppSpacing.lg), - Expanded(child: _methodsCard(controller, state)), - ]), - ), - const SizedBox(width: AppSpacing.lg), - Expanded(flex: 5, child: _tenderCard(controller, state)), - ], - ), - ), - bottomNavigationBar: SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.xxl, - 0, - AppSpacing.xxl, - AppSpacing.xxl, - ), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - if (state.error != null) ...[ - Container( - width: double.infinity, - padding: const EdgeInsets.all(AppSpacing.md), - margin: const EdgeInsets.only(bottom: AppSpacing.md), - decoration: BoxDecoration( - color: AppColors.dangerSurface, - borderRadius: AppRadius.brSm, - ), - child: Row(children: [ - const Icon(Icons.error_outline_rounded, - color: AppColors.danger, size: 18), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text(state.error!, - style: const TextStyle(color: AppColors.danger)), - ), - ]), - ).animate().shake(duration: 300.ms, hz: 3), + body: LayoutBuilder( + builder: (context, constraints) { + final twoColumn = constraints.maxWidth >= PaymentScreen.twoColumnAbove; + final pad = + constraints.maxWidth < 700 ? AppSpacing.lg : AppSpacing.xxl; + + final left = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + _amountCard(controller, state, cart.grandTotal, cart.lineCount), + const SizedBox(height: AppSpacing.md), + _customerCard(), + const SizedBox(height: AppSpacing.md), + _methodsCard(controller, state), ], - PrimaryButton( - label: 'Complete Sale', - icon: Icons.check_circle_outline_rounded, - large: true, - tone: ButtonTone.success, - busy: state.isProcessing, - onPressed: cart.isEmpty ? null : _confirm, - trailing: Text( - Formatters.money(cart.grandTotal), - style: AppTypography.money(21, color: Colors.white), + ); + + final right = _tenderCard(controller, state); + + if (!twoColumn) { + return SingleChildScrollView( + padding: EdgeInsets.all(pad), + child: Column( + children: [left, const SizedBox(height: AppSpacing.md), right], ), + ); + } + + return Padding( + padding: EdgeInsets.all(pad), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 4, child: SingleChildScrollView(child: left)), + const SizedBox(width: AppSpacing.lg), + Expanded(flex: 5, child: SingleChildScrollView(child: right)), + ], ), - ]), - ), + ); + }, ), + bottomNavigationBar: _bottomBar(cart.grandTotal, state, cart.isEmpty), ); } - // ------------------------------------------------------------- Sections - Widget _amountCard(PaymentController controller, PaymentState state) { - final cart = ref.watch(cartControllerProvider); - - return GlassCard( - padding: const EdgeInsets.all(AppSpacing.xxl), - radius: AppRadius.xl, - tinted: true, - child: Column(children: [ - Text('Amount due', style: context.text.labelMedium), - const SizedBox(height: AppSpacing.xs), - Text( - Formatters.money(controller.balanceDue), - style: AppTypography.money(40, color: AppColors.primary), - ), - const SizedBox(height: AppSpacing.md), - Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - _mini('Items', '${cart.lineCount}'), - _dot(), - _mini('Bill total', Formatters.money(cart.grandTotal)), - if (state.settled > 0) ...[ - _dot(), - _mini('Settled', Formatters.money(state.settled)), - ], - ]), - ]), - ); - } - - Widget _methodsCard(PaymentController controller, PaymentState state) { + // ------------------------------------------------------------ Amount due + Widget _amountCard( + PaymentController controller, + PaymentState state, + double total, + int lineCount, + ) { return GlassCard( padding: const EdgeInsets.all(AppSpacing.xl), radius: AppRadius.xl, + tinted: true, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Amount due', + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + letterSpacing: 0.4, + ), + ), + const SizedBox(height: AppSpacing.xs), + // Scales instead of clipping when the amount runs long. + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + Formatters.money(controller.balanceDue), + style: AppTypography.money(38, color: AppColors.primary), + ), + ), + const SizedBox(height: AppSpacing.md), + Wrap( + alignment: WrapAlignment.center, + spacing: AppSpacing.lg, + runSpacing: AppSpacing.xs, + children: [ + _mini('Items', '$lineCount'), + _mini('Bill total', Formatters.money(total)), + if (state.settled > 0) + _mini('Settled', Formatters.money(state.settled)), + ], + ), + ], + ), + ); + } + + // -------------------------------------------------------------- Customer + Widget _customerCard() { + final cart = ref.watch(cartControllerProvider); + final customer = cart.customer; + + return GlassCard( + padding: const EdgeInsets.all(AppSpacing.lg), + radius: AppRadius.xl, + onTap: () => showCustomerCaptureSheet(context), + child: Row( + children: [ + CircleAvatar( + radius: 20, + backgroundColor: customer == null + ? AppColors.surfaceAlt + : AppColors.primarySurface, + child: customer == null + ? const Icon(Icons.person_add_alt_1_outlined, + size: 19, color: AppColors.textSecondary) + : Text( + Formatters.initials(customer.name), + style: const TextStyle( + color: AppColors.primary, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Flexible( + child: Text( + customer?.name ?? 'Walk-in customer', + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + if (customer != null) ...[ + const SizedBox(width: AppSpacing.sm), + StatusPill.tier(customer.tier, dense: true), + ], + ], + ), + const SizedBox(height: 1), + Text( + customer == null + ? 'Add a mobile number for loyalty — or skip' + : '${Formatters.mobile(customer.mobile)} · earns ' + '+${cart.pointsEarned} pts', + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12.5, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(width: AppSpacing.sm), + TextButton( + onPressed: () => showCustomerCaptureSheet(context), + style: TextButton.styleFrom( + minimumSize: const Size(0, 38), + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + ), + child: Text(customer == null ? 'Add' : 'Change'), + ), + ], + ), + ); + } + + // --------------------------------------------------------------- Methods + Widget _methodsCard(PaymentController controller, PaymentState state) { + return GlassCard( + padding: const EdgeInsets.all(AppSpacing.lg), + radius: AppRadius.xl, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, children: [ - Text('Payment method', style: context.text.titleMedium), - const SizedBox(height: AppSpacing.lg), - Wrap( - spacing: AppSpacing.md, - runSpacing: AppSpacing.md, - children: PaymentMethod.values - .where((m) => m != PaymentMethod.loyalty) - .map((m) => _MethodTile( - method: m, - selected: state.activeMethod == m, - onTap: () { - setState(() => _cashBuffer = ''); - controller.selectMethod(m); - }, - )) - .toList(), + const Text( + 'Payment method', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + ), + const SizedBox(height: AppSpacing.md), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + for (final m in PaymentMethod.values) + if (m != PaymentMethod.loyalty) + _MethodTile( + method: m, + selected: state.activeMethod == m, + onTap: () { + setState(() => _cashBuffer = ''); + controller.selectMethod(m); + }, + ), + ], ), - if (state.splits.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.xl), - const Divider(), - const SizedBox(height: AppSpacing.md), - Row(children: [ - Text('Split tenders', style: context.text.titleSmall), - const Spacer(), - TextButton( - onPressed: controller.clearSplits, - style: TextButton.styleFrom( - foregroundColor: AppColors.danger), - child: const Text('Clear all'), - ), - ]), - const SizedBox(height: AppSpacing.sm), - ...state.splits.asMap().entries.map((e) => Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.sm), - child: Row(children: [ + const Divider(height: AppSpacing.xxl), + Row( + children: [ + const Expanded( + child: Text( + 'Split tenders', + style: + TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600), + ), + ), + TextButton( + onPressed: controller.clearSplits, + style: TextButton.styleFrom( + foregroundColor: AppColors.danger, + minimumSize: const Size(0, 32), + ), + child: const Text('Clear'), + ), + ], + ), + for (final e in state.splits.asMap().entries) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xs), + child: Row( + children: [ Text(e.value.method.emoji, - style: const TextStyle(fontSize: 16)), + style: const TextStyle(fontSize: 15)), const SizedBox(width: AppSpacing.sm), - Expanded(child: Text(e.value.method.label)), + Expanded( + child: Text( + e.value.method.label, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13.5), + ), + ), Text(Formatters.money(e.value.amount), - style: AppTypography.money(14.5)), + style: AppTypography.money(13.5)), IconButton( onPressed: () => controller.removeSplit(e.key), - icon: const Icon(Icons.close_rounded, size: 17), + icon: const Icon(Icons.close_rounded, size: 16), color: AppColors.textTertiary, constraints: const BoxConstraints(minWidth: 30, minHeight: 30), padding: EdgeInsets.zero, + tooltip: 'Remove tender', ), - ]), - )), + ], + ), + ), ], ], ), ); } + // ---------------------------------------------------------------- Tender Widget _tenderCard(PaymentController controller, PaymentState state) { - final isCash = state.activeMethod.needsChange; - return GlassCard( - padding: const EdgeInsets.all(AppSpacing.xl), + padding: const EdgeInsets.all(AppSpacing.lg), radius: AppRadius.xl, - child: isCash - ? _cashTender(controller, state) + child: state.activeMethod.needsChange + ? _cashTender(controller) : _referenceTender(controller, state), ); } - Widget _cashTender(PaymentController controller, PaymentState state) { - final change = controller.changeDue; - + Widget _cashTender(PaymentController controller) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, children: [ - Text('Cash received', style: context.text.titleMedium), + const Text( + 'Cash received', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + ), const SizedBox(height: AppSpacing.md), - Container( padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.xl, - vertical: AppSpacing.lg, + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, ), decoration: BoxDecoration( color: AppColors.surfaceAlt, borderRadius: AppRadius.brLg, border: Border.all(color: AppColors.border), ), - child: Row(children: [ - const Text('₹', - style: TextStyle(fontSize: 24, color: AppColors.textTertiary)), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - _cashBuffer.isEmpty ? '0' : _cashBuffer, - style: AppTypography.money(30), + child: Row( + children: [ + const Text('₹', + style: + TextStyle(fontSize: 22, color: AppColors.textTertiary)), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + _cashBuffer.isEmpty ? '0' : _cashBuffer, + style: AppTypography.money(28), + ), + ), ), - ), - ]), + ], + ), ), - const SizedBox(height: AppSpacing.md), Wrap( spacing: AppSpacing.sm, @@ -296,58 +405,17 @@ class _PaymentScreenState extends ConsumerState { label: const Text('Exact'), onPressed: () => _setCash(controller.balanceDue), ), - ...[50, 100, 200, 500, 2000].map( - (note) => ActionChip( + for (final note in const [50, 100, 200, 500, 2000]) + ActionChip( label: Text('₹$note'), - onPressed: () => _setCash( - (double.tryParse(_cashBuffer) ?? 0) + note, - ), + onPressed: () => + _setCash((double.tryParse(_cashBuffer) ?? 0) + note), ), - ), ], ), - - const SizedBox(height: AppSpacing.lg), - - AnimatedContainer( - duration: AppMotion.normal, - padding: const EdgeInsets.all(AppSpacing.lg), - decoration: BoxDecoration( - color: change > 0 - ? AppColors.successSurface - : AppColors.surfaceAlt, - borderRadius: AppRadius.brMd, - ), - child: Row(children: [ - Icon( - change > 0 - ? Icons.currency_exchange_rounded - : Icons.info_outline_rounded, - size: 19, - color: change > 0 ? AppColors.success : AppColors.textTertiary, - ), - const SizedBox(width: AppSpacing.md), - Text( - 'Change to return', - style: TextStyle( - fontWeight: FontWeight.w600, - color: change > 0 - ? AppColors.success - : AppColors.textSecondary, - ), - ), - const Spacer(), - Text( - Formatters.money(change), - style: AppTypography.money( - 22, - color: change > 0 ? AppColors.success : AppColors.textTertiary, - ), - ), - ]), - ), - - const SizedBox(height: AppSpacing.lg), + const SizedBox(height: AppSpacing.md), + _changeRow(controller.changeDue), + const SizedBox(height: AppSpacing.md), Center( child: NumericKeypad( allowDecimal: true, @@ -356,61 +424,100 @@ class _PaymentScreenState extends ConsumerState { onBackspace: _backspaceCash, ), ), - const SizedBox(height: AppSpacing.md), - OutlinedButton.icon( - onPressed: controller.balanceDue > 0 - ? () { - controller.addSplit( - amount: (double.tryParse(_cashBuffer) ?? 0) - .clamp(0, controller.balanceDue) - .toDouble(), - ); - setState(() => _cashBuffer = ''); - } - : null, - icon: const Icon(Icons.call_split_rounded, size: 17), - label: const Text('Add as split payment'), - ), + _splitButton(controller, amount: double.tryParse(_cashBuffer) ?? 0), ], ); } + Widget _changeRow(double change) { + final active = change > 0; + return AnimatedContainer( + duration: AppMotion.normal, + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: active ? AppColors.successSurface : AppColors.surfaceAlt, + borderRadius: AppRadius.brMd, + ), + child: Row( + children: [ + Icon( + active + ? Icons.currency_exchange_rounded + : Icons.info_outline_rounded, + size: 18, + color: active ? AppColors.success : AppColors.textTertiary, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + 'Change to return', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.5, + fontWeight: FontWeight.w600, + color: active ? AppColors.success : AppColors.textSecondary, + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Text( + Formatters.money(change), + style: AppTypography.money( + 20, + color: active ? AppColors.success : AppColors.textTertiary, + ), + ), + ], + ), + ); + } + Widget _referenceTender(PaymentController controller, PaymentState state) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, children: [ - Row(children: [ - Text(state.activeMethod.emoji, style: const TextStyle(fontSize: 22)), - const SizedBox(width: AppSpacing.sm), - Text('${state.activeMethod.label} payment', - style: context.text.titleMedium), - ]), - const SizedBox(height: AppSpacing.xxl), - - Center( - child: Column(children: [ - Container( - width: 120, - height: 120, - decoration: BoxDecoration( - color: AppColors.primarySurface, - borderRadius: AppRadius.brXl, + Row( + children: [ + Text(state.activeMethod.emoji, + style: const TextStyle(fontSize: 20)), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + '${state.activeMethod.label} payment', + overflow: TextOverflow.ellipsis, + style: + const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), ), - alignment: Alignment.center, - child: Text(state.activeMethod.emoji, - style: const TextStyle(fontSize: 52)), ), - const SizedBox(height: AppSpacing.lg), - Text( - 'Charge ${Formatters.money(controller.balanceDue)} ' - 'on the ${state.activeMethod.label.toLowerCase()} terminal', - textAlign: TextAlign.center, - style: context.text.bodyMedium, - ), - ]), + ], + ), + const SizedBox(height: AppSpacing.xxl), + Center( + child: Container( + width: 104, + height: 104, + decoration: BoxDecoration( + color: AppColors.primarySurface, + borderRadius: AppRadius.brXl, + ), + alignment: Alignment.center, + child: Text(state.activeMethod.emoji, + style: const TextStyle(fontSize: 46)), + ), + ), + const SizedBox(height: AppSpacing.lg), + Text( + 'Charge ${Formatters.money(controller.balanceDue)} on the ' + '${state.activeMethod.label.toLowerCase()} terminal', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 13.5, + color: AppColors.textSecondary, + height: 1.5, + ), ), - const SizedBox(height: AppSpacing.xxl), if (state.activeMethod.needsReference) TextField( @@ -425,37 +532,103 @@ class _PaymentScreenState extends ConsumerState { prefixIcon: const Icon(Icons.tag_rounded), ), ), - - const SizedBox(height: AppSpacing.xxxl), - OutlinedButton.icon( - onPressed: controller.balanceDue > 0 - ? () => controller.addSplit() - : null, - icon: const Icon(Icons.call_split_rounded, size: 17), - label: const Text('Add as split payment'), - ), + const SizedBox(height: AppSpacing.lg), + _splitButton(controller), ], ); } - Widget _mini(String label, String value) => Column(children: [ - Text(label, + Widget _splitButton(PaymentController controller, {double? amount}) { + return OutlinedButton.icon( + onPressed: controller.balanceDue > 0 + ? () { + controller.addSplit( + amount: amount == null + ? null + : amount.clamp(0, controller.balanceDue).toDouble(), + ); + setState(() => _cashBuffer = ''); + } + : null, + icon: const Icon(Icons.call_split_rounded, size: 17), + label: const Text('Add as split payment'), + style: OutlinedButton.styleFrom(minimumSize: const Size(0, 44)), + ); + } + + // ------------------------------------------------------------ Bottom bar + Widget _bottomBar(double total, PaymentState state, bool cartEmpty) { + return SafeArea( + child: Container( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xxl, + AppSpacing.md, + AppSpacing.xxl, + AppSpacing.lg, + ), + decoration: const BoxDecoration( + color: AppColors.surface, + border: Border(top: BorderSide(color: AppColors.border)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (state.error != null) + Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.md), + margin: const EdgeInsets.only(bottom: AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.dangerSurface, + borderRadius: AppRadius.brSm, + ), + child: Row( + children: [ + const Icon(Icons.error_outline_rounded, + color: AppColors.danger, size: 18), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + state.error!, + style: const TextStyle( + color: AppColors.danger, + fontSize: 13, + ), + ), + ), + ], + ), + ).animate().shake(duration: 300.ms, hz: 3), + PrimaryButton( + label: 'Complete Sale', + icon: Icons.check_circle_outline_rounded, + large: true, + tone: ButtonTone.success, + busy: state.isProcessing, + onPressed: cartEmpty ? null : _confirm, + trailing: Text( + Formatters.money(total), + style: AppTypography.money(19, color: Colors.white), + ), + ), + ], + ), + ), + ); + } + + Widget _mini(String label, String value) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, style: const TextStyle( fontSize: 11, color: AppColors.textSecondary, - )), - Text(value, - style: AppTypography.money(14, weight: FontWeight.w600)), - ]); - - Widget _dot() => Container( - width: 3, - height: 3, - margin: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), - decoration: const BoxDecoration( - color: AppColors.textTertiary, - shape: BoxShape.circle, - ), + ), + ), + Text(value, style: AppTypography.money(13.5)), + ], ); } @@ -480,10 +653,10 @@ class _MethodTile extends StatelessWidget { borderRadius: AppRadius.brMd, child: AnimatedContainer( duration: AppMotion.fast, - width: 118, + width: 104, padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.lg, + horizontal: AppSpacing.sm, + vertical: AppSpacing.md, ), decoration: BoxDecoration( borderRadius: AppRadius.brMd, @@ -491,19 +664,24 @@ class _MethodTile extends StatelessWidget { color: selected ? AppColors.primary : AppColors.border, ), ), - child: Column(children: [ - Text(method.emoji, style: const TextStyle(fontSize: 24)), - const SizedBox(height: AppSpacing.sm), - Text( - method.label, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: selected ? Colors.white : AppColors.textPrimary, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(method.emoji, style: const TextStyle(fontSize: 22)), + const SizedBox(height: AppSpacing.xs), + Text( + method.label, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: selected ? Colors.white : AppColors.textPrimary, + ), ), - ), - ]), + ], + ), ), ), ); diff --git a/lib/presentation/pos/widgets/app_sidebar.dart b/lib/presentation/pos/widgets/app_sidebar.dart index 17f5ce2..46a1029 100644 --- a/lib/presentation/pos/widgets/app_sidebar.dart +++ b/lib/presentation/pos/widgets/app_sidebar.dart @@ -7,7 +7,6 @@ import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_layout.dart'; import '../../../core/theme/app_typography.dart'; import '../../../core/utils/formatters.dart'; -import '../../../domain/entities/sync_event.dart'; import '../../auth/providers/auth_controller.dart'; import '../../sync/widgets/sign_out_dialog.dart'; import '../providers/cart_controller.dart'; @@ -232,10 +231,7 @@ class _Section extends ConsumerWidget { final active = ref.watch(activeModuleProvider); final cartCount = ref.watch(cartItemCountProvider); final ready = ref.watch(catalogueReadyProvider); - final outstanding = ref - .watch(syncEventsProvider) - .where((e) => e.status != SyncStatus.synced) - .length; + final outstanding = ref.watch(unsyncedCountProvider).value ?? 0; return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/presentation/pos/widgets/billing_panel.dart b/lib/presentation/pos/widgets/billing_panel.dart index c7eb86f..018dc75 100644 --- a/lib/presentation/pos/widgets/billing_panel.dart +++ b/lib/presentation/pos/widgets/billing_panel.dart @@ -82,61 +82,104 @@ class _Header extends ConsumerWidget { return Padding( padding: const EdgeInsets.fromLTRB( - AppSpacing.xl, AppSpacing.lg, AppSpacing.md, - AppSpacing.lg, + AppSpacing.sm, + AppSpacing.md, ), - child: Row(children: [ - Text('Cart', style: context.text.headlineSmall), - const SizedBox(width: AppSpacing.sm), - if (cart.isNotEmpty) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: AppColors.primarySurface, - borderRadius: AppRadius.brPill, - ), + child: Row( + children: [ + Flexible( child: Text( - '${cart.lineCount}', - style: const TextStyle( - color: AppColors.primary, - fontWeight: FontWeight.w800, - fontSize: 13, + 'Cart', + overflow: TextOverflow.ellipsis, + style: context.text.titleLarge, + ), + ), + if (cart.isNotEmpty) ...[ + const SizedBox(width: AppSpacing.sm), + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: AppColors.primarySurface, + borderRadius: AppRadius.brPill, + ), + child: Text( + '${cart.lineCount}', + style: const TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w700, + fontSize: 12.5, + ), ), ), - ), - const Spacer(), - if (controller.canUndo) - IconButton( - tooltip: 'Undo (F8)', - onPressed: controller.undo, - icon: const Icon(Icons.undo_rounded, size: 19), - color: AppColors.textSecondary, - ), - if (cart.isNotEmpty) ...[ - TextButton.icon( - onPressed: () async { - await controller.park(); - ref.invalidate(parkedBillsProvider); - if (context.mounted) context.showSnack('Bill parked'); - }, - icon: const Icon(Icons.pause_circle_outline_rounded, size: 17), - label: const Text('Park'), - style: TextButton.styleFrom(foregroundColor: AppColors.warning), - ), - TextButton( - onPressed: controller.clear, - style: TextButton.styleFrom(foregroundColor: AppColors.danger), - child: const Text('Clear'), - ), + ], + const Spacer(), + // Icon-only actions: labelled buttons overflowed the 380px panel. + if (controller.canUndo) + _IconAction( + icon: Icons.undo_rounded, + tooltip: 'Undo (F8)', + color: AppColors.textSecondary, + onTap: controller.undo, + ), + if (cart.isNotEmpty) ...[ + _IconAction( + icon: Icons.pause_circle_outline_rounded, + tooltip: 'Park bill', + color: AppColors.warning, + onTap: () async { + await controller.park(); + ref.invalidate(parkedBillsProvider); + if (context.mounted) context.showSnack('Bill parked'); + }, + ), + _IconAction( + icon: Icons.delete_outline_rounded, + tooltip: 'Clear bill', + color: AppColors.danger, + onTap: controller.clear, + ), + ], + if (inSheet) + _IconAction( + icon: Icons.close_rounded, + tooltip: 'Close', + color: AppColors.textSecondary, + onTap: () => Navigator.of(context).pop(), + ), ], - if (inSheet) - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close_rounded), - ), - ]), + ), + ); + } +} + +/// Compact square action used in the bill header. +class _IconAction extends StatelessWidget { + const _IconAction({ + required this.icon, + required this.tooltip, + required this.color, + required this.onTap, + }); + + final IconData icon; + final String tooltip; + final Color color; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: IconButton( + onPressed: onTap, + icon: Icon(icon, size: 19), + color: color, + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + padding: EdgeInsets.zero, + ), ); } } @@ -305,11 +348,16 @@ class _Row extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2), child: Row(children: [ - Text(label, + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 14, color: AppColors.textSecondary, - )), + ), + ), + ), if (hint != null) ...[ const SizedBox(width: AppSpacing.xs), Text('($hint)', @@ -318,6 +366,7 @@ class _Row extends StatelessWidget { color: AppColors.textTertiary, )), ], + const SizedBox(width: AppSpacing.sm), const Spacer(), Text( value, diff --git a/lib/presentation/pos/widgets/cart_fab.dart b/lib/presentation/pos/widgets/cart_fab.dart index 62de33f..b15854e 100644 --- a/lib/presentation/pos/widgets/cart_fab.dart +++ b/lib/presentation/pos/widgets/cart_fab.dart @@ -22,7 +22,11 @@ class CartFab extends ConsumerWidget { return Padding( padding: const EdgeInsets.all(AppSpacing.lg), - child: Material( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width - AppSpacing.xxxl, + ), + child: Material( color: AppColors.primary, borderRadius: AppRadius.brLg, elevation: 0, @@ -57,15 +61,18 @@ class CartFab extends ConsumerWidget { ), ), const SizedBox(width: AppSpacing.md), - const Text( - 'View bill', - style: TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w600, + const Flexible( + child: Text( + 'View bill', + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), ), ), - const SizedBox(width: AppSpacing.xl), + const SizedBox(width: AppSpacing.lg), Text( Formatters.money(cart.grandTotal), style: AppTypography.money(19, color: Colors.white), @@ -77,6 +84,7 @@ class CartFab extends ConsumerWidget { ), ), ), + ), ), ).animate().fadeIn(duration: 180.ms).slideY(begin: 0.3, end: 0); } diff --git a/lib/presentation/pos/widgets/customer_bar.dart b/lib/presentation/pos/widgets/customer_bar.dart index 861bd7f..24d5cd6 100644 --- a/lib/presentation/pos/widgets/customer_bar.dart +++ b/lib/presentation/pos/widgets/customer_bar.dart @@ -1,13 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../../core/router/app_router.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/utils/extensions.dart'; import '../../../core/utils/formatters.dart'; import '../../../core/widgets/status_pill.dart'; +import '../../customer/widgets/customer_capture_sheet.dart'; import '../providers/cart_controller.dart'; /// Strip above the product grid showing who the sale belongs to. @@ -85,7 +83,7 @@ class CustomerBar extends ConsumerWidget { ), const SizedBox(width: AppSpacing.sm), OutlinedButton.icon( - onPressed: () => context.push(AppRoutes.existingCustomer), + onPressed: () => showCustomerCaptureSheet(context), icon: const Icon(Icons.sync_alt_rounded, size: 17), label: Text(customer == null ? 'Add Customer' : 'Change'), style: OutlinedButton.styleFrom( diff --git a/lib/presentation/pos/widgets/discount_sheet.dart b/lib/presentation/pos/widgets/discount_sheet.dart index 29c1914..09cc046 100644 --- a/lib/presentation/pos/widgets/discount_sheet.dart +++ b/lib/presentation/pos/widgets/discount_sheet.dart @@ -137,12 +137,12 @@ class _DiscountSheetState extends State<_DiscountSheet> { segments: const [ ButtonSegment( value: DiscountType.percentage, - label: Text('Percentage'), + label: Text('Percent'), icon: Icon(Icons.percent_rounded, size: 17), ), ButtonSegment( value: DiscountType.flat, - label: Text('Flat amount'), + label: Text('Flat'), icon: Icon(Icons.currency_rupee_rounded, size: 17), ), ], diff --git a/lib/presentation/pos/widgets/page_header.dart b/lib/presentation/pos/widgets/page_header.dart index 56f5f32..9c6d265 100644 --- a/lib/presentation/pos/widgets/page_header.dart +++ b/lib/presentation/pos/widgets/page_header.dart @@ -1,9 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; import '../../../app/providers.dart'; -import '../../../core/router/app_router.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_layout.dart'; @@ -29,6 +27,8 @@ class PageHeader extends ConsumerWidget { final module = ref.watch(activeModuleProvider); final now = ref.watch(clockProvider).value ?? DateTime.now(); final compact = layout.sidebarIsDrawer; + // Status chrome is the first thing dropped when width gets tight. + final showStatus = MediaQuery.sizeOf(context).width >= 1180; return Container( constraints: const BoxConstraints(minHeight: AppSizes.headerHeight), @@ -75,7 +75,7 @@ class PageHeader extends ConsumerWidget { const Spacer(), - if (!compact) ...[ + if (showStatus) ...[ const _LivePill(), const SizedBox(width: AppSpacing.lg), Text( @@ -250,7 +250,7 @@ class _ParkedBillsButton extends ConsumerWidget { builder: (_) => AlertDialog( title: const Text('Parked bills'), content: SizedBox( - width: 380, + width: (MediaQuery.sizeOf(context).width - 96).clamp(280.0, 380.0), child: ListView.separated( shrinkWrap: true, itemCount: parked.length, @@ -297,7 +297,10 @@ class _NewSaleButton extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { void start() { ref.read(cartControllerProvider.notifier).reset(); - context.go(AppRoutes.welcome); + ref.read(activeModuleProvider.notifier).state = PosModule.pos; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(const SnackBar(content: Text('Started a new sale.'))); } if (compact) { diff --git a/lib/presentation/pos/widgets/product_card.dart b/lib/presentation/pos/widgets/product_card.dart index 3458866..7214a67 100644 --- a/lib/presentation/pos/widgets/product_card.dart +++ b/lib/presentation/pos/widgets/product_card.dart @@ -88,10 +88,13 @@ class _ProductCardState extends State { ), ), const SizedBox(height: AppSpacing.xs + 2), - Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ + // Scales down rather than overflowing on small tiles. + FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ Text( Formatters.money(p.price), style: AppTypography.money(17, @@ -112,8 +115,9 @@ class _ProductCardState extends State { ), ), ), + ], ], - ], + ), ), const SizedBox(height: AppSpacing.xs), Text( diff --git a/lib/presentation/receipt/screens/receipt_screen.dart b/lib/presentation/receipt/screens/receipt_screen.dart index 718f113..7acb3cc 100644 --- a/lib/presentation/receipt/screens/receipt_screen.dart +++ b/lib/presentation/receipt/screens/receipt_screen.dart @@ -58,7 +58,7 @@ class _ReceiptScreenState extends ConsumerState { void _newSale() { _timer?.cancel(); ref.read(cartControllerProvider.notifier).reset(); - if (mounted) context.go(AppRoutes.welcome); + if (mounted) context.go(AppRoutes.pos); } void _continueBilling() { diff --git a/lib/presentation/sync/providers/sync_controller.dart b/lib/presentation/sync/providers/sync_controller.dart index 1efcbd5..e65e591 100644 --- a/lib/presentation/sync/providers/sync_controller.dart +++ b/lib/presentation/sync/providers/sync_controller.dart @@ -3,10 +3,28 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app/providers.dart'; import '../../../domain/entities/shift_report.dart'; import '../../../domain/entities/sync_event.dart'; +import '../../../domain/repositories/sync_repository.dart'; import '../../auth/providers/auth_controller.dart'; import '../../pos/providers/catalog_providers.dart'; -/// Progress of a catalogue pull. +/// Bumped after every import so catalogue-backed providers refetch. +final catalogueVersionProvider = StateProvider((ref) => 0); + +/// Bumped after every sale or sync so order-backed providers refetch. +final orderVersionProvider = StateProvider((ref) => 0); + +/// Whether products exist on this terminal. Billing is gated on it. +final catalogueReadyProvider = Provider((ref) { + ref.watch(catalogueVersionProvider); + return ref.watch(syncRepositoryProvider).hasCatalogue; +}); + +final lastImportAtProvider = Provider((ref) { + ref.watch(catalogueVersionProvider); + return ref.watch(syncRepositoryProvider).lastImportAt; +}); + +// ------------------------------------------------------- Morning: import sealed class ImportState { const ImportState(); } @@ -34,20 +52,6 @@ class ImportFailed extends ImportState { final String message; } -/// Bumped after every successful import so catalogue providers refetch. -final catalogueVersionProvider = StateProvider((ref) => 0); - -/// Whether the terminal has products to sell. The POS is gated on this. -final catalogueReadyProvider = Provider((ref) { - ref.watch(catalogueVersionProvider); - return ref.watch(syncRepositoryProvider).hasCatalogue; -}); - -final lastImportAtProvider = Provider((ref) { - ref.watch(catalogueVersionProvider); - return ref.watch(syncRepositoryProvider).lastImportAt; -}); - class CatalogueImportController extends StateNotifier { CatalogueImportController(this._ref) : super(const ImportIdle()); @@ -55,7 +59,6 @@ class CatalogueImportController extends StateNotifier { Future run() async { if (state is ImportRunning) return false; - state = const ImportRunning(0, 'Starting…'); final event = await _ref.read(syncRepositoryProvider).importCatalogue( @@ -65,13 +68,11 @@ class CatalogueImportController extends StateNotifier { ); if (event.status == SyncStatus.synced) { - // Force every catalogue-backed provider to refetch. _ref.read(catalogueVersionProvider.notifier).state++; _ref.invalidate(allProductsProvider); _ref.invalidate(visibleProductsProvider); _ref.invalidate(categoryCountsProvider); _ref.invalidate(lowStockProductsProvider); - state = ImportDone(event); return true; } @@ -79,8 +80,6 @@ class CatalogueImportController extends StateNotifier { state = ImportFailed(event.error ?? 'Import failed.'); return false; } - - void reset() => state = const ImportIdle(); } final catalogueImportProvider = @@ -88,69 +87,89 @@ final catalogueImportProvider = (ref) => CatalogueImportController(ref), ); -// ------------------------------------------------------------------ Events -/// Bumped whenever the event log changes. -final syncVersionProvider = StateProvider((ref) => 0); - -final syncEventsProvider = Provider>((ref) { - ref.watch(syncVersionProvider); - ref.watch(catalogueVersionProvider); - return ref.watch(syncRepositoryProvider).events; +// ---------------------------------------------------- Business hours: read +/// Bills still held on this terminal at sync_status = 0. +final unsyncedCountProvider = FutureProvider((ref) { + ref.watch(orderVersionProvider); + return ref.watch(syncRepositoryProvider).unsyncedCount(); }); -final hasUnsyncedProvider = Provider((ref) { - ref.watch(syncVersionProvider); - ref.watch(catalogueVersionProvider); - return ref.watch(syncRepositoryProvider).hasUnsyncedEvents; -}); - -/// Today's takings, recomputed from local sales on every change. -final shiftReportProvider = Provider((ref) { - ref.watch(syncVersionProvider); +/// Today's totals, read back from SQLite. +final todayReportProvider = FutureProvider((ref) { + ref.watch(orderVersionProvider); final session = ref.watch(cashierSessionProvider); final user = ref.watch(currentUserProvider); - return ref.watch(syncRepositoryProvider).buildShiftReport( - businessDate: DateTime.now(), + return ref.watch(syncRepositoryProvider).todayReport( terminalId: session.terminalId, cashierName: user?.name ?? session.name, ); }); -/// Drives the push button and the sign-out dialog. -class ReportPushController extends StateNotifier { - ReportPushController(this._ref) : super(false); +/// Per-order sync state for the events log. +final orderSyncRowsProvider = FutureProvider>((ref) { + ref.watch(orderVersionProvider); + return ref.watch(syncRepositoryProvider).orderSyncRows(); +}); + +final syncEventsProvider = Provider>((ref) { + ref.watch(orderVersionProvider); + ref.watch(catalogueVersionProvider); + return ref.watch(syncRepositoryProvider).events; +}); + +// ------------------------------------------------------ End of day: upload +sealed class OrderSyncState { + const OrderSyncState(); +} + +class SyncIdle extends OrderSyncState { + const SyncIdle(); +} + +class SyncRunning extends OrderSyncState { + const SyncRunning(this.progress, this.stage); + + final double progress; + final String stage; +} + +class SyncFinished extends OrderSyncState { + const SyncFinished(this.outcome); + + final SyncOutcome outcome; +} + +class OrderSyncController extends StateNotifier { + OrderSyncController(this._ref) : super(const SyncIdle()); final Ref _ref; - /// Pushes today's report. Returns the resulting event so the caller can - /// tell the cashier whether it landed. - Future pushToday() async { - state = true; - try { - final report = _ref.read(shiftReportProvider); - final event = - await _ref.read(syncRepositoryProvider).pushShiftReport(report); - _ref.read(syncVersionProvider.notifier).state++; - return event; - } finally { - if (mounted) state = false; + bool get isRunning => state is SyncRunning; + + /// Uploads every bill at sync_status = 0 and flips the accepted ones to 1. + Future run() async { + if (isRunning) { + return const SyncOutcome(attempted: 0, uploaded: 0); } + + state = const SyncRunning(0, 'Starting…'); + + final outcome = await _ref.read(syncRepositoryProvider).syncOrders( + onProgress: (progress, stage) { + if (mounted) state = SyncRunning(progress, stage); + }, + ); + + _ref.read(orderVersionProvider.notifier).state++; + if (mounted) state = SyncFinished(outcome); + return outcome; } - Future retry(String eventId) async { - state = true; - try { - final event = await _ref.read(syncRepositoryProvider).retry(eventId); - _ref.read(syncVersionProvider.notifier).state++; - return event; - } finally { - if (mounted) state = false; - } - } + void reset() => state = const SyncIdle(); } -final reportPushProvider = - StateNotifierProvider( - (ref) => ReportPushController(ref), +final orderSyncProvider = + StateNotifierProvider( + (ref) => OrderSyncController(ref), ); diff --git a/lib/presentation/sync/widgets/sign_out_dialog.dart b/lib/presentation/sync/widgets/sign_out_dialog.dart index 2231b2f..15cd66e 100644 --- a/lib/presentation/sync/widgets/sign_out_dialog.dart +++ b/lib/presentation/sync/widgets/sign_out_dialog.dart @@ -7,9 +7,9 @@ import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/utils/formatters.dart'; import '../../../core/widgets/primary_button.dart'; -import '../../../domain/entities/sync_event.dart'; import '../../auth/providers/auth_controller.dart'; import '../../pos/providers/cart_controller.dart'; +import '../../../domain/repositories/sync_repository.dart'; import '../providers/sync_controller.dart'; /// End-of-shift flow. @@ -33,7 +33,7 @@ class _SignOutDialog extends ConsumerStatefulWidget { } class _SignOutDialogState extends ConsumerState<_SignOutDialog> { - SyncEvent? _result; + SyncOutcome? _result; void _finish() { ref.read(cartControllerProvider.notifier).reset(); @@ -43,12 +43,12 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> { } Future _pushThenFinish() async { - final event = await ref.read(reportPushProvider.notifier).pushToday(); + final outcome = await ref.read(orderSyncProvider.notifier).run(); if (!mounted) return; - setState(() => _result = event); + setState(() => _result = outcome); - if (event.status == SyncStatus.synced) { + if (outcome.isSuccess) { await Future.delayed(const Duration(milliseconds: 700)); if (mounted) _finish(); } @@ -56,10 +56,11 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> { @override Widget build(BuildContext context) { - final report = ref.watch(shiftReportProvider); + final report = ref.watch(todayReportProvider).value; + final pending = ref.watch(unsyncedCountProvider).value ?? 0; final cart = ref.watch(cartControllerProvider); - final pushing = ref.watch(reportPushProvider); - final failed = _result?.status == SyncStatus.failed; + final pushing = ref.watch(orderSyncProvider) is SyncRunning; + final failed = _result != null && !_result!.isSuccess; return AlertDialog( title: const Text('End shift'), @@ -70,7 +71,8 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> { AppSpacing.sm, ), content: SizedBox( - width: 420, + // Never wider than the viewport allows. + width: (MediaQuery.sizeOf(context).width - 96).clamp(280.0, 420.0), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, @@ -85,17 +87,17 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> { 'and will be cleared. Park it first if you need it.', ), - if (report.isEmpty) + if (pending == 0) const _Banner( - icon: Icons.info_outline_rounded, - color: AppColors.textSecondary, - background: AppColors.surfaceAlt, - message: 'No sales were recorded today, so there is nothing ' - 'to push.', + icon: Icons.check_circle_outline_rounded, + color: AppColors.success, + background: AppColors.successSurface, + message: 'Every bill has already been uploaded. Nothing is ' + 'waiting on this terminal.', ) else ...[ const Text( - "Today's takings", + 'Waiting to upload', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, @@ -103,12 +105,14 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> { ), ), const SizedBox(height: AppSpacing.sm), - _row('Bills', '${report.billCount}'), - _row('Items sold', report.itemCount.toStringAsFixed(0)), - _row('Gross sales', Formatters.money(report.grossSales)), - _row('GST collected', Formatters.money(report.taxCollected)), - _row('Average basket', - Formatters.money(report.averageBasket)), + _row('Bills pending sync', '$pending'), + if (report != null) ...[ + _row('Bills today', '${report.billCount}'), + _row('Items sold', report.itemCount.toStringAsFixed(0)), + _row('Gross sales', Formatters.money(report.grossSales)), + _row('GST collected', + Formatters.money(report.taxCollected)), + ], ], if (failed) ...[ @@ -118,7 +122,7 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> { color: AppColors.danger, background: AppColors.dangerSurface, message: _result?.error ?? - 'The push failed. The report is still saved on this ' + 'Upload failed. Every bill is still stored on this ' 'terminal and can be retried from Events.', ), ], @@ -149,14 +153,14 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> { foregroundColor: AppColors.textSecondary, ), child: Text( - report.isEmpty ? 'Sign out' : 'Sign out without pushing', + pending == 0 ? 'Sign out' : 'Sign out without syncing', ), ), - if (!report.isEmpty) + if (pending > 0) SizedBox( width: 190, child: PrimaryButton( - label: failed ? 'Retry push' : 'Push & sign out', + label: failed ? 'Retry sync' : 'Sync & sign out', icon: Icons.cloud_upload_rounded, busy: pushing, onPressed: _pushThenFinish, diff --git a/lib/presentation/welcome/screens/welcome_screen.dart b/lib/presentation/welcome/screens/welcome_screen.dart deleted file mode 100644 index c554903..0000000 --- a/lib/presentation/welcome/screens/welcome_screen.dart +++ /dev/null @@ -1,337 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../../app/providers.dart'; -import '../../../core/constants/app_constants.dart'; -import '../../../core/router/app_router.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_dimens.dart'; -import '../../../core/utils/extensions.dart'; -import '../../../core/utils/formatters.dart'; -import '../../../core/widgets/glass_card.dart'; -import '../../pos/providers/cart_controller.dart'; -import '../widgets/welcome_illustration.dart'; - -/// Screen 1 — the terminal's resting state between sales. -class WelcomeScreen extends ConsumerWidget { - const WelcomeScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final session = ref.watch(cashierSessionProvider); - final clock = ref.watch(clockProvider).value ?? DateTime.now(); - - return Scaffold( - body: Container( - decoration: const BoxDecoration(gradient: AppColors.primaryGradient), - child: SafeArea( - child: Column( - children: [ - _TopBar(cashier: session.name, now: clock), - Expanded( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.xxl), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 920), - child: GlassCard( - blur: 18, - padding: EdgeInsets.all( - context.responsive( - compact: AppSpacing.xxl, - expanded: AppSpacing.giant, - ), - ), - radius: AppRadius.xxl, - shadows: AppColors.shadowLg, - child: context.isCompact - ? const _StackedLayout() - : const _SideBySideLayout(), - ), - ), - ).animate().fadeIn(duration: 350.ms).slideY( - begin: 0.04, - end: 0, - curve: Curves.easeOutCubic, - ), - ), - ), - const _BottomHint(), - ], - ), - ), - ), - ); - } -} - -class _SideBySideLayout extends StatelessWidget { - const _SideBySideLayout(); - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: const [ - Expanded(flex: 4, child: WelcomeIllustration(size: 260)), - SizedBox(width: AppSpacing.giant), - Expanded(flex: 5, child: _WelcomeContent()), - ], - ); - } -} - -class _StackedLayout extends StatelessWidget { - const _StackedLayout(); - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: const [ - WelcomeIllustration(size: 150), - SizedBox(height: AppSpacing.xxl), - _WelcomeContent(), - ], - ); - } -} - -class _WelcomeContent extends ConsumerWidget { - const _WelcomeContent(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Welcome to', - style: context.text.titleMedium?.copyWith( - color: AppColors.textSecondary, - letterSpacing: 1.4, - ), - ), - const SizedBox(height: AppSpacing.xs), - Text( - 'Nearle POS', - style: context.text.displaySmall?.copyWith( - color: AppColors.primary, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: AppSpacing.md), - Text( - 'Start a new sale by identifying the shopper, ' - 'or skip straight to billing.', - style: context.text.bodyMedium - ?.copyWith(color: AppColors.textSecondary), - ), - const SizedBox(height: AppSpacing.xxxl), - - _WelcomeAction( - icon: Icons.person_add_alt_1_rounded, - title: 'New Customer', - subtitle: 'Register and start earning loyalty points', - onTap: () => context.push(AppRoutes.registerCustomer), - primary: true, - ), - const SizedBox(height: AppSpacing.md), - _WelcomeAction( - icon: Icons.badge_outlined, - title: 'Existing Customer', - subtitle: 'Look up by mobile number', - onTap: () => context.push(AppRoutes.existingCustomer), - ), - const SizedBox(height: AppSpacing.md), - _WelcomeAction( - icon: Icons.directions_walk_rounded, - title: 'Skip Customer', - subtitle: 'Walk-in sale, no loyalty tracking', - onTap: () { - ref.read(cartControllerProvider.notifier).reset(); - context.go(AppRoutes.pos); - }, - ), - ], - ); - } -} - -/// A tall, unmistakable target — the cashier taps this hundreds of times a day. -class _WelcomeAction extends StatefulWidget { - const _WelcomeAction({ - required this.icon, - required this.title, - required this.subtitle, - required this.onTap, - this.primary = false, - }); - - final IconData icon; - final String title; - final String subtitle; - final VoidCallback onTap; - final bool primary; - - @override - State<_WelcomeAction> createState() => _WelcomeActionState(); -} - -class _WelcomeActionState extends State<_WelcomeAction> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - final bg = widget.primary - ? AppColors.primary - : (_hovered ? AppColors.primarySurface : AppColors.surface); - final fg = - widget.primary ? AppColors.textOnPrimary : AppColors.textPrimary; - final sub = widget.primary - ? AppColors.textOnPrimary.withValues(alpha: 0.78) - : AppColors.textSecondary; - - return MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: AnimatedContainer( - duration: AppMotion.fast, - transform: Matrix4.translationValues(0, _hovered ? -2 : 0, 0), - child: Material( - color: bg, - borderRadius: AppRadius.brLg, - child: InkWell( - onTap: widget.onTap, - borderRadius: AppRadius.brLg, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.xl, - vertical: AppSpacing.lg, - ), - decoration: BoxDecoration( - borderRadius: AppRadius.brLg, - border: Border.all( - color: widget.primary - ? Colors.transparent - : AppColors.border, - ), - boxShadow: widget.primary && _hovered - ? AppColors.shadowMd - : null, - ), - child: Row( - children: [ - Container( - width: 46, - height: 46, - decoration: BoxDecoration( - color: widget.primary - ? Colors.white.withValues(alpha: 0.18) - : AppColors.primarySurface, - borderRadius: AppRadius.brMd, - ), - child: Icon( - widget.icon, - color: widget.primary - ? AppColors.textOnPrimary - : AppColors.primary, - size: 22, - ), - ), - const SizedBox(width: AppSpacing.lg), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.title, - style: context.text.titleMedium?.copyWith(color: fg), - ), - const SizedBox(height: 2), - Text( - widget.subtitle, - style: context.text.bodySmall?.copyWith(color: sub), - ), - ], - ), - ), - Icon(Icons.arrow_forward_rounded, color: sub, size: 20), - ], - ), - ), - ), - ), - ), - ); - } -} - -class _TopBar extends StatelessWidget { - const _TopBar({required this.cashier, required this.now}); - - final String cashier; - final DateTime now; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.xxl, - vertical: AppSpacing.lg, - ), - child: Row( - children: [ - const Icon(Icons.storefront_rounded, - color: Colors.white, size: 26), - const SizedBox(width: AppSpacing.md), - Text( - AppConstants.storeName, - style: context.text.titleLarge?.copyWith(color: Colors.white), - ), - const Spacer(), - Text( - '${Formatters.date(now)} ${Formatters.time(now)}', - style: context.text.bodyMedium - ?.copyWith(color: Colors.white.withValues(alpha: 0.85)), - ), - const SizedBox(width: AppSpacing.xxl), - CircleAvatar( - radius: 16, - backgroundColor: Colors.white.withValues(alpha: 0.2), - child: Text( - Formatters.initials(cashier), - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(width: AppSpacing.sm), - Text(cashier, - style: context.text.bodyMedium?.copyWith(color: Colors.white)), - ], - ), - ); - } -} - -class _BottomHint extends StatelessWidget { - const _BottomHint(); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.xl), - child: Text( - 'Scan a barcode at any time to begin a walk-in sale', - style: context.text.bodySmall - ?.copyWith(color: Colors.white.withValues(alpha: 0.7)), - ), - ); - } -} diff --git a/lib/presentation/welcome/widgets/welcome_illustration.dart b/lib/presentation/welcome/widgets/welcome_illustration.dart deleted file mode 100644 index 0b3f533..0000000 --- a/lib/presentation/welcome/widgets/welcome_illustration.dart +++ /dev/null @@ -1,156 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; - -import '../../../core/theme/app_colors.dart'; - -/// Vector shopping-cart illustration drawn in code. -/// -/// Painting it avoids shipping a raster asset and keeps it crisp on 4K desktop -/// displays as well as tablet screens. -class WelcomeIllustration extends StatelessWidget { - const WelcomeIllustration({super.key, this.size = 240}); - - final double size; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: size, - height: size, - child: CustomPaint(painter: _CartPainter()), - ) - .animate(onPlay: (c) => c.repeat(reverse: true)) - .moveY(begin: 0, end: -8, duration: 2400.ms, curve: Curves.easeInOut); - } -} - -class _CartPainter extends CustomPainter { - @override - void paint(Canvas canvas, Size size) { - final w = size.width; - final h = size.height; - final unit = w / 100; - - // Soft backdrop disc. - canvas.drawCircle( - Offset(w * 0.5, h * 0.5), - w * 0.46, - Paint()..color = AppColors.primarySurface, - ); - - // Decorative arc. - canvas.drawArc( - Rect.fromCircle(center: Offset(w * 0.5, h * 0.5), radius: w * 0.46), - -1.1, - 2.0, - false, - Paint() - ..color = AppColors.primaryBorder - ..style = PaintingStyle.stroke - ..strokeWidth = unit * 1.6 - ..strokeCap = StrokeCap.round, - ); - - final stroke = Paint() - ..color = AppColors.primary - ..style = PaintingStyle.stroke - ..strokeWidth = unit * 3 - ..strokeCap = StrokeCap.round - ..strokeJoin = StrokeJoin.round; - - final fill = Paint()..color = AppColors.primary.withValues(alpha: 0.16); - - // Cart basket. - final basket = Path() - ..moveTo(w * 0.30, h * 0.36) - ..lineTo(w * 0.78, h * 0.36) - ..lineTo(w * 0.70, h * 0.60) - ..lineTo(w * 0.37, h * 0.60) - ..close(); - canvas.drawPath(basket, fill); - canvas.drawPath(basket, stroke); - - // Handle running to the push bar. - canvas.drawPath( - Path() - ..moveTo(w * 0.16, h * 0.26) - ..lineTo(w * 0.24, h * 0.26) - ..lineTo(w * 0.30, h * 0.36), - stroke, - ); - - // Basket ribs. - for (var i = 1; i <= 2; i++) { - final t = i / 3; - canvas.drawLine( - Offset(w * (0.30 + 0.48 * t), h * 0.36), - Offset(w * (0.37 + 0.33 * t), h * 0.60), - stroke..strokeWidth = unit * 1.6, - ); - } - stroke.strokeWidth = unit * 3; - - // Wheels. - for (final dx in [0.44, 0.66]) { - canvas.drawCircle( - Offset(w * dx, h * 0.70), - unit * 5, - Paint()..color = AppColors.surface, - ); - canvas.drawCircle(Offset(w * dx, h * 0.70), unit * 5, stroke); - } - - // Groceries poking out of the basket. - _item(canvas, Offset(w * 0.42, h * 0.30), unit * 5.5, - AppColors.tierGold.withValues(alpha: 0.9)); - _item(canvas, Offset(w * 0.55, h * 0.27), unit * 6.5, - AppColors.success.withValues(alpha: 0.85)); - _item(canvas, Offset(w * 0.67, h * 0.31), unit * 5, - AppColors.danger.withValues(alpha: 0.75)); - - // Receipt tape drifting away from the terminal. - final receipt = Path() - ..moveTo(w * 0.80, h * 0.20) - ..lineTo(w * 0.94, h * 0.20) - ..lineTo(w * 0.94, h * 0.44) - ..lineTo(w * 0.905, h * 0.40) - ..lineTo(w * 0.87, h * 0.44) - ..lineTo(w * 0.835, h * 0.40) - ..lineTo(w * 0.80, h * 0.44) - ..close(); - canvas.drawPath(receipt, Paint()..color = AppColors.surface); - canvas.drawPath( - receipt, - Paint() - ..color = AppColors.primaryLight - ..style = PaintingStyle.stroke - ..strokeWidth = unit * 1.4 - ..strokeJoin = StrokeJoin.round, - ); - - // Receipt lines. - final line = Paint() - ..color = AppColors.primaryBorder - ..strokeWidth = unit * 1.2 - ..strokeCap = StrokeCap.round; - for (var i = 0; i < 3; i++) { - final y = h * (0.25 + i * 0.05); - canvas.drawLine(Offset(w * 0.835, y), Offset(w * 0.905, y), line); - } - } - - void _item(Canvas canvas, Offset center, double radius, Color color) { - canvas.drawCircle(center, radius, Paint()..color = color); - canvas.drawCircle( - center, - radius, - Paint() - ..color = Colors.white.withValues(alpha: 0.5) - ..style = PaintingStyle.stroke - ..strokeWidth = 1.5, - ); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 6064afd..1aaf9e8 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,8 +7,10 @@ import Foundation import audioplayers_darwin import printing +import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 0ba1763..5bf88ba 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -232,6 +232,14 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" go_router: dependency: "direct main" description: @@ -376,6 +384,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72 + url: "https://pub.dev" + source: hosted + version: "0.19.2" objective_c: dependency: transitive description: @@ -393,7 +409,7 @@ packages: source: hosted version: "3.0.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" @@ -557,6 +573,62 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" + url: "https://pub.dev" + source: hosted + version: "2.5.11" + sqflite_common_ffi: + dependency: "direct main" + description: + name: sqflite_common_ffi + sha256: "5ccd38136edb9beb3213f6927775d52db70dfdadcdb28dad1f625ca9f2b9824f" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f + url: "https://pub.dev" + source: hosted + version: "2.4.3+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: c73fd75df1332d76a6257f4823ae4df9c791f522b97e4a60cbcad214de1becf4 + url: "https://pub.dev" + source: hosted + version: "3.5.0" stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c93f580..1d5fbea 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,6 +27,11 @@ dependencies: google_fonts: ^6.2.1 flutter_animate: ^4.5.0 + # Local persistence — SQLite is the source of truth on the terminal + sqflite: ^2.3.3 + sqflite_common_ffi: ^2.3.3 + path: ^1.9.0 + # Peripherals: scanner beeps and thermal receipt printing audioplayers: ^6.0.0 pdf: ^3.11.0 diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..d76d2a7 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:nearle_pos/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +}