added product import and billing integration
This commit is contained in:
BIN
assets/images/logo.png
Normal file
BIN
assets/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -79,15 +79,19 @@ final catalogueSourceProvider = Provider<CatalogueSource>((ref) {
|
|||||||
// ------------------------------------------------------------------- Sync
|
// ------------------------------------------------------------------- Sync
|
||||||
/// How this terminal reaches the back office.
|
/// How this terminal reaches the back office.
|
||||||
///
|
///
|
||||||
/// Defaults to the simulated route so a fresh install is usable with no broker
|
/// Defaults to this store's live HTTP endpoint, so importing works out of the
|
||||||
/// and no endpoint; Settings re-points it.
|
/// 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
|
/// Terminal id always comes from this device's own identity, never from a
|
||||||
/// from a literal — two terminals publishing on the same topic is the failure
|
/// literal — two terminals publishing on the same topic is the failure this
|
||||||
/// this exists to prevent.
|
/// exists to prevent.
|
||||||
final syncConfigProvider = StateProvider<SyncConfig>((ref) {
|
final syncConfigProvider = StateProvider<SyncConfig>((ref) {
|
||||||
final terminal = ref.watch(terminalIdentityProvider);
|
final terminal = ref.watch(terminalIdentityProvider);
|
||||||
return SyncConfig(
|
return SyncConfig(
|
||||||
|
transport: TransportKind.http,
|
||||||
|
httpBaseUrl: 'https://fiesta.nearle.app/live/api/v1/pos',
|
||||||
storeId: terminal.storeId,
|
storeId: terminal.storeId,
|
||||||
terminalId: terminal.code,
|
terminalId: terminal.code,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,8 +39,13 @@ class AppConstants {
|
|||||||
static const Duration barcodeScanTimeout = Duration(milliseconds: 120);
|
static const Duration barcodeScanTimeout = Duration(milliseconds: 120);
|
||||||
static const int minBarcodeLength = 6;
|
static const int minBarcodeLength = 6;
|
||||||
|
|
||||||
/// Idle time after a completed sale before the terminal resets itself.
|
/// Window after a completed sale during which the terminal waits, and the
|
||||||
static const Duration postSaleResetDelay = Duration(seconds: 3);
|
/// 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 lowStockThreshold = 10;
|
||||||
static const int maxParkedBills = 20;
|
static const int maxParkedBills = 20;
|
||||||
|
|||||||
@@ -146,6 +146,18 @@ class LocalStore {
|
|||||||
String? get catalogueRevision => _catalogueRevision;
|
String? get catalogueRevision => _catalogueRevision;
|
||||||
int get unsyncedOrders => _unsyncedOrders;
|
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<void> clearCatalogue() async {
|
||||||
|
await catalogue.clearCatalogue();
|
||||||
|
_products.clear();
|
||||||
|
_lastImportAt = null;
|
||||||
|
_catalogueRevision = null;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> importCatalogue({
|
Future<void> importCatalogue({
|
||||||
required List<Product> products,
|
required List<Product> products,
|
||||||
required List<Customer> customers,
|
required List<Customer> customers,
|
||||||
|
|||||||
@@ -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<void> 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.
|
/// Applies stock movement after a sale, clamped at zero.
|
||||||
Future<void> decrementStock(Map<String, double> quantities) async {
|
Future<void> decrementStock(Map<String, double> quantities) async {
|
||||||
if (quantities.isEmpty) return;
|
if (quantities.isEmpty) return;
|
||||||
|
|||||||
@@ -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<void> voidSale({
|
||||||
|
required String orderId,
|
||||||
|
required Map<String, double> stockMovements,
|
||||||
|
Map<String, Object?>? 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<void> _insertOrder(DatabaseExecutor txn, SaleTransaction t) async {
|
Future<void> _insertOrder(DatabaseExecutor txn, SaleTransaction t) async {
|
||||||
final cart = t.cart;
|
final cart = t.cart;
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,13 @@ class TerminalIdentityStore {
|
|||||||
///
|
///
|
||||||
/// The mint is idempotent: an existing device id is never replaced, so a
|
/// The mint is idempotent: an existing device id is never replaced, so a
|
||||||
/// terminal cannot silently change identity and orphan its own history.
|
/// terminal cannot silently change identity and orphan its own history.
|
||||||
Future<TerminalIdentity> 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<TerminalIdentity> load({String defaultStoreId = '1135'}) async {
|
||||||
var deviceId = await _catalogue.meta(MetaKeys.deviceId);
|
var deviceId = await _catalogue.meta(MetaKeys.deviceId);
|
||||||
var code = await _catalogue.meta(MetaKeys.terminalCode);
|
var code = await _catalogue.meta(MetaKeys.terminalCode);
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ import 'catalogue_wire.dart';
|
|||||||
/// Pulls the catalogue from the back office over HTTP.
|
/// 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}
|
/// 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
|
/// ```json
|
||||||
/// {
|
/// {
|
||||||
/// "revision": "rev-8821",
|
/// "revision": "rev-8821",
|
||||||
@@ -53,6 +56,10 @@ class HttpCatalogueSource implements CatalogueSource {
|
|||||||
/// connection.
|
/// connection.
|
||||||
static const int maxPages = 200;
|
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);
|
static const Duration _timeout = Duration(seconds: 30);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -77,7 +84,7 @@ class HttpCatalogueSource implements CatalogueSource {
|
|||||||
|
|
||||||
var revision = since ?? '';
|
var revision = since ?? '';
|
||||||
var isDelta = false;
|
var isDelta = false;
|
||||||
var page = 1;
|
var page = 0;
|
||||||
|
|
||||||
onProgress?.call(0.05, 'Contacting the back office…');
|
onProgress?.call(0.05, 'Contacting the back office…');
|
||||||
|
|
||||||
@@ -137,6 +144,7 @@ class HttpCatalogueSource implements CatalogueSource {
|
|||||||
queryParameters: {
|
queryParameters: {
|
||||||
if (since != null && since.isNotEmpty) 'since': since,
|
if (since != null && since.isNotEmpty) 'since': since,
|
||||||
'page': '$page',
|
'page': '$page',
|
||||||
|
'page_size': '$pageSize',
|
||||||
'store_id': config.storeId,
|
'store_id': config.storeId,
|
||||||
'terminal_id': config.terminalId,
|
'terminal_id': config.terminalId,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../../core/config/sync_config.dart';
|
import '../../core/config/sync_config.dart';
|
||||||
import 'order_transport.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
|
/// having as the route to bring up first, and as the fallback when a broker is
|
||||||
/// unreachable but the internet is not.
|
/// 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:
|
/// The endpoint must answer with the ids it committed:
|
||||||
///
|
///
|
||||||
/// ```json
|
/// ```json
|
||||||
@@ -29,6 +36,8 @@ class HttpOrderTransport implements OrderTransport {
|
|||||||
final SyncConfig config;
|
final SyncConfig config;
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
|
|
||||||
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
final _connection = StreamController<bool>.broadcast();
|
final _connection = StreamController<bool>.broadcast();
|
||||||
|
|
||||||
bool _reachable = true;
|
bool _reachable = true;
|
||||||
@@ -73,6 +82,16 @@ class HttpOrderTransport implements OrderTransport {
|
|||||||
|
|
||||||
final uri = Uri.parse('${config.httpBaseUrl}/$path');
|
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;
|
http.Response response;
|
||||||
try {
|
try {
|
||||||
response = await _client
|
response = await _client
|
||||||
@@ -82,15 +101,13 @@ class HttpOrderTransport implements OrderTransport {
|
|||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
if (config.apiKey != null)
|
if (config.apiKey != null)
|
||||||
'authorization': 'Bearer ${config.apiKey}',
|
'authorization': 'Bearer ${config.apiKey}',
|
||||||
// Lets the endpoint collapse a retried batch server-side rather
|
'idempotency-key': batchId,
|
||||||
// than relying on every order id being checked individually.
|
|
||||||
'idempotency-key': _batchKey(items),
|
|
||||||
},
|
},
|
||||||
body: jsonEncode({
|
body: jsonEncode({
|
||||||
'schema': 1,
|
'schema': 1,
|
||||||
|
'batch_id': batchId,
|
||||||
'store_id': config.storeId,
|
'store_id': config.storeId,
|
||||||
'terminal_id': config.terminalId,
|
'terminal_id': config.terminalId,
|
||||||
'sent_at': DateTime.now().toIso8601String(),
|
|
||||||
key: items,
|
key: items,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -143,11 +160,6 @@ class HttpOrderTransport implements OrderTransport {
|
|||||||
return PushReceipt(accepted: accepted, rejected: rejected);
|
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<Map<String, Object?>> items) =>
|
|
||||||
items.map((o) => o['id']).join('|').hashCode.toRadixString(16);
|
|
||||||
|
|
||||||
void _setReachable(bool value) {
|
void _setReachable(bool value) {
|
||||||
if (_reachable == value) return;
|
if (_reachable == value) return;
|
||||||
_reachable = value;
|
_reachable = value;
|
||||||
|
|||||||
@@ -437,12 +437,12 @@ class SyncRepositoryImpl implements SyncRepository {
|
|||||||
'registered_by_terminal': _store.terminal.code,
|
'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<String, Object?> _orderToPayload(SaleTransaction t) => {
|
Map<String, Object?> _orderToPayload(SaleTransaction t) => {
|
||||||
'id': t.id,
|
'id': t.id,
|
||||||
'invoice_number': t.invoiceNumber,
|
'invoice_number': t.invoiceNumber,
|
||||||
'created_at': t.createdAt.toIso8601String(),
|
'created_at': t.createdAt.toIso8601String(),
|
||||||
'terminal_id': t.terminalId,
|
|
||||||
'cashier': t.cashierName,
|
'cashier': t.cashierName,
|
||||||
'customer': t.customer == null
|
'customer': t.customer == null
|
||||||
? null
|
? null
|
||||||
@@ -453,15 +453,6 @@ class SyncRepositoryImpl implements SyncRepository {
|
|||||||
},
|
},
|
||||||
'subtotal': t.cart.subtotal,
|
'subtotal': t.cart.subtotal,
|
||||||
'discount': t.cart.billDiscountTotal + t.cart.lineDiscountTotal,
|
'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,
|
'tax': t.cart.taxAmount,
|
||||||
// GST per slab, as printed on the invoice. Sent as well as the total
|
// 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
|
// because a compliant tax return is filed per slab, and recomputing the
|
||||||
|
|||||||
@@ -30,6 +30,35 @@ class TransactionRepositoryImpl implements TransactionRepository {
|
|||||||
await _store.refreshUnsyncedCount();
|
await _store.refreshUnsyncedCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> voidSale({
|
||||||
|
required SaleTransaction transaction,
|
||||||
|
required Map<String, double> 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
|
@override
|
||||||
Future<List<SaleTransaction>> history({int limit = 50}) =>
|
Future<List<SaleTransaction>> history({int limit = 50}) =>
|
||||||
_store.orders.recent(limit: limit);
|
_store.orders.recent(limit: limit);
|
||||||
|
|||||||
@@ -13,6 +13,18 @@ abstract class TransactionRepository {
|
|||||||
Customer? updatedCustomer,
|
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<void> voidSale({
|
||||||
|
required SaleTransaction transaction,
|
||||||
|
required Map<String, double> stockMovements,
|
||||||
|
});
|
||||||
|
|
||||||
Future<List<SaleTransaction>> history({int limit = 50});
|
Future<List<SaleTransaction>> history({int limit = 50});
|
||||||
|
|
||||||
Future<SaleTransaction?> findByInvoice(String invoiceNumber);
|
Future<SaleTransaction?> findByInvoice(String invoiceNumber);
|
||||||
|
|||||||
@@ -126,7 +126,14 @@ class AuthController extends StateNotifier<AuthState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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<void> signOut() async {
|
||||||
|
await _ref.read(localStoreProvider).clearCatalogue();
|
||||||
|
state = const Unauthenticated();
|
||||||
|
}
|
||||||
|
|
||||||
void clearError() {
|
void clearError() {
|
||||||
if (state is AuthFailure) state = const Unauthenticated();
|
if (state is AuthFailure) state = const Unauthenticated();
|
||||||
|
|||||||
@@ -106,14 +106,7 @@ class _BrandPanel extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(11),
|
borderRadius: BorderRadius.circular(11),
|
||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: const Text(
|
child: Image.asset('assets/images/logo.png', fit: BoxFit.contain),
|
||||||
'N',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppColors.primary,
|
|
||||||
fontSize: 23,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: AppSpacing.md),
|
const SizedBox(width: AppSpacing.md),
|
||||||
const Flexible(
|
const Flexible(
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import '../../../app/providers.dart';
|
|||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/utils/formatters.dart';
|
import '../../../core/utils/formatters.dart';
|
||||||
import '../../../core/widgets/status_pill.dart';
|
|
||||||
import '../../../domain/entities/customer.dart';
|
import '../../../domain/entities/customer.dart';
|
||||||
import '../../customer/widgets/customer_capture_sheet.dart';
|
import '../../customer/widgets/customer_capture_sheet.dart';
|
||||||
import '../widgets/module_widgets.dart';
|
import '../widgets/module_widgets.dart';
|
||||||
@@ -24,14 +23,6 @@ class CustomersView extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _CustomersViewState extends ConsumerState<CustomersView> {
|
class _CustomersViewState extends ConsumerState<CustomersView> {
|
||||||
String _query = '';
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -39,10 +30,9 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
|
|||||||
|
|
||||||
final filtered = all.where((c) {
|
final filtered = all.where((c) {
|
||||||
final q = _query.trim().toLowerCase();
|
final q = _query.trim().toLowerCase();
|
||||||
final matchesQuery = q.isEmpty ||
|
return q.isEmpty ||
|
||||||
c.name.toLowerCase().contains(q) ||
|
c.name.toLowerCase().contains(q) ||
|
||||||
c.mobile.contains(q);
|
c.mobile.contains(q);
|
||||||
return matchesQuery && (_tier == null || c.tier == _tier);
|
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
final lifetime = all.fold<double>(0, (s, c) => s + c.lifetimeSpend);
|
final lifetime = all.fold<double>(0, (s, c) => s + c.lifetimeSpend);
|
||||||
@@ -85,26 +75,6 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.lg),
|
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(
|
PanelCard(
|
||||||
title: 'Customer book',
|
title: 'Customer book',
|
||||||
subtitle: '${filtered.length} shown',
|
subtitle: '${filtered.length} shown',
|
||||||
@@ -134,45 +104,11 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
|
|||||||
isDense: true,
|
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),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
ResponsiveTable(
|
ResponsiveTable(
|
||||||
columns: const [
|
columns: const [
|
||||||
TableCol('Customer', flex: 4),
|
TableCol('Customer', flex: 4),
|
||||||
TableCol('Mobile', flex: 3, priority: 1),
|
TableCol('Mobile', flex: 3, priority: 1),
|
||||||
TableCol('Tier', flex: 2),
|
|
||||||
TableCol('Points', flex: 2, numeric: true, priority: 1),
|
TableCol('Points', flex: 2, numeric: true, priority: 1),
|
||||||
TableCol('Lifetime', flex: 2, numeric: true),
|
TableCol('Lifetime', flex: 2, numeric: true),
|
||||||
TableCol('Visits', flex: 2, numeric: true, priority: 1),
|
TableCol('Visits', flex: 2, numeric: true, priority: 1),
|
||||||
@@ -199,7 +135,6 @@ class _CustomersViewState extends ConsumerState<CustomersView> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
Cell(Formatters.mobile(c.mobile), mono: true),
|
Cell(Formatters.mobile(c.mobile), mono: true),
|
||||||
StatusPill.tier(c.tier, dense: true),
|
|
||||||
Cell('${c.loyaltyPoints}', mono: true),
|
Cell('${c.loyaltyPoints}', mono: true),
|
||||||
Cell(Formatters.moneyCompact(c.lifetimeSpend),
|
Cell(Formatters.moneyCompact(c.lifetimeSpend),
|
||||||
mono: true, bold: true,),
|
mono: true, bold: true,),
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../app/providers.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/utils/formatters.dart';
|
import '../../../core/utils/formatters.dart';
|
||||||
import '../../../core/widgets/primary_button.dart';
|
import '../../../core/widgets/primary_button.dart';
|
||||||
|
import '../../../data/sync/sync_engine.dart';
|
||||||
import '../../../domain/entities/transaction.dart';
|
import '../../../domain/entities/transaction.dart';
|
||||||
import '../../../domain/repositories/sync_repository.dart';
|
import '../../../domain/repositories/sync_repository.dart';
|
||||||
import '../../sync/providers/sync_controller.dart';
|
import '../../sync/providers/sync_controller.dart';
|
||||||
@@ -26,10 +28,21 @@ class EventsView extends ConsumerWidget {
|
|||||||
final syncState = ref.watch(orderSyncProvider);
|
final syncState = ref.watch(orderSyncProvider);
|
||||||
final events = ref.watch(syncEventsProvider);
|
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;
|
final r = report.value;
|
||||||
|
|
||||||
return ModulePage(
|
return ModulePage(
|
||||||
children: [
|
children: [
|
||||||
|
if (engine.lastError != null && pending > 0)
|
||||||
|
_EngineWarningBanner(engine: engine),
|
||||||
|
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: AppSpacing.lg,
|
spacing: AppSpacing.lg,
|
||||||
runSpacing: 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../../../app/providers.dart';
|
import '../../../app/providers.dart';
|
||||||
import '../../../core/utils/extensions.dart';
|
import '../../../core/utils/extensions.dart';
|
||||||
import '../../../data/sync/sync_engine.dart';
|
|
||||||
import '../../../domain/entities/transaction.dart';
|
import '../../../domain/entities/transaction.dart';
|
||||||
import '../../../domain/usecases/checkout_sale.dart';
|
import '../../../domain/usecases/checkout_sale.dart';
|
||||||
import '../../auth/providers/auth_controller.dart';
|
import '../../auth/providers/auth_controller.dart';
|
||||||
@@ -80,29 +79,27 @@ class PaymentController extends StateNotifier<PaymentState> {
|
|||||||
|
|
||||||
/// Whether Complete Sale should be enabled.
|
/// Whether Complete Sale should be enabled.
|
||||||
///
|
///
|
||||||
/// Cash is the strict case: the drawer cannot be reconciled and no change can
|
/// Every method now requires the cashier to state what was actually
|
||||||
/// be calculated unless the cashier states what was handed over. Card, UPI
|
/// received before the sale can complete — a tap on Exact for the common
|
||||||
/// and wallet settle on the external terminal, so they need no amount here.
|
/// 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 {
|
bool get canConfirm {
|
||||||
if (_billTotal <= 0) return false;
|
if (_billTotal <= 0) return false;
|
||||||
|
|
||||||
// Staged splits already cover the bill.
|
// Staged splits already cover the bill.
|
||||||
if (balanceDue <= 0.01) return true;
|
if (balanceDue <= 0.01) return true;
|
||||||
|
|
||||||
if (state.activeMethod.needsChange) {
|
return state.cashTendered >= balanceDue;
|
||||||
return state.cashTendered >= balanceDue;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why the button is disabled, for display next to it.
|
/// Why the button is disabled, for display next to it.
|
||||||
String? get blockedReason {
|
String? get blockedReason {
|
||||||
if (_billTotal <= 0) return 'Add at least one item before charging.';
|
if (_billTotal <= 0) return 'Add at least one item before charging.';
|
||||||
if (canConfirm) return null;
|
if (canConfirm) return null;
|
||||||
if (state.activeMethod.needsChange) {
|
return state.activeMethod.needsChange
|
||||||
return 'Enter the cash received, or tap Exact.';
|
? 'Enter the cash received, or tap Exact.'
|
||||||
}
|
: 'Enter the amount received, or tap Exact.';
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void selectMethod(PaymentMethod method) {
|
void selectMethod(PaymentMethod method) {
|
||||||
@@ -212,10 +209,11 @@ class PaymentController extends StateNotifier<PaymentState> {
|
|||||||
_ref.invalidate(visibleProductsProvider);
|
_ref.invalidate(visibleProductsProvider);
|
||||||
_ref.read(orderVersionProvider.notifier).state++;
|
_ref.read(orderVersionProvider.notifier).state++;
|
||||||
|
|
||||||
// The bill is safely on disk; getting it to the back office is the
|
// Deliberately NOT nudging the sync engine here. The bill sits on this
|
||||||
// engine's problem now. Deliberately not awaited — the cashier must
|
// terminal, unsynced, until the receipt screen's cancellation window
|
||||||
// reach the receipt screen at network speed of zero.
|
// closes — either it is pressed past early with New Sale, or the
|
||||||
_ref.read(syncEngineProvider).nudge(SyncTrigger.saleCommitted);
|
// window runs out — or the sale is voided if the cashier cancels
|
||||||
|
// instead. See ReceiptScreen.
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} on CheckoutFailure catch (e) {
|
} on CheckoutFailure catch (e) {
|
||||||
|
|||||||
@@ -350,20 +350,41 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
|||||||
return GlassCard(
|
return GlassCard(
|
||||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||||
radius: AppRadius.xl,
|
radius: AppRadius.xl,
|
||||||
child: state.activeMethod.needsChange
|
child: _amountTender(controller, state),
|
||||||
? _cashTender(controller)
|
|
||||||
: _referenceTender(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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Row(
|
||||||
'Cash received',
|
children: [
|
||||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
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),
|
const SizedBox(height: AppSpacing.md),
|
||||||
Container(
|
Container(
|
||||||
@@ -405,16 +426,20 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
|||||||
label: const Text('Exact'),
|
label: const Text('Exact'),
|
||||||
onPressed: () => _setCash(controller.balanceDue),
|
onPressed: () => _setCash(controller.balanceDue),
|
||||||
),
|
),
|
||||||
for (final note in const [50, 100, 200, 500, 2000])
|
// Denomination shortcuts only make sense for physical notes.
|
||||||
ActionChip(
|
if (cash)
|
||||||
label: Text('₹$note'),
|
for (final note in const [50, 100, 200, 500, 2000])
|
||||||
onPressed: () =>
|
ActionChip(
|
||||||
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
|
label: Text('₹$note'),
|
||||||
),
|
onPressed: () =>
|
||||||
|
_setCash((double.tryParse(_cashBuffer) ?? 0) + note),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
if (cash) ...[
|
||||||
_changeRow(controller.changeDue),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
_changeRow(controller.changeDue),
|
||||||
|
],
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
Center(
|
Center(
|
||||||
child: NumericKeypad(
|
child: NumericKeypad(
|
||||||
@@ -424,6 +449,21 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
|||||||
onBackspace: _backspaceCash,
|
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),
|
const SizedBox(height: AppSpacing.md),
|
||||||
_splitButton(controller, amount: double.tryParse(_cashBuffer) ?? 0),
|
_splitButton(controller, amount: double.tryParse(_cashBuffer) ?? 0),
|
||||||
],
|
],
|
||||||
@@ -473,71 +513,6 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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}) {
|
Widget _splitButton(PaymentController controller, {double? amount}) {
|
||||||
return OutlinedButton.icon(
|
return OutlinedButton.icon(
|
||||||
onPressed: controller.balanceDue > 0
|
onPressed: controller.balanceDue > 0
|
||||||
@@ -622,18 +597,17 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (state.activeMethod.needsChange)
|
TextButton(
|
||||||
TextButton(
|
onPressed: () => _setCash(controller.balanceDue),
|
||||||
onPressed: () => _setCash(controller.balanceDue),
|
style: TextButton.styleFrom(
|
||||||
style: TextButton.styleFrom(
|
minimumSize: const Size(0, 30),
|
||||||
minimumSize: const Size(0, 30),
|
padding: const EdgeInsets.symmetric(
|
||||||
padding: const EdgeInsets.symmetric(
|
horizontal: AppSpacing.md,
|
||||||
horizontal: AppSpacing.md,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: const Text('Exact',
|
|
||||||
style: TextStyle(fontSize: 12.5),),
|
|
||||||
),
|
),
|
||||||
|
child: const Text('Exact',
|
||||||
|
style: TextStyle(fontSize: 12.5),),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -302,6 +302,15 @@ class CartController extends StateNotifier<Cart> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> resume(ParkedBill bill) async {
|
Future<void> 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);
|
await _transactions.removeParked(bill.id);
|
||||||
_undoStack.clear();
|
_undoStack.clear();
|
||||||
// Re-evaluated rather than restored: a campaign that has since ended must
|
// Re-evaluated rather than restored: a campaign that has since ended must
|
||||||
@@ -309,6 +318,32 @@ class CartController extends StateNotifier<Cart> {
|
|||||||
_commit(bill.cart);
|
_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<void> 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<CartLine> _replace(CartLine updated) => [
|
List<CartLine> _replace(CartLine updated) => [
|
||||||
for (final l in state.lines)
|
for (final l in state.lines)
|
||||||
if (l.product.id == updated.product.id) updated else l,
|
if (l.product.id == updated.product.id) updated else l,
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ class PosView extends ConsumerWidget {
|
|||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const CustomerBar(),
|
|
||||||
const Divider(height: 1),
|
|
||||||
Padding(
|
Padding(
|
||||||
padding:
|
padding:
|
||||||
EdgeInsets.fromLTRB(pad, AppSpacing.lg, pad, AppSpacing.md),
|
EdgeInsets.fromLTRB(pad, AppSpacing.lg, pad, AppSpacing.md),
|
||||||
|
|||||||
197
lib/presentation/pos/widgets/admin_pin_dialog.dart
Normal file
197
lib/presentation/pos/widgets/admin_pin_dialog.dart
Normal file
@@ -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<bool> requireAdminPin(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref, {
|
||||||
|
required String reason,
|
||||||
|
}) async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
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<void> _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',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../../../core/router/app_router.dart';
|
import '../../../core/router/app_router.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
|
import '../../../core/theme/app_layout.dart';
|
||||||
import '../../../core/theme/app_typography.dart';
|
import '../../../core/theme/app_typography.dart';
|
||||||
import '../../../core/utils/extensions.dart';
|
import '../../../core/utils/extensions.dart';
|
||||||
import '../../../core/utils/formatters.dart';
|
import '../../../core/utils/formatters.dart';
|
||||||
@@ -15,6 +16,7 @@ import '../../../core/widgets/primary_button.dart';
|
|||||||
import '../../../domain/entities/cart.dart';
|
import '../../../domain/entities/cart.dart';
|
||||||
import '../../customer/widgets/customer_capture_sheet.dart';
|
import '../../customer/widgets/customer_capture_sheet.dart';
|
||||||
import '../providers/cart_controller.dart';
|
import '../providers/cart_controller.dart';
|
||||||
|
import 'admin_pin_dialog.dart';
|
||||||
import 'cart_line_tile.dart';
|
import 'cart_line_tile.dart';
|
||||||
import 'discount_sheet.dart';
|
import 'discount_sheet.dart';
|
||||||
|
|
||||||
@@ -58,7 +60,12 @@ class BillingPanel extends ConsumerWidget {
|
|||||||
line: line,
|
line: line,
|
||||||
onIncrement: () => controller.increment(line.product.id),
|
onIncrement: () => controller.increment(line.product.id),
|
||||||
onDecrement: () => controller.decrement(line.product.id),
|
onDecrement: () => controller.decrement(line.product.id),
|
||||||
onRemove: () => controller.removeLine(line.product.id),
|
onRemove: () => _removeLine(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
controller,
|
||||||
|
line.product.id,
|
||||||
|
),
|
||||||
onDiscount: () =>
|
onDiscount: () =>
|
||||||
showLineDiscountSheet(context, ref, line),
|
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<void> _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<void> _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 {
|
class _Header extends ConsumerWidget {
|
||||||
const _Header({required this.cart, required this.inSheet});
|
const _Header({required this.cart, required this.inSheet});
|
||||||
|
|
||||||
@@ -81,15 +119,15 @@ class _Header extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final controller = ref.read(cartControllerProvider.notifier);
|
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(
|
return Container(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
constraints: const BoxConstraints(minHeight: AppSizes.headerHeight),
|
||||||
AppSpacing.lg,
|
padding: EdgeInsets.symmetric(horizontal: contentPadding),
|
||||||
AppSpacing.md,
|
|
||||||
AppSpacing.sm,
|
|
||||||
AppSpacing.md,
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -99,9 +137,9 @@ class _Header extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (cart.isNotEmpty) ...[
|
if (cart.isNotEmpty) ...[
|
||||||
const SizedBox(width: AppSpacing.sm),
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: AppColors.primarySurface,
|
color: AppColors.primarySurface,
|
||||||
borderRadius: AppRadius.brPill,
|
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.
|
// Icon-only actions: labelled buttons overflowed the 380px panel.
|
||||||
if (controller.canUndo)
|
// Grouped tight with even spacing, flush against the same right
|
||||||
_IconAction(
|
// edge the header buttons on the left use.
|
||||||
icon: Icons.undo_rounded,
|
Row(
|
||||||
tooltip: 'Undo (F8)',
|
mainAxisSize: MainAxisSize.min,
|
||||||
color: AppColors.textSecondary,
|
children: [
|
||||||
onTap: controller.undo,
|
if (controller.canUndo)
|
||||||
),
|
_IconAction(
|
||||||
if (cart.isNotEmpty) ...[
|
icon: Icons.undo_rounded,
|
||||||
_IconAction(
|
tooltip: 'Undo (F8)',
|
||||||
icon: Icons.pause_circle_outline_rounded,
|
color: AppColors.textSecondary,
|
||||||
tooltip: 'Park bill',
|
onTap: controller.undo,
|
||||||
color: AppColors.warning,
|
),
|
||||||
onTap: () async {
|
if (cart.isNotEmpty) ...[
|
||||||
await controller.park();
|
_IconAction(
|
||||||
ref.invalidate(parkedBillsProvider);
|
icon: Icons.pause_circle_outline_rounded,
|
||||||
if (context.mounted) context.showSnack('Bill parked');
|
tooltip: 'Park bill',
|
||||||
},
|
color: AppColors.warning,
|
||||||
),
|
onTap: () async {
|
||||||
_IconAction(
|
await controller.park();
|
||||||
icon: Icons.delete_outline_rounded,
|
ref.invalidate(parkedBillsProvider);
|
||||||
tooltip: 'Clear bill',
|
if (context.mounted) context.showSnack('Bill parked');
|
||||||
color: AppColors.danger,
|
},
|
||||||
onTap: controller.clear,
|
),
|
||||||
),
|
_IconAction(
|
||||||
],
|
icon: Icons.delete_outline_rounded,
|
||||||
if (inSheet)
|
tooltip: 'Clear bill',
|
||||||
_IconAction(
|
color: AppColors.danger,
|
||||||
icon: Icons.close_rounded,
|
onTap: () => _clearCart(context, ref, controller),
|
||||||
tooltip: 'Close',
|
),
|
||||||
color: AppColors.textSecondary,
|
],
|
||||||
onTap: () => Navigator.of(context).pop(),
|
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) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
|
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
|
||||||
child: Row(children: [
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
@@ -390,7 +442,7 @@ class _Row extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (trailingIcon != null) ...[
|
if (trailingIcon != null) ...[
|
||||||
const SizedBox(width: AppSpacing.xs),
|
|
||||||
Icon(trailingIcon, size: 14, color: AppColors.textTertiary),
|
Icon(trailingIcon, size: 14, color: AppColors.textTertiary),
|
||||||
],
|
],
|
||||||
],),
|
],),
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -68,28 +68,7 @@ class PageHeader extends ConsumerWidget {
|
|||||||
const SizedBox(width: AppSpacing.xs),
|
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) ...[
|
if (showStatus) ...[
|
||||||
_LivePill(offline: ref.watch(simulateOfflineProvider)),
|
_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.
|
/// What the pill is saying, in the order it takes precedence.
|
||||||
enum _Liveness { offlineSim, halted, syncing, queued, live }
|
enum _Liveness { offlineSim, halted, syncing, queued, live }
|
||||||
@@ -342,11 +285,24 @@ class _ParkedBillsButton extends ConsumerWidget {
|
|||||||
'${Formatters.time(bill.parkedAt)}',
|
'${Formatters.time(bill.parkedAt)}',
|
||||||
),
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
|
final hadItems =
|
||||||
|
ref.read(cartControllerProvider).isNotEmpty;
|
||||||
await ref
|
await ref
|
||||||
.read(cartControllerProvider.notifier)
|
.read(cartControllerProvider.notifier)
|
||||||
.resume(bill);
|
.resume(bill);
|
||||||
ref.invalidate(parkedBillsProvider);
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
void start() {
|
Future<void> start() async {
|
||||||
ref.read(cartControllerProvider.notifier).reset();
|
final hadItems = ref.read(cartControllerProvider).isNotEmpty;
|
||||||
|
await ref.read(cartControllerProvider.notifier).startNewSale();
|
||||||
|
if (hadItems) ref.invalidate(parkedBillsProvider);
|
||||||
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
|
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
ScaffoldMessenger.of(context)
|
ScaffoldMessenger.of(context)
|
||||||
..hideCurrentSnackBar()
|
..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) {
|
if (compact) {
|
||||||
|
|||||||
@@ -84,9 +84,10 @@ class _ProductCardState extends State<ProductCard> {
|
|||||||
children: [
|
children: [
|
||||||
Opacity(
|
Opacity(
|
||||||
opacity: disabled ? 0.4 : 1,
|
opacity: disabled ? 0.4 : 1,
|
||||||
child: Text(
|
child: _ProductVisual(
|
||||||
p.emoji,
|
imageUrl: p.imageUrl,
|
||||||
style: TextStyle(fontSize: emoji),
|
emoji: p.emoji,
|
||||||
|
size: emoji,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: tight ? 2 : AppSpacing.sm),
|
SizedBox(height: tight ? 2 : AppSpacing.sm),
|
||||||
@@ -252,3 +253,62 @@ class _ProductCardState extends State<ProductCard> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,13 +15,22 @@ import '../../../core/utils/extensions.dart';
|
|||||||
import '../../../core/utils/formatters.dart';
|
import '../../../core/utils/formatters.dart';
|
||||||
import '../../../core/widgets/glass_card.dart';
|
import '../../../core/widgets/glass_card.dart';
|
||||||
import '../../../core/widgets/primary_button.dart';
|
import '../../../core/widgets/primary_button.dart';
|
||||||
|
import '../../../data/sync/sync_engine.dart';
|
||||||
import '../../../domain/entities/transaction.dart';
|
import '../../../domain/entities/transaction.dart';
|
||||||
import '../../modules/providers/printer_settings.dart';
|
import '../../modules/providers/printer_settings.dart';
|
||||||
import '../../pos/providers/cart_controller.dart';
|
import '../../pos/providers/cart_controller.dart';
|
||||||
|
import '../../pos/providers/catalog_providers.dart';
|
||||||
|
import '../../sync/providers/sync_controller.dart';
|
||||||
import '../widgets/receipt_preview.dart';
|
import '../widgets/receipt_preview.dart';
|
||||||
|
|
||||||
/// Confirmation screen. Counts down and starts the next sale on its own so an
|
/// Confirmation screen.
|
||||||
/// unattended terminal never sits on a finished bill.
|
///
|
||||||
|
/// 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 {
|
class ReceiptScreen extends ConsumerStatefulWidget {
|
||||||
const ReceiptScreen({super.key, required this.transaction});
|
const ReceiptScreen({super.key, required this.transaction});
|
||||||
|
|
||||||
@@ -32,9 +41,13 @@ class ReceiptScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
||||||
late int _seconds = AppConstants.postSaleResetDelay.inSeconds + 5;
|
late int _seconds = AppConstants.postSaleResetDelay.inSeconds;
|
||||||
Timer? _timer;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -82,13 +95,36 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
|||||||
|
|
||||||
void _newSale() {
|
void _newSale() {
|
||||||
_timer?.cancel();
|
_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();
|
ref.read(cartControllerProvider.notifier).reset();
|
||||||
if (mounted) context.go(AppRoutes.pos);
|
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<void> _cancelSale() async {
|
||||||
|
if (_voiding) return;
|
||||||
|
setState(() => _voiding = true);
|
||||||
_timer?.cancel();
|
_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);
|
if (mounted) context.go(AppRoutes.pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,12 +329,23 @@ class _ReceiptScreenState extends ConsumerState<ReceiptScreen> {
|
|||||||
: 'New Sale',
|
: 'New Sale',
|
||||||
icon: Icons.add_shopping_cart_rounded,
|
icon: Icons.add_shopping_cart_rounded,
|
||||||
large: true,
|
large: true,
|
||||||
onPressed: _newSale,
|
onPressed: _voiding ? null : _newSale,
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.sm),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
TextButton(
|
TextButton.icon(
|
||||||
onPressed: _continueBilling,
|
onPressed: _voiding ? null : _cancelSale,
|
||||||
child: const Text('Back to billing screen'),
|
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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../../../core/utils/formatters.dart';
|
|||||||
import '../../../core/widgets/primary_button.dart';
|
import '../../../core/widgets/primary_button.dart';
|
||||||
import '../../auth/providers/auth_controller.dart';
|
import '../../auth/providers/auth_controller.dart';
|
||||||
import '../../pos/providers/cart_controller.dart';
|
import '../../pos/providers/cart_controller.dart';
|
||||||
|
import '../../pos/providers/catalog_providers.dart';
|
||||||
import '../../../domain/repositories/sync_repository.dart';
|
import '../../../domain/repositories/sync_repository.dart';
|
||||||
import '../providers/sync_controller.dart';
|
import '../providers/sync_controller.dart';
|
||||||
|
|
||||||
@@ -35,9 +36,21 @@ class _SignOutDialog extends ConsumerStatefulWidget {
|
|||||||
class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
||||||
SyncOutcome? _result;
|
SyncOutcome? _result;
|
||||||
|
|
||||||
void _finish() {
|
Future<void> _finish() async {
|
||||||
ref.read(cartControllerProvider.notifier).reset();
|
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();
|
Navigator.of(context).pop();
|
||||||
context.go(AppRoutes.login);
|
context.go(AppRoutes.login);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.cs.allow-jit</key>
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.server</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.network.server</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.print</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -4,5 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.print</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
12
pubspec.lock
12
pubspec.lock
@@ -282,10 +282,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage_platform_interface
|
name: flutter_secure_storage_platform_interface
|
||||||
sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633"
|
sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.0.3"
|
||||||
flutter_secure_storage_web:
|
flutter_secure_storage_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -729,10 +729,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqlite3
|
name: sqlite3
|
||||||
sha256: c73fd75df1332d76a6257f4823ae4df9c791f522b97e4a60cbcad214de1becf4
|
sha256: "64b2c63c8232dd20d14b34105a81ebfd74320442e8451f836179ec89986aa478"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.5.0"
|
version: "3.5.1"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -897,10 +897,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: win32
|
name: win32
|
||||||
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
|
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.0"
|
version: "6.4.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -54,3 +54,4 @@ flutter:
|
|||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
assets:
|
assets:
|
||||||
- assets/sounds/
|
- assets/sounds/
|
||||||
|
- assets/images/
|
||||||
|
|||||||
30
test/widget_test.dart
Normal file
30
test/widget_test.dart
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user