added product import and billing integration

This commit is contained in:
2026-08-05 18:15:21 +05:30
parent 33b4337933
commit 6c0266c9c7
32 changed files with 889 additions and 457 deletions

BIN
assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -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,
); );

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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,
}, },

View File

@@ -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;

View File

@@ -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

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();

View File

@@ -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(

View File

@@ -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,),

View File

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

View File

@@ -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) {

View File

@@ -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),),
),
], ],
), ),
), ),

View File

@@ -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,

View File

@@ -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),

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

View File

@@ -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),
], ],
],), ],),

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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),
), ),
], ],
), ),

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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:

View File

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