From 6c0266c9c76f160b63ddcf8ad82568d952b84223 Mon Sep 17 00:00:00 2001 From: Anbarasu22004 Date: Wed, 5 Aug 2026 18:15:21 +0530 Subject: [PATCH] added product import and billing integration --- assets/images/logo.png | Bin 0 -> 29548 bytes lib/app/providers.dart | 14 +- lib/core/constants/app_constants.dart | 9 +- lib/data/datasources/local_store.dart | 12 ++ lib/data/local/catalogue_dao.dart | 18 ++ lib/data/local/order_dao.dart | 40 ++++ lib/data/local/terminal_identity.dart | 8 +- lib/data/remote/http_catalogue_source.dart | 12 +- lib/data/remote/http_order_transport.dart | 30 ++- .../repositories/sync_repository_impl.dart | 13 +- .../transaction_repository_impl.dart | 29 +++ .../repositories/transaction_repository.dart | 12 ++ .../auth/providers/auth_controller.dart | 9 +- .../auth/screens/login_screen.dart | 9 +- .../modules/screens/customers_view.dart | 67 +----- .../modules/screens/events_view.dart | 91 ++++++++ .../payment/providers/payment_controller.dart | 30 ++- .../payment/screens/payment_screen.dart | 154 ++++++-------- .../pos/providers/cart_controller.dart | 35 ++++ lib/presentation/pos/screens/pos_view.dart | 4 +- .../pos/widgets/admin_pin_dialog.dart | 197 ++++++++++++++++++ .../pos/widgets/billing_panel.dart | 142 +++++++++---- .../pos/widgets/customer_bar.dart | 110 ---------- lib/presentation/pos/widgets/page_header.dart | 88 +++----- .../pos/widgets/product_card.dart | 66 +++++- .../receipt/screens/receipt_screen.dart | 65 +++++- .../sync/widgets/sign_out_dialog.dart | 17 +- macos/Runner/DebugProfile.entitlements | 18 +- macos/Runner/Release.entitlements | 4 +- pubspec.lock | 12 +- pubspec.yaml | 1 + test/widget_test.dart | 30 +++ 32 files changed, 889 insertions(+), 457 deletions(-) create mode 100644 assets/images/logo.png create mode 100644 lib/presentation/pos/widgets/admin_pin_dialog.dart create mode 100644 test/widget_test.dart diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..aec2c2c6789c10bebebbead89936c8eeccac8d20 GIT binary patch literal 29548 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 hZuq|=1hN~$c5SIC{1Z`<^((ref) { // ------------------------------------------------------------------- Sync /// How this terminal reaches the back office. /// -/// Defaults to the simulated route so a fresh install is usable with no broker -/// and no endpoint; Settings re-points it. +/// Defaults to this store's live HTTP endpoint, so importing works out of the +/// box against real products rather than the offline demo catalogue. +/// Settings → Connectivity & sync → Configure re-points it to a different +/// store, endpoint, or transport without a rebuild. /// -/// Store and terminal ids always come from this device's own identity, never -/// from a literal — two terminals publishing on the same topic is the failure -/// this exists to prevent. +/// Terminal id always comes from this device's own identity, never from a +/// literal — two terminals publishing on the same topic is the failure this +/// exists to prevent. final syncConfigProvider = StateProvider((ref) { final terminal = ref.watch(terminalIdentityProvider); return SyncConfig( + transport: TransportKind.http, + httpBaseUrl: 'https://fiesta.nearle.app/live/api/v1/pos', storeId: terminal.storeId, terminalId: terminal.code, ); diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index 2b0594c..4afbf9d 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -39,8 +39,13 @@ class AppConstants { static const Duration barcodeScanTimeout = Duration(milliseconds: 120); static const int minBarcodeLength = 6; - /// Idle time after a completed sale before the terminal resets itself. - static const Duration postSaleResetDelay = Duration(seconds: 3); + /// Window after a completed sale during which the terminal waits, and the + /// bill is held back from the server — long enough for the cashier to + /// catch a mistake and cancel it before it becomes final. If the window + /// runs out (or "New Sale" is pressed early) the terminal resets and the + /// bill goes up; if it's cancelled first, the sale is voided and the cart + /// comes back exactly as it was. + static const Duration postSaleResetDelay = Duration(seconds: 30); static const int lowStockThreshold = 10; static const int maxParkedBills = 20; diff --git a/lib/data/datasources/local_store.dart b/lib/data/datasources/local_store.dart index 1828894..d2a3edc 100644 --- a/lib/data/datasources/local_store.dart +++ b/lib/data/datasources/local_store.dart @@ -146,6 +146,18 @@ class LocalStore { String? get catalogueRevision => _catalogueRevision; int get unsyncedOrders => _unsyncedOrders; + /// Drops the imported catalogue from disk and from the in-memory cache. + /// + /// Called at sign-out so the next shift never bills against a copy left + /// over from this one — [hasCatalogue] goes back to false, and the only way + /// to sell again is a fresh pull from the back office. + Future clearCatalogue() async { + await catalogue.clearCatalogue(); + _products.clear(); + _lastImportAt = null; + _catalogueRevision = null; + } + Future importCatalogue({ required List products, required List customers, diff --git a/lib/data/local/catalogue_dao.dart b/lib/data/local/catalogue_dao.dart index c374696..f8d5790 100644 --- a/lib/data/local/catalogue_dao.dart +++ b/lib/data/local/catalogue_dao.dart @@ -239,6 +239,24 @@ class CatalogueDao { }); } + /// Drops every product and forgets when the catalogue was last imported. + /// + /// Used at sign-out. Leaves customers, staff, orders and every other table + /// untouched — this is about the shelf, not the terminal's history — so the + /// next session starts with nothing to sell until it pulls a fresh copy from + /// the back office rather than carrying over whatever this session ended + /// with. + Future clearCatalogue() async { + await _db.transaction((txn) async { + await txn.delete(Tables.products); + await txn.delete( + Tables.meta, + where: 'key IN (?, ?)', + whereArgs: [MetaKeys.lastImportAt, MetaKeys.catalogueRevision], + ); + }); + } + /// Applies stock movement after a sale, clamped at zero. Future decrementStock(Map quantities) async { if (quantities.isEmpty) return; diff --git a/lib/data/local/order_dao.dart b/lib/data/local/order_dao.dart index 1d83e38..7602e27 100644 --- a/lib/data/local/order_dao.dart +++ b/lib/data/local/order_dao.dart @@ -85,6 +85,46 @@ class OrderDao { }); } + /// Reverses [commitSale]: deletes the order and its lines, adds the stock + /// back, and restores an attached customer's row exactly as passed in + /// (the caller supplies the pre-sale row — reconstructing it from deltas + /// here would get `last_visit_at` wrong). + Future voidSale({ + required String orderId, + required Map stockMovements, + Map? customerRow, + }) async { + await _db.transaction((txn) async { + await txn + .delete(Tables.orderItems, where: 'order_id = ?', whereArgs: [orderId]); + await txn.delete(Tables.orders, where: 'id = ?', whereArgs: [orderId]); + + final batch = txn.batch(); + final now = DateTime.now().millisecondsSinceEpoch; + stockMovements.forEach((id, qty) { + batch.rawUpdate( + 'UPDATE ${Tables.products} SET stock = stock + ?, updated_at = ? ' + 'WHERE id = ?', + [qty, now, id], + ); + }); + if (customerRow != null) { + batch.update( + Tables.customers, + { + 'loyalty_points': customerRow['loyalty_points'], + 'lifetime_spend': customerRow['lifetime_spend'], + 'visit_count': customerRow['visit_count'], + 'last_visit_at': customerRow['last_visit_at'], + }, + where: 'id = ?', + whereArgs: [customerRow['id']], + ); + } + await batch.commit(noResult: true); + }); + } + Future _insertOrder(DatabaseExecutor txn, SaleTransaction t) async { final cart = t.cart; diff --git a/lib/data/local/terminal_identity.dart b/lib/data/local/terminal_identity.dart index 681bbfe..e3bf443 100644 --- a/lib/data/local/terminal_identity.dart +++ b/lib/data/local/terminal_identity.dart @@ -53,7 +53,13 @@ class TerminalIdentityStore { /// /// The mint is idempotent: an existing device id is never replaced, so a /// terminal cannot silently change identity and orphan its own history. - Future load({String defaultStoreId = 'store-01'}) async { + /// + /// [defaultStoreId] matches the store this build's default HTTP endpoint + /// serves — see `syncConfigProvider` — so a fresh terminal's first import + /// pulls that store's real catalogue without anyone visiting Settings + /// first. Settings → Connectivity & sync → Configure changes it per + /// terminal from there. + Future load({String defaultStoreId = '1135'}) async { var deviceId = await _catalogue.meta(MetaKeys.deviceId); var code = await _catalogue.meta(MetaKeys.terminalCode); diff --git a/lib/data/remote/http_catalogue_source.dart b/lib/data/remote/http_catalogue_source.dart index 4618934..e6d6a28 100644 --- a/lib/data/remote/http_catalogue_source.dart +++ b/lib/data/remote/http_catalogue_source.dart @@ -11,10 +11,13 @@ import 'catalogue_wire.dart'; /// Pulls the catalogue from the back office over HTTP. /// /// ``` -/// GET {base}/catalogue?since={revision}&page={n} +/// GET {base}/catalogue?since={revision}&page={n}&page_size={pageSize}&store_id={storeId} /// Authorization: Bearer {apiKey} /// ``` /// +/// Pages are 0-indexed — the first page requested is `page=0` — matching the +/// back office's own convention rather than the more common 1-indexed one. +/// /// ```json /// { /// "revision": "rev-8821", @@ -53,6 +56,10 @@ class HttpCatalogueSource implements CatalogueSource { /// connection. static const int maxPages = 200; + /// Rows requested per page. Sent as `page_size` on every request so the + /// back office doesn't fall back to its own (smaller) default. + static const int pageSize = 500; + static const Duration _timeout = Duration(seconds: 30); @override @@ -77,7 +84,7 @@ class HttpCatalogueSource implements CatalogueSource { var revision = since ?? ''; var isDelta = false; - var page = 1; + var page = 0; onProgress?.call(0.05, 'Contacting the back office…'); @@ -137,6 +144,7 @@ class HttpCatalogueSource implements CatalogueSource { queryParameters: { if (since != null && since.isNotEmpty) 'since': since, 'page': '$page', + 'page_size': '$pageSize', 'store_id': config.storeId, 'terminal_id': config.terminalId, }, diff --git a/lib/data/remote/http_order_transport.dart b/lib/data/remote/http_order_transport.dart index 379cafe..9b8e41a 100644 --- a/lib/data/remote/http_order_transport.dart +++ b/lib/data/remote/http_order_transport.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:uuid/uuid.dart'; import '../../core/config/sync_config.dart'; import 'order_transport.dart'; @@ -13,6 +14,12 @@ import 'order_transport.dart'; /// having as the route to bring up first, and as the fallback when a broker is /// unreachable but the internet is not. /// +/// ``` +/// POST {base}/orders +/// { "schema": 1, "batch_id": "…", "store_id": "…", "terminal_id": "…", +/// "orders": [ … ] } +/// ``` +/// /// The endpoint must answer with the ids it committed: /// /// ```json @@ -29,6 +36,8 @@ class HttpOrderTransport implements OrderTransport { final SyncConfig config; final http.Client _client; + static const _uuid = Uuid(); + final _connection = StreamController.broadcast(); bool _reachable = true; @@ -73,6 +82,16 @@ class HttpOrderTransport implements OrderTransport { final uri = Uri.parse('${config.httpBaseUrl}/$path'); + // Deterministic from the set of ids in this batch — not a fresh random + // id per attempt — so a retry after a timeout (the same rows, because + // nothing was marked sent) carries the exact same batch_id as the + // attempt that may already have landed. That is what lets the back + // office collapse a retried batch server-side instead of re-billing it. + final batchId = _uuid.v5( + Uuid.NAMESPACE_URL, + items.map((o) => o['id']).join('|'), + ); + http.Response response; try { response = await _client @@ -82,15 +101,13 @@ class HttpOrderTransport implements OrderTransport { 'content-type': 'application/json', if (config.apiKey != null) 'authorization': 'Bearer ${config.apiKey}', - // Lets the endpoint collapse a retried batch server-side rather - // than relying on every order id being checked individually. - 'idempotency-key': _batchKey(items), + 'idempotency-key': batchId, }, body: jsonEncode({ 'schema': 1, + 'batch_id': batchId, 'store_id': config.storeId, 'terminal_id': config.terminalId, - 'sent_at': DateTime.now().toIso8601String(), key: items, }), ) @@ -143,11 +160,6 @@ class HttpOrderTransport implements OrderTransport { return PushReceipt(accepted: accepted, rejected: rejected); } - /// Stable for a given set of records, so a retry after a timeout carries the - /// same key as the attempt that may already have landed. - String _batchKey(List> items) => - items.map((o) => o['id']).join('|').hashCode.toRadixString(16); - void _setReachable(bool value) { if (_reachable == value) return; _reachable = value; diff --git a/lib/data/repositories/sync_repository_impl.dart b/lib/data/repositories/sync_repository_impl.dart index df4772c..7297a7f 100644 --- a/lib/data/repositories/sync_repository_impl.dart +++ b/lib/data/repositories/sync_repository_impl.dart @@ -437,12 +437,12 @@ class SyncRepositoryImpl implements SyncRepository { 'registered_by_terminal': _store.terminal.code, }; - /// The JSON body sent per order. + /// The JSON body sent per order. Matches the back office's `/orders` + /// schema field for field — nothing added beyond it. 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 @@ -453,15 +453,6 @@ class SyncRepositoryImpl implements SyncRepository { }, 'subtotal': t.cart.subtotal, 'discount': t.cart.billDiscountTotal + t.cart.lineDiscountTotal, - 'promos': [ - for (final applied in t.cart.appliedPromos) - { - 'id': applied.promo.id, - 'name': applied.promo.name, - 'type': applied.promo.type.name, - 'amount': applied.amount, - }, - ], 'tax': t.cart.taxAmount, // GST per slab, as printed on the invoice. Sent as well as the total // because a compliant tax return is filed per slab, and recomputing the diff --git a/lib/data/repositories/transaction_repository_impl.dart b/lib/data/repositories/transaction_repository_impl.dart index 66eb744..1fef48f 100644 --- a/lib/data/repositories/transaction_repository_impl.dart +++ b/lib/data/repositories/transaction_repository_impl.dart @@ -30,6 +30,35 @@ class TransactionRepositoryImpl implements TransactionRepository { await _store.refreshUnsyncedCount(); } + @override + Future voidSale({ + required SaleTransaction transaction, + required Map stockMovements, + }) async { + // The customer attached to the cart is the pre-sale snapshot — commitSale + // never mutates it, only the separate `updatedCustomer` it computed — so + // restoring exactly this row undoes the loyalty movement precisely, + // rather than trying to reconstruct it from a delta. + final preSaleCustomer = transaction.cart.customer; + + await _store.orders.voidSale( + orderId: transaction.id, + stockMovements: stockMovements, + customerRow: preSaleCustomer == null + ? null + : CatalogueDao.customerToRow(preSaleCustomer), + ); + + // Disk is reverted; bring the read caches back in line with it. Negating + // the same map reuses cacheStockMovement's "subtract" semantics to add + // the stock back instead. + _store.cacheStockMovement( + stockMovements.map((id, qty) => MapEntry(id, -qty)), + ); + if (preSaleCustomer != null) _store.cacheCustomer(preSaleCustomer); + await _store.refreshUnsyncedCount(); + } + @override Future> history({int limit = 50}) => _store.orders.recent(limit: limit); diff --git a/lib/domain/repositories/transaction_repository.dart b/lib/domain/repositories/transaction_repository.dart index 262ed31..f46817c 100644 --- a/lib/domain/repositories/transaction_repository.dart +++ b/lib/domain/repositories/transaction_repository.dart @@ -13,6 +13,18 @@ abstract class TransactionRepository { Customer? updatedCustomer, }); + /// Reverses a sale still inside its cancellation window: deletes the order, + /// restores the stock it consumed, and puts an attached shopper's loyalty + /// balance back to what it was immediately before the sale. + /// + /// Only valid before the bill has been offered to the back office — this + /// does not send a cancellation anywhere, it erases the sale as if it had + /// never happened locally. + Future voidSale({ + required SaleTransaction transaction, + required Map stockMovements, + }); + Future> history({int limit = 50}); Future findByInvoice(String invoiceNumber); diff --git a/lib/presentation/auth/providers/auth_controller.dart b/lib/presentation/auth/providers/auth_controller.dart index 95a1256..ad5a354 100644 --- a/lib/presentation/auth/providers/auth_controller.dart +++ b/lib/presentation/auth/providers/auth_controller.dart @@ -126,7 +126,14 @@ class AuthController extends StateNotifier { ); } - void signOut() => state = const Unauthenticated(); + /// The next shift should only ever bill against what the back office + /// answers with, never a catalogue instance carried over from this + /// session — so the local product table is dropped before the session + /// itself is. + Future signOut() async { + await _ref.read(localStoreProvider).clearCatalogue(); + state = const Unauthenticated(); + } void clearError() { if (state is AuthFailure) state = const Unauthenticated(); diff --git a/lib/presentation/auth/screens/login_screen.dart b/lib/presentation/auth/screens/login_screen.dart index 4386203..11ec5ea 100644 --- a/lib/presentation/auth/screens/login_screen.dart +++ b/lib/presentation/auth/screens/login_screen.dart @@ -106,14 +106,7 @@ class _BrandPanel extends StatelessWidget { borderRadius: BorderRadius.circular(11), ), alignment: Alignment.center, - child: const Text( - 'N', - style: TextStyle( - color: AppColors.primary, - fontSize: 23, - fontWeight: FontWeight.w800, - ), - ), + child: Image.asset('assets/images/logo.png', fit: BoxFit.contain), ), const SizedBox(width: AppSpacing.md), const Flexible( diff --git a/lib/presentation/modules/screens/customers_view.dart b/lib/presentation/modules/screens/customers_view.dart index c18fed8..e12055d 100644 --- a/lib/presentation/modules/screens/customers_view.dart +++ b/lib/presentation/modules/screens/customers_view.dart @@ -5,7 +5,6 @@ import '../../../app/providers.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/utils/formatters.dart'; -import '../../../core/widgets/status_pill.dart'; import '../../../domain/entities/customer.dart'; import '../../customer/widgets/customer_capture_sheet.dart'; import '../widgets/module_widgets.dart'; @@ -24,14 +23,6 @@ class CustomersView extends ConsumerStatefulWidget { class _CustomersViewState extends ConsumerState { String _query = ''; - MembershipTier? _tier; - - Color _tierColor(MembershipTier t) => switch (t) { - MembershipTier.bronze => AppColors.tierBronze, - MembershipTier.silver => AppColors.tierSilver, - MembershipTier.gold => AppColors.tierGold, - MembershipTier.platinum => AppColors.tierPlatinum, - }; @override Widget build(BuildContext context) { @@ -39,10 +30,9 @@ class _CustomersViewState extends ConsumerState { final filtered = all.where((c) { final q = _query.trim().toLowerCase(); - final matchesQuery = q.isEmpty || + return q.isEmpty || c.name.toLowerCase().contains(q) || c.mobile.contains(q); - return matchesQuery && (_tier == null || c.tier == _tier); }).toList(); final lifetime = all.fold(0, (s, c) => s + c.lifetimeSpend); @@ -85,26 +75,6 @@ class _CustomersViewState extends ConsumerState { ), const SizedBox(height: AppSpacing.lg), - PanelCard( - title: 'Tier distribution', - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final t in MembershipTier.values) - ProgressRow( - label: '${t.label} · ' - '${(t.discountRate * 100).toStringAsFixed(0)}% off', - value: '${all.where((c) => c.tier == t).length}', - fraction: all.isEmpty - ? 0 - : all.where((c) => c.tier == t).length / all.length, - color: _tierColor(t), - ), - ], - ), - ), - const SizedBox(height: AppSpacing.lg), - PanelCard( title: 'Customer book', subtitle: '${filtered.length} shown', @@ -134,45 +104,11 @@ class _CustomersViewState extends ConsumerState { isDense: true, ), ), - const SizedBox(height: AppSpacing.md), - Wrap( - spacing: AppSpacing.sm, - runSpacing: AppSpacing.sm, - children: [ - ChoiceChip( - label: const Text('All tiers'), - selected: _tier == null, - onSelected: (_) => setState(() => _tier = null), - labelStyle: TextStyle( - fontSize: 12.5, - fontWeight: FontWeight.w600, - color: _tier == null - ? Colors.white - : AppColors.textSecondary, - ), - ), - for (final t in MembershipTier.values) - ChoiceChip( - label: Text(t.label), - selected: _tier == t, - onSelected: (_) => - setState(() => _tier = _tier == t ? null : t), - labelStyle: TextStyle( - fontSize: 12.5, - fontWeight: FontWeight.w600, - color: _tier == t - ? Colors.white - : AppColors.textSecondary, - ), - ), - ], - ), const SizedBox(height: AppSpacing.lg), ResponsiveTable( columns: const [ TableCol('Customer', flex: 4), TableCol('Mobile', flex: 3, priority: 1), - TableCol('Tier', flex: 2), TableCol('Points', flex: 2, numeric: true, priority: 1), TableCol('Lifetime', flex: 2, numeric: true), TableCol('Visits', flex: 2, numeric: true, priority: 1), @@ -199,7 +135,6 @@ class _CustomersViewState extends ConsumerState { ], ), Cell(Formatters.mobile(c.mobile), mono: true), - StatusPill.tier(c.tier, dense: true), Cell('${c.loyaltyPoints}', mono: true), Cell(Formatters.moneyCompact(c.lifetimeSpend), mono: true, bold: true,), diff --git a/lib/presentation/modules/screens/events_view.dart b/lib/presentation/modules/screens/events_view.dart index ac7eb21..9bca8b5 100644 --- a/lib/presentation/modules/screens/events_view.dart +++ b/lib/presentation/modules/screens/events_view.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../app/providers.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/utils/formatters.dart'; import '../../../core/widgets/primary_button.dart'; +import '../../../data/sync/sync_engine.dart'; import '../../../domain/entities/transaction.dart'; import '../../../domain/repositories/sync_repository.dart'; import '../../sync/providers/sync_controller.dart'; @@ -26,10 +28,21 @@ class EventsView extends ConsumerWidget { final syncState = ref.watch(orderSyncProvider); final events = ref.watch(syncEventsProvider); + // The engine may not have emitted yet on a cold start, so fall back to + // its current value rather than showing nothing. This is the same state + // that drives the header pill — it is what actually knows whether the + // automatic push right after a sale succeeded, not just what the manual + // "Sync" button on this page last did. + final engine = ref.watch(syncEngineStateProvider).value ?? + ref.watch(syncEngineProvider).state; + final r = report.value; return ModulePage( children: [ + if (engine.lastError != null && pending > 0) + _EngineWarningBanner(engine: engine), + Wrap( spacing: AppSpacing.lg, runSpacing: AppSpacing.lg, @@ -310,3 +323,81 @@ class EventsView extends ConsumerWidget { ), ); } + +/// Flags a bill that could not reach the server on its own — the automatic +/// push right after checkout, not the manual "Sync" button below. +/// +/// Sits above everything else on the page because a bill stuck here is the +/// one thing on this screen that needs a person to notice it, rather than +/// just waiting for the next background retry. +class _EngineWarningBanner extends StatelessWidget { + const _EngineWarningBanner({required this.engine}); + + final SyncEngineState engine; + + @override + Widget build(BuildContext context) { + final halted = engine.isHalted; + final color = halted ? AppColors.danger : AppColors.warning; + final surface = halted ? AppColors.dangerSurface : AppColors.warningSurface; + + final retry = engine.nextAttemptAt; + final retryNote = halted + ? 'Retrying will not help until this is fixed — press Sync below ' + 'once it is sorted.' + : retry == null + ? 'It will retry automatically, or press Sync below to try now.' + : 'It will retry automatically at ${Formatters.time(retry)}, or ' + 'press Sync below to try now.'; + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.lg), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration(color: surface, borderRadius: AppRadius.brLg), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.wifi_off_rounded, size: 20, color: color), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + halted + ? 'Sync halted — ${engine.pending} bill(s) not sent' + : "Couldn't reach the server — " + '${engine.pending} bill(s) not sent', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: color, + ), + ), + const SizedBox(height: 2), + Text( + engine.lastError ?? 'The last upload attempt failed.', + style: TextStyle( + fontSize: 12.5, + color: color, + height: 1.45, + ), + ), + const SizedBox(height: 4), + Text( + '$retryNote Every bill is still safe on this terminal — ' + 'nothing is lost while it waits.', + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + height: 1.45, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/payment/providers/payment_controller.dart b/lib/presentation/payment/providers/payment_controller.dart index b9d8218..ff3138c 100644 --- a/lib/presentation/payment/providers/payment_controller.dart +++ b/lib/presentation/payment/providers/payment_controller.dart @@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app/providers.dart'; import '../../../core/utils/extensions.dart'; -import '../../../data/sync/sync_engine.dart'; import '../../../domain/entities/transaction.dart'; import '../../../domain/usecases/checkout_sale.dart'; import '../../auth/providers/auth_controller.dart'; @@ -80,29 +79,27 @@ class PaymentController extends StateNotifier { /// Whether Complete Sale should be enabled. /// - /// Cash is the strict case: the drawer cannot be reconciled and no change can - /// be calculated unless the cashier states what was handed over. Card, UPI - /// and wallet settle on the external terminal, so they need no amount here. + /// Every method now requires the cashier to state what was actually + /// received before the sale can complete — a tap on Exact for the common + /// case, or a typed amount for a partial tender. `cashTendered` is the + /// amount entered for whichever method is currently active, not literally + /// cash; the name stayed to keep this change out of the rest of the app. bool get canConfirm { if (_billTotal <= 0) return false; // Staged splits already cover the bill. if (balanceDue <= 0.01) return true; - if (state.activeMethod.needsChange) { - return state.cashTendered >= balanceDue; - } - return true; + return state.cashTendered >= balanceDue; } /// Why the button is disabled, for display next to it. String? get blockedReason { if (_billTotal <= 0) return 'Add at least one item before charging.'; if (canConfirm) return null; - if (state.activeMethod.needsChange) { - return 'Enter the cash received, or tap Exact.'; - } - return null; + return state.activeMethod.needsChange + ? 'Enter the cash received, or tap Exact.' + : 'Enter the amount received, or tap Exact.'; } void selectMethod(PaymentMethod method) { @@ -212,10 +209,11 @@ class PaymentController extends StateNotifier { _ref.invalidate(visibleProductsProvider); _ref.read(orderVersionProvider.notifier).state++; - // The bill is safely on disk; getting it to the back office is the - // engine's problem now. Deliberately not awaited — the cashier must - // reach the receipt screen at network speed of zero. - _ref.read(syncEngineProvider).nudge(SyncTrigger.saleCommitted); + // Deliberately NOT nudging the sync engine here. The bill sits on this + // terminal, unsynced, until the receipt screen's cancellation window + // closes — either it is pressed past early with New Sale, or the + // window runs out — or the sale is voided if the cashier cancels + // instead. See ReceiptScreen. 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 29d2350..2bcf344 100644 --- a/lib/presentation/payment/screens/payment_screen.dart +++ b/lib/presentation/payment/screens/payment_screen.dart @@ -350,20 +350,41 @@ class _PaymentScreenState extends ConsumerState { return GlassCard( padding: const EdgeInsets.all(AppSpacing.lg), radius: AppRadius.xl, - child: state.activeMethod.needsChange - ? _cashTender(controller) - : _referenceTender(controller, state), + child: _amountTender(controller, state), ); } - Widget _cashTender(PaymentController controller) { + /// Amount entry for whichever method is active. Every method works the + /// same way now — a keypad, an Exact shortcut, and an explicit amount — + /// rather than cash alone asking what was received while card/UPI/wallet + /// silently assumed the full balance. Cash additionally gets denomination + /// chips and a change-due row, since only cash can be over-tendered; a + /// method that captures a reference (card, UPI, gift card) additionally + /// gets that field below the keypad. + Widget _amountTender(PaymentController controller, PaymentState state) { + final cash = state.activeMethod.needsChange; + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - const Text( - 'Cash received', - style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + Row( + children: [ + Text(state.activeMethod.emoji, style: const TextStyle(fontSize: 20)), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + cash + ? 'Cash received' + : '${state.activeMethod.label} amount received', + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ], ), const SizedBox(height: AppSpacing.md), Container( @@ -405,16 +426,20 @@ class _PaymentScreenState extends ConsumerState { label: const Text('Exact'), onPressed: () => _setCash(controller.balanceDue), ), - for (final note in const [50, 100, 200, 500, 2000]) - ActionChip( - label: Text('₹$note'), - onPressed: () => - _setCash((double.tryParse(_cashBuffer) ?? 0) + note), - ), + // Denomination shortcuts only make sense for physical notes. + if (cash) + for (final note in const [50, 100, 200, 500, 2000]) + ActionChip( + label: Text('₹$note'), + onPressed: () => + _setCash((double.tryParse(_cashBuffer) ?? 0) + note), + ), ], ), - const SizedBox(height: AppSpacing.md), - _changeRow(controller.changeDue), + if (cash) ...[ + const SizedBox(height: AppSpacing.md), + _changeRow(controller.changeDue), + ], const SizedBox(height: AppSpacing.md), Center( child: NumericKeypad( @@ -424,6 +449,21 @@ class _PaymentScreenState extends ConsumerState { onBackspace: _backspaceCash, ), ), + if (state.activeMethod.needsReference) ...[ + const SizedBox(height: AppSpacing.md), + TextField( + onChanged: controller.setReference, + decoration: InputDecoration( + labelText: switch (state.activeMethod) { + PaymentMethod.card => 'Approval code', + PaymentMethod.upi => 'UPI transaction ID', + PaymentMethod.giftCard => 'Gift card number', + _ => 'Reference', + }, + prefixIcon: const Icon(Icons.tag_rounded), + ), + ), + ], const SizedBox(height: AppSpacing.md), _splitButton(controller, amount: double.tryParse(_cashBuffer) ?? 0), ], @@ -473,71 +513,6 @@ class _PaymentScreenState extends ConsumerState { ); } - 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: 20),), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - '${state.activeMethod.label} payment', - overflow: TextOverflow.ellipsis, - style: - const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), - ), - ), - ], - ), - const SizedBox(height: AppSpacing.xxl), - Center( - child: Container( - width: 104, - height: 104, - decoration: const 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( - onChanged: controller.setReference, - decoration: InputDecoration( - labelText: switch (state.activeMethod) { - PaymentMethod.card => 'Approval code', - PaymentMethod.upi => 'UPI transaction ID', - PaymentMethod.giftCard => 'Gift card number', - _ => 'Reference', - }, - prefixIcon: const Icon(Icons.tag_rounded), - ), - ), - const SizedBox(height: AppSpacing.lg), - _splitButton(controller), - ], - ); - } - Widget _splitButton(PaymentController controller, {double? amount}) { return OutlinedButton.icon( onPressed: controller.balanceDue > 0 @@ -622,18 +597,17 @@ class _PaymentScreenState extends ConsumerState { ), ), ), - if (state.activeMethod.needsChange) - TextButton( - onPressed: () => _setCash(controller.balanceDue), - style: TextButton.styleFrom( - minimumSize: const Size(0, 30), - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - ), + TextButton( + onPressed: () => _setCash(controller.balanceDue), + style: TextButton.styleFrom( + minimumSize: const Size(0, 30), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, ), - child: const Text('Exact', - style: TextStyle(fontSize: 12.5),), ), + child: const Text('Exact', + style: TextStyle(fontSize: 12.5),), + ), ], ), ), diff --git a/lib/presentation/pos/providers/cart_controller.dart b/lib/presentation/pos/providers/cart_controller.dart index b9b65a3..1056996 100644 --- a/lib/presentation/pos/providers/cart_controller.dart +++ b/lib/presentation/pos/providers/cart_controller.dart @@ -302,6 +302,15 @@ class CartController extends StateNotifier { } Future resume(ParkedBill bill) async { + // Resuming a bill on top of an already-active cart would otherwise + // silently overwrite whatever was already there — picking a second + // parked bill while the first one's items are still sitting in the + // cart, unsaved. Same hole startNewSale closes for the header button; + // this closes it here by parking what's active first. + if (state.isNotEmpty) { + await park(label: 'Auto-parked — replaced by resuming another bill'); + } + await _transactions.removeParked(bill.id); _undoStack.clear(); // Re-evaluated rather than restored: a campaign that has since ended must @@ -309,6 +318,32 @@ class CartController extends StateNotifier { _commit(bill.cart); } + /// What the header's "New Sale" button actually calls. + /// + /// A cashier can always start over — that's the whole point of the escape + /// hatch — but silently wiping a non-empty cart here would be the exact + /// same hole the removal PIN closes: scan an item, then abandon the cart + /// instead of removing that one line, and it disappears just the same, + /// with nobody having approved anything. So a non-empty cart is parked, + /// not discarded — visible and resumable from Parked bills — and only an + /// already-empty cart takes the plain reset path. + Future startNewSale() async { + if (state.isNotEmpty) { + await park(label: 'Auto-parked — new sale started with items still in cart'); + return; + } + reset(); + } + + /// Puts a cart back exactly as it was just before a sale that has since + /// been voided, so cancelling a completed bill doesn't make the cashier + /// re-scan everything. Promos are re-evaluated for the same reason + /// [resume] re-evaluates them, not restored verbatim. + void restore(Cart cart) { + _undoStack.clear(); + _commit(cart); + } + List _replace(CartLine updated) => [ for (final l in state.lines) if (l.product.id == updated.product.id) updated else l, diff --git a/lib/presentation/pos/screens/pos_view.dart b/lib/presentation/pos/screens/pos_view.dart index 0cacdb2..23fdc37 100644 --- a/lib/presentation/pos/screens/pos_view.dart +++ b/lib/presentation/pos/screens/pos_view.dart @@ -41,8 +41,8 @@ class PosView extends ConsumerWidget { Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const CustomerBar(), - const Divider(height: 1), + + Padding( padding: EdgeInsets.fromLTRB(pad, AppSpacing.lg, pad, AppSpacing.md), diff --git a/lib/presentation/pos/widgets/admin_pin_dialog.dart b/lib/presentation/pos/widgets/admin_pin_dialog.dart new file mode 100644 index 0000000..a6220e2 --- /dev/null +++ b/lib/presentation/pos/widgets/admin_pin_dialog.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../app/providers.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/numeric_keypad.dart'; +import '../../../domain/entities/store_account.dart'; + +/// Prompts for an admin PIN before a theft-sensitive cart action — taking a +/// scanned item back out of the bill, or clearing it — and resolves `true` +/// only once a PIN belonging to an [StaffRole.admin] account is verified. +/// +/// A cashier can always start a brand new sale; what this exists to stop is +/// quietly taking something back out of a bill a customer has already been +/// shown, after it was rung up. +Future requireAdminPin( + BuildContext context, + WidgetRef ref, { + required String reason, +}) async { + final ok = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _AdminPinDialog(reason: reason), + ); + return ok ?? false; +} + +class _AdminPinDialog extends ConsumerStatefulWidget { + const _AdminPinDialog({required this.reason}); + + final String reason; + + @override + ConsumerState<_AdminPinDialog> createState() => _AdminPinDialogState(); +} + +class _AdminPinDialogState extends ConsumerState<_AdminPinDialog> { + String _pin = ''; + String? _error; + bool _checking = false; + + void _key(String digit) { + if (_checking || _pin.length >= 8) return; + setState(() { + _pin += digit; + _error = null; + }); + } + + void _backspace() { + if (_checking || _pin.isEmpty) return; + setState(() => _pin = _pin.substring(0, _pin.length - 1)); + } + + void _clear() { + if (_checking) return; + setState(() => _pin = ''); + } + + Future _submit() async { + if (_pin.isEmpty || _checking) return; + setState(() { + _checking = true; + _error = null; + }); + + final store = ref.read(localStoreProvider); + final user = await store.staff.authenticate(_pin); + + if (!mounted) return; + + if (user == null) { + setState(() { + _checking = false; + _error = 'Incorrect PIN.'; + _pin = ''; + }); + return; + } + + if (user.role != StaffRole.admin) { + setState(() { + _checking = false; + _error = "${user.name}'s PIN is ${user.role.label.toLowerCase()} — " + 'this needs an admin.'; + _pin = ''; + }); + return; + } + + Navigator.of(context).pop(true); + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: const RoundedRectangleBorder(borderRadius: AppRadius.brLg), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Icon(Icons.lock_outline_rounded, + color: AppColors.danger, size: 20,), + const SizedBox(width: AppSpacing.sm), + const Expanded( + child: Text( + 'Admin PIN required', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + InkWell( + onTap: () => Navigator.of(context).pop(false), + borderRadius: AppRadius.brSm, + child: const Padding( + padding: EdgeInsets.all(4), + child: Icon(Icons.close_rounded, + size: 20, color: AppColors.textSecondary,), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xs), + Text( + widget.reason, + style: const TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: AppSpacing.lg), + SizedBox( + height: 20, + child: _pin.isEmpty + ? const Center( + child: Text( + 'Enter PIN', + style: TextStyle( + fontSize: 13, + color: AppColors.textTertiary, + ), + ), + ) + : Wrap( + alignment: WrapAlignment.center, + spacing: 10, + children: [ + for (var i = 0; i < _pin.length; i++) + Container( + width: 12, + height: 12, + decoration: const BoxDecoration( + color: AppColors.primary, + shape: BoxShape.circle, + ), + ), + ], + ), + ), + if (_error != null) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + _error!, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12.5, + color: AppColors.danger, + fontWeight: FontWeight.w600, + ), + ), + ], + const SizedBox(height: AppSpacing.lg), + NumericKeypad( + onKey: _key, + onBackspace: _backspace, + onClear: _clear, + onSubmit: _checking ? null : _submit, + submitLabel: _checking ? 'Checking…' : 'Approve', + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/pos/widgets/billing_panel.dart b/lib/presentation/pos/widgets/billing_panel.dart index bd11bde..d4b43d6 100644 --- a/lib/presentation/pos/widgets/billing_panel.dart +++ b/lib/presentation/pos/widgets/billing_panel.dart @@ -7,6 +7,7 @@ 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/theme/app_layout.dart'; import '../../../core/theme/app_typography.dart'; import '../../../core/utils/extensions.dart'; import '../../../core/utils/formatters.dart'; @@ -15,6 +16,7 @@ import '../../../core/widgets/primary_button.dart'; import '../../../domain/entities/cart.dart'; import '../../customer/widgets/customer_capture_sheet.dart'; import '../providers/cart_controller.dart'; +import 'admin_pin_dialog.dart'; import 'cart_line_tile.dart'; import 'discount_sheet.dart'; @@ -58,7 +60,12 @@ class BillingPanel extends ConsumerWidget { line: line, onIncrement: () => controller.increment(line.product.id), onDecrement: () => controller.decrement(line.product.id), - onRemove: () => controller.removeLine(line.product.id), + onRemove: () => _removeLine( + context, + ref, + controller, + line.product.id, + ), onDiscount: () => showLineDiscountSheet(context, ref, line), ); @@ -72,6 +79,37 @@ class BillingPanel extends ConsumerWidget { } } +/// Once an item is on the bill, taking it back off needs an admin's +/// approval — a cashier can always start an entirely new sale instead. This +/// is the one gate both removal paths (a single line, or the whole cart) go +/// through, so they can never drift out of sync with each other. +Future _removeLine( + BuildContext context, + WidgetRef ref, + CartController controller, + String productId, +) async { + final ok = await requireAdminPin( + context, + ref, + reason: 'Removing a scanned item from the bill needs admin approval.', + ); + if (ok) controller.removeLine(productId); +} + +Future _clearCart( + BuildContext context, + WidgetRef ref, + CartController controller, +) async { + final ok = await requireAdminPin( + context, + ref, + reason: 'Clearing the whole bill needs admin approval.', + ); + if (ok) controller.clear(); +} + class _Header extends ConsumerWidget { const _Header({required this.cart, required this.inSheet}); @@ -81,15 +119,15 @@ class _Header extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final controller = ref.read(cartControllerProvider.notifier); + // Same fixed height as the page header on the left, so the two bars + // line up on one visual line instead of the cart title floating lower. + final contentPadding = PosLayout.of(context).contentPadding; - return Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.lg, - AppSpacing.md, - AppSpacing.sm, - AppSpacing.md, - ), + return Container( + constraints: const BoxConstraints(minHeight: AppSizes.headerHeight), + padding: EdgeInsets.symmetric(horizontal: contentPadding), child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Text( @@ -99,9 +137,9 @@ class _Header extends ConsumerWidget { ), ), if (cart.isNotEmpty) ...[ - const SizedBox(width: AppSpacing.sm), + Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), decoration: const BoxDecoration( color: AppColors.primarySurface, borderRadius: AppRadius.brPill, @@ -116,40 +154,52 @@ class _Header extends ConsumerWidget { ), ), ], - const Spacer(), + SizedBox(), + SizedBox(), + + 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(), - ), + // Grouped tight with even spacing, flush against the same right + // edge the header buttons on the left use. + Row( + mainAxisSize: MainAxisSize.min, + children: [ + 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: () => _clearCart(context, ref, controller), + ), + ], + if (inSheet) + _IconAction( + icon: Icons.close_rounded, + tooltip: 'Close', + color: AppColors.textSecondary, + onTap: () => Navigator.of(context).pop(), + ), + ], + ), ], ), ); @@ -360,7 +410,9 @@ class _Row extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2), - child: Row(children: [ + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ Flexible( child: Text( label, @@ -390,7 +442,7 @@ class _Row extends StatelessWidget { ), ), if (trailingIcon != null) ...[ - const SizedBox(width: AppSpacing.xs), + Icon(trailingIcon, size: 14, color: AppColors.textTertiary), ], ],), diff --git a/lib/presentation/pos/widgets/customer_bar.dart b/lib/presentation/pos/widgets/customer_bar.dart index 7b3b006..e69de29 100644 --- a/lib/presentation/pos/widgets/customer_bar.dart +++ b/lib/presentation/pos/widgets/customer_bar.dart @@ -1,110 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.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. -class CustomerBar extends ConsumerWidget { - const CustomerBar({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final customer = ref.watch( - cartControllerProvider.select((cart) => cart.customer), - ); - - return Container( - // A hard height clipped the subtitle once it wrapped. Minimum height - // keeps the strip its usual size but lets it grow if it must. - constraints: const BoxConstraints( - minHeight: AppSizes.customerBarHeight, - ), - color: AppColors.surface, - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.xl, - vertical: AppSpacing.sm, - ), - child: Row(children: [ - CircleAvatar( - radius: 20, - backgroundColor: customer == null - ? AppColors.border - : AppColors.primarySurface, - child: customer == null - ? const Icon(Icons.directions_walk_rounded, - size: 20, color: AppColors.textSecondary,) - : Text( - Formatters.initials(customer.name), - style: const TextStyle( - color: AppColors.primary, - fontSize: 14, - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(width: AppSpacing.md), - Flexible( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(mainAxisSize: MainAxisSize.min, children: [ - Flexible( - child: Text( - customer?.name ?? 'Walk-in Customer', - style: context.text.titleMedium, - overflow: TextOverflow.ellipsis, - ), - ), - if (customer != null) ...[ - const SizedBox(width: AppSpacing.sm), - StatusPill.tier(customer.tier, dense: true), - ], - ],), - if (customer != null) - Text( - '${Formatters.mobile(customer.mobile)} · ' - '${customer.loyaltyPoints} pts', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.text.bodySmall, - ) - else - Text( - 'No loyalty tracking for this sale', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.text.bodySmall, - ), - ], - ), - ), - const Spacer(), - if (customer != null) - TextButton.icon( - onPressed: () => - ref.read(cartControllerProvider.notifier).attachCustomer(null), - icon: const Icon(Icons.person_off_outlined, size: 17), - label: const Text('Detach'), - style: TextButton.styleFrom( - foregroundColor: AppColors.textSecondary,), - ), - const SizedBox(width: AppSpacing.sm), - OutlinedButton.icon( - onPressed: () => showCustomerCaptureSheet(context), - icon: const Icon(Icons.sync_alt_rounded, size: 17), - label: Text(customer == null ? 'Add Customer' : 'Change'), - style: OutlinedButton.styleFrom( - minimumSize: const Size(0, 44), - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), - ), - ), - ],), - ); - } -} diff --git a/lib/presentation/pos/widgets/page_header.dart b/lib/presentation/pos/widgets/page_header.dart index 01719c1..7eb8a5a 100644 --- a/lib/presentation/pos/widgets/page_header.dart +++ b/lib/presentation/pos/widgets/page_header.dart @@ -68,28 +68,7 @@ class PageHeader extends ConsumerWidget { const SizedBox(width: AppSpacing.xs), ], - Flexible( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - module.title, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 19, - fontWeight: FontWeight.w600, - letterSpacing: -0.4, - color: AppColors.textPrimary, - height: 1.2, - ), - ), - if (!compact) _Breadcrumb(module: module), - ], - ), - ), - const Spacer(), if (showStatus) ...[ _LivePill(offline: ref.watch(simulateOfflineProvider)), @@ -117,42 +96,6 @@ class PageHeader extends ConsumerWidget { } } -class _Breadcrumb extends StatelessWidget { - const _Breadcrumb({required this.module}); - - final PosModule module; - - @override - Widget build(BuildContext context) { - const style = TextStyle(fontSize: 12, color: AppColors.textTertiary); - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Home', style: style), - const Padding( - padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs + 2), - child: Icon(Icons.chevron_right_rounded, - size: 13, color: AppColors.textTertiary,), - ), - Text(module.section.label, style: style), - const Padding( - padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs + 2), - child: Icon(Icons.chevron_right_rounded, - size: 13, color: AppColors.textTertiary,), - ), - Text( - module.label, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: AppColors.primary, - ), - ), - ], - ); - } -} /// What the pill is saying, in the order it takes precedence. enum _Liveness { offlineSim, halted, syncing, queued, live } @@ -342,11 +285,24 @@ class _ParkedBillsButton extends ConsumerWidget { '${Formatters.time(bill.parkedAt)}', ), onTap: () async { + final hadItems = + ref.read(cartControllerProvider).isNotEmpty; await ref .read(cartControllerProvider.notifier) .resume(bill); ref.invalidate(parkedBillsProvider); - if (context.mounted) Navigator.of(context).pop(); + if (!context.mounted) return; + Navigator.of(context).pop(); + if (hadItems) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(const SnackBar( + content: Text( + 'The cart you were on was saved back to Parked ' + 'bills.', + ), + ),); + } }, ); }, @@ -370,12 +326,22 @@ class _NewSaleButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - void start() { - ref.read(cartControllerProvider.notifier).reset(); + Future start() async { + final hadItems = ref.read(cartControllerProvider).isNotEmpty; + await ref.read(cartControllerProvider.notifier).startNewSale(); + if (hadItems) ref.invalidate(parkedBillsProvider); ref.read(activeModuleProvider.notifier).state = PosModule.pos; + + if (!context.mounted) return; ScaffoldMessenger.of(context) ..hideCurrentSnackBar() - ..showSnackBar(const SnackBar(content: Text('Started a new sale.'))); + ..showSnackBar(SnackBar( + content: Text( + hadItems + ? 'Previous cart saved to Parked bills. Started a new sale.' + : '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 16e6ec5..6cb3004 100644 --- a/lib/presentation/pos/widgets/product_card.dart +++ b/lib/presentation/pos/widgets/product_card.dart @@ -84,9 +84,10 @@ class _ProductCardState extends State { children: [ Opacity( opacity: disabled ? 0.4 : 1, - child: Text( - p.emoji, - style: TextStyle(fontSize: emoji), + child: _ProductVisual( + imageUrl: p.imageUrl, + emoji: p.emoji, + size: emoji, ), ), SizedBox(height: tight ? 2 : AppSpacing.sm), @@ -252,3 +253,62 @@ class _ProductCardState extends State { ); } } + +/// Shows the catalogue's product photo when the imported record has one, +/// otherwise falls back to the emoji. +/// +/// Today's imports don't carry `image_url` yet, so the emoji path is still +/// the common case — this quietly takes over per product once the back +/// office starts sending photos, with nothing else on the card changing. +class _ProductVisual extends StatelessWidget { + const _ProductVisual({ + required this.imageUrl, + required this.emoji, + required this.size, + }); + + final String? imageUrl; + final String emoji; + + /// Matches the emoji font size the caller computed for this tile, so the + /// two are visually interchangeable. + final double size; + + @override + Widget build(BuildContext context) { + final url = imageUrl; + if (url == null || url.isEmpty) { + return Text(emoji, style: TextStyle(fontSize: size)); + } + + final box = size * 1.7; + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + url, + width: box, + height: box, + fit: BoxFit.cover, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return SizedBox( + width: box, + height: box, + child: const Center( + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + }, + // A missing or unreachable photo falls back to the emoji rather than + // Flutter's default broken-image icon, so one bad URL in a catalogue + // of thousands never leaves a tile looking broken. + errorBuilder: (context, error, stackTrace) => + Text(emoji, style: TextStyle(fontSize: size)), + ), + ); + } +} diff --git a/lib/presentation/receipt/screens/receipt_screen.dart b/lib/presentation/receipt/screens/receipt_screen.dart index 00ba828..830ad28 100644 --- a/lib/presentation/receipt/screens/receipt_screen.dart +++ b/lib/presentation/receipt/screens/receipt_screen.dart @@ -15,13 +15,22 @@ import '../../../core/utils/extensions.dart'; import '../../../core/utils/formatters.dart'; import '../../../core/widgets/glass_card.dart'; import '../../../core/widgets/primary_button.dart'; +import '../../../data/sync/sync_engine.dart'; import '../../../domain/entities/transaction.dart'; import '../../modules/providers/printer_settings.dart'; import '../../pos/providers/cart_controller.dart'; +import '../../pos/providers/catalog_providers.dart'; +import '../../sync/providers/sync_controller.dart'; import '../widgets/receipt_preview.dart'; -/// Confirmation screen. Counts down and starts the next sale on its own so an -/// unattended terminal never sits on a finished bill. +/// Confirmation screen. +/// +/// The bill is written to SQLite the instant checkout completes, but is +/// deliberately held back from the server for [AppConstants.postSaleResetDelay] +/// — long enough to catch a mistake. Counts down and starts the next sale (and +/// releases the bill to the sync engine) on its own so an unattended terminal +/// never sits on a finished bill forever; "Cancel sale" inside the window +/// voids it instead and hands the cart straight back. class ReceiptScreen extends ConsumerStatefulWidget { const ReceiptScreen({super.key, required this.transaction}); @@ -32,9 +41,13 @@ class ReceiptScreen extends ConsumerStatefulWidget { } class _ReceiptScreenState extends ConsumerState { - late int _seconds = AppConstants.postSaleResetDelay.inSeconds + 5; + late int _seconds = AppConstants.postSaleResetDelay.inSeconds; Timer? _timer; + /// True while a cancellation is being written to disk — guards against a + /// second tap voiding a sale that is already half-reversed. + bool _voiding = false; + @override void initState() { super.initState(); @@ -82,13 +95,36 @@ class _ReceiptScreenState extends ConsumerState { void _newSale() { _timer?.cancel(); + // This is the one moment the bill is actually released to the server — + // whether the window ran out on its own or New Sale was pressed early, + // the cashier has let this bill stand. + ref.read(syncEngineProvider).nudge(SyncTrigger.saleCommitted); ref.read(cartControllerProvider.notifier).reset(); if (mounted) context.go(AppRoutes.pos); } - void _continueBilling() { + /// Undoes the sale: deletes the order, puts the stock back, restores an + /// attached shopper's loyalty balance, and hands the exact same cart back + /// to the billing screen. Never touches the sync engine — this bill must + /// never reach the server. + Future _cancelSale() async { + if (_voiding) return; + setState(() => _voiding = true); _timer?.cancel(); - ref.read(cartControllerProvider.notifier).reset(); + + final txn = widget.transaction; + await ref.read(transactionRepositoryProvider).voidSale( + transaction: txn, + stockMovements: { + for (final line in txn.cart.lines) line.product.id: line.quantity, + }, + ); + + ref.invalidate(allProductsProvider); + ref.invalidate(visibleProductsProvider); + ref.read(orderVersionProvider.notifier).state++; + ref.read(cartControllerProvider.notifier).restore(txn.cart); + if (mounted) context.go(AppRoutes.pos); } @@ -293,12 +329,23 @@ class _ReceiptScreenState extends ConsumerState { : 'New Sale', icon: Icons.add_shopping_cart_rounded, large: true, - onPressed: _newSale, + onPressed: _voiding ? null : _newSale, ), const SizedBox(height: AppSpacing.sm), - TextButton( - onPressed: _continueBilling, - child: const Text('Back to billing screen'), + TextButton.icon( + onPressed: _voiding ? null : _cancelSale, + icon: _voiding + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.danger, + ), + ) + : const Icon(Icons.undo_rounded, size: 16), + label: Text(_voiding ? 'Cancelling…' : 'Cancel sale'), + style: TextButton.styleFrom(foregroundColor: AppColors.danger), ), ], ), diff --git a/lib/presentation/sync/widgets/sign_out_dialog.dart b/lib/presentation/sync/widgets/sign_out_dialog.dart index 44e93ab..841f0c6 100644 --- a/lib/presentation/sync/widgets/sign_out_dialog.dart +++ b/lib/presentation/sync/widgets/sign_out_dialog.dart @@ -9,6 +9,7 @@ import '../../../core/utils/formatters.dart'; import '../../../core/widgets/primary_button.dart'; import '../../auth/providers/auth_controller.dart'; import '../../pos/providers/cart_controller.dart'; +import '../../pos/providers/catalog_providers.dart'; import '../../../domain/repositories/sync_repository.dart'; import '../providers/sync_controller.dart'; @@ -35,9 +36,21 @@ class _SignOutDialog extends ConsumerStatefulWidget { class _SignOutDialogState extends ConsumerState<_SignOutDialog> { SyncOutcome? _result; - void _finish() { + Future _finish() async { ref.read(cartControllerProvider.notifier).reset(); - ref.read(authControllerProvider.notifier).signOut(); + await ref.read(authControllerProvider.notifier).signOut(); + + // Mirrors what a successful import does on the way in: bump the version + // so catalogueReadyProvider re-reads hasCatalogue as false, and drop the + // cached product lists so the next session's grid doesn't flash this + // session's now-cleared data before it re-fetches. + ref.read(catalogueVersionProvider.notifier).state++; + ref.invalidate(allProductsProvider); + ref.invalidate(visibleProductsProvider); + ref.invalidate(categoryCountsProvider); + ref.invalidate(lowStockProductsProvider); + + if (!mounted) return; Navigator.of(context).pop(); context.go(AppRoutes.login); } diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index dddb8a3..41c9c18 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -2,11 +2,15 @@ - com.apple.security.app-sandbox - - com.apple.security.cs.allow-jit - - com.apple.security.network.server - + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.print + - + \ No newline at end of file diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 852fa1a..cd824f9 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -4,5 +4,7 @@ com.apple.security.app-sandbox + com.apple.security.print + - + \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 19c0b9c..911d333 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -282,10 +282,10 @@ packages: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633" + sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.0.3" flutter_secure_storage_web: dependency: transitive description: @@ -729,10 +729,10 @@ packages: dependency: transitive description: name: sqlite3 - sha256: c73fd75df1332d76a6257f4823ae4df9c791f522b97e4a60cbcad214de1becf4 + sha256: "64b2c63c8232dd20d14b34105a81ebfd74320442e8451f836179ec89986aa478" url: "https://pub.dev" source: hosted - version: "3.5.0" + version: "3.5.1" stack_trace: dependency: transitive description: @@ -897,10 +897,10 @@ packages: dependency: transitive description: name: win32 - sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.4.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b8a5f78..6aab87f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,3 +54,4 @@ flutter: uses-material-design: true assets: - assets/sounds/ + - assets/images/ 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); + }); +}