diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100644 index 0000000..aec2c2c Binary files /dev/null and b/assets/images/logo.png differ diff --git a/lib/app/providers.dart b/lib/app/providers.dart index b176c77..c04e1c5 100644 --- a/lib/app/providers.dart +++ b/lib/app/providers.dart @@ -79,15 +79,19 @@ final catalogueSourceProvider = Provider((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); + }); +}