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