second commit
This commit is contained in:
136
lib/data/datasources/local_store.dart
Normal file
136
lib/data/datasources/local_store.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
import '../../domain/entities/customer.dart';
|
||||
import '../../domain/entities/product.dart';
|
||||
import '../../domain/entities/sync_event.dart';
|
||||
import '../../domain/entities/transaction.dart';
|
||||
import 'seed_data.dart';
|
||||
|
||||
/// On-terminal storage.
|
||||
///
|
||||
/// The terminal starts with an **empty catalogue**: nothing can be billed
|
||||
/// until the cashier imports products. Everything after that — sales, parked
|
||||
/// bills, queued events — lives here and survives without a connection.
|
||||
///
|
||||
/// Swapping this for Hive or SQLite changes nothing above the data layer.
|
||||
class LocalStore {
|
||||
LocalStore._();
|
||||
|
||||
static final LocalStore instance = LocalStore._();
|
||||
|
||||
final Map<String, Product> _products = {};
|
||||
final Map<String, Customer> _customers = {};
|
||||
final List<SaleTransaction> _transactions = [];
|
||||
final List<ParkedBill> _parked = [];
|
||||
final List<SyncEvent> _events = [];
|
||||
|
||||
int _invoiceSequence = 0;
|
||||
|
||||
DateTime? _lastImportAt;
|
||||
String? _catalogueRevision;
|
||||
|
||||
/// Nothing to seed — the catalogue arrives via import.
|
||||
Future<void> init() async {}
|
||||
|
||||
/// Test helper. Clears everything and optionally loads the demo catalogue
|
||||
/// so fixtures don't have to run an import first.
|
||||
Future<void> reset({bool withCatalogue = false}) async {
|
||||
_products.clear();
|
||||
_customers.clear();
|
||||
_transactions.clear();
|
||||
_parked.clear();
|
||||
_events.clear();
|
||||
_invoiceSequence = 0;
|
||||
_lastImportAt = null;
|
||||
_catalogueRevision = null;
|
||||
|
||||
if (withCatalogue) {
|
||||
importCatalogue(
|
||||
products: SeedData.products(),
|
||||
customers: SeedData.customers(),
|
||||
revision: 'seed',
|
||||
at: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- Catalogue
|
||||
/// True once a catalogue has been pulled. The POS refuses to bill until so.
|
||||
bool get hasCatalogue => _products.isNotEmpty;
|
||||
|
||||
DateTime? get lastImportAt => _lastImportAt;
|
||||
String? get catalogueRevision => _catalogueRevision;
|
||||
|
||||
/// Replaces the catalogue wholesale.
|
||||
///
|
||||
/// 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<Customer> customers,
|
||||
required String revision,
|
||||
required DateTime at,
|
||||
}) {
|
||||
final priorStock = {
|
||||
for (final p in _products.values) p.id: p.stock,
|
||||
};
|
||||
|
||||
_products
|
||||
..clear()
|
||||
..addEntries(products.map((p) {
|
||||
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
|
||||
List<Product> get products => _products.values.toList(growable: false);
|
||||
|
||||
void putProduct(Product p) => _products[p.id] = p;
|
||||
|
||||
Product? productById(String id) => _products[id];
|
||||
|
||||
// -------------------------------------------------------------- Customers
|
||||
List<Customer> get customers => _customers.values.toList(growable: false);
|
||||
|
||||
void putCustomer(Customer c) => _customers[c.id] = c;
|
||||
|
||||
Customer? customerById(String id) => _customers[id];
|
||||
|
||||
// ----------------------------------------------------------- Transactions
|
||||
List<SaleTransaction> get transactions =>
|
||||
List.unmodifiable(_transactions.reversed);
|
||||
|
||||
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 =>
|
||||
_events.any((e) => e.status != SyncStatus.synced);
|
||||
}
|
||||
98
lib/data/datasources/remote_catalogue_source.dart
Normal file
98
lib/data/datasources/remote_catalogue_source.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import '../../domain/entities/customer.dart';
|
||||
import '../../domain/entities/product.dart';
|
||||
import 'seed_data.dart';
|
||||
|
||||
/// What one catalogue pull returns.
|
||||
class CatalogueSnapshot {
|
||||
const CatalogueSnapshot({
|
||||
required this.products,
|
||||
required this.customers,
|
||||
required this.fetchedAt,
|
||||
required this.revision,
|
||||
});
|
||||
|
||||
final List<Product> products;
|
||||
final List<Customer> customers;
|
||||
final DateTime fetchedAt;
|
||||
|
||||
/// Server-side catalogue version, shown so the cashier can tell whether a
|
||||
/// re-import actually changed anything.
|
||||
final String revision;
|
||||
}
|
||||
|
||||
/// Raised when the catalogue cannot be pulled.
|
||||
class CatalogueSyncException implements Exception {
|
||||
const CatalogueSyncException(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// Stands in for the back-office catalogue API.
|
||||
///
|
||||
/// The real implementation would issue an HTTP request; the contract is the
|
||||
/// same, so only this class changes.
|
||||
class RemoteCatalogueSource {
|
||||
RemoteCatalogueSource();
|
||||
|
||||
/// Flipped from Settings to exercise the offline path.
|
||||
bool simulateOffline = false;
|
||||
|
||||
/// Streams progress so the import screen can show a real bar rather than an
|
||||
/// indeterminate spinner.
|
||||
Future<CatalogueSnapshot> fetch({
|
||||
void Function(double progress, String stage)? onProgress,
|
||||
}) async {
|
||||
const stages = [
|
||||
(0.15, 'Contacting server…'),
|
||||
(0.35, 'Authorising terminal…'),
|
||||
(0.60, 'Downloading products…'),
|
||||
(0.85, 'Downloading customers…'),
|
||||
(1.00, 'Writing to local storage…'),
|
||||
];
|
||||
|
||||
for (final (progress, stage) in stages) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 320));
|
||||
|
||||
if (simulateOffline) {
|
||||
throw const CatalogueSyncException(
|
||||
'No connection to the catalogue server. '
|
||||
'Check the network and try again.',
|
||||
);
|
||||
}
|
||||
|
||||
onProgress?.call(progress, stage);
|
||||
}
|
||||
|
||||
return CatalogueSnapshot(
|
||||
products: SeedData.products(),
|
||||
customers: SeedData.customers(),
|
||||
fetchedAt: DateTime.now(),
|
||||
revision: 'rev-${DateTime.now().millisecondsSinceEpoch % 100000}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stands in for the back-office reporting API.
|
||||
class RemoteReportSink {
|
||||
RemoteReportSink();
|
||||
|
||||
bool simulateOffline = false;
|
||||
|
||||
/// Pushes one payload. Throws on failure so the caller can keep the event
|
||||
/// queued rather than marking it sent.
|
||||
Future<String> push(Map<String, Object?> payload) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 900));
|
||||
|
||||
if (simulateOffline) {
|
||||
throw const CatalogueSyncException(
|
||||
'Could not reach the reporting server. '
|
||||
'The report is still saved on this terminal.',
|
||||
);
|
||||
}
|
||||
|
||||
return 'ACK-${DateTime.now().millisecondsSinceEpoch % 1000000}';
|
||||
}
|
||||
}
|
||||
542
lib/data/datasources/seed_data.dart
Normal file
542
lib/data/datasources/seed_data.dart
Normal file
@@ -0,0 +1,542 @@
|
||||
import '../../domain/entities/customer.dart';
|
||||
import '../../domain/entities/product.dart';
|
||||
|
||||
/// Demo catalogue and customer book.
|
||||
///
|
||||
/// Barcodes are valid 13-digit EAN strings beginning with the Indian GS1
|
||||
/// prefix `890`, so a real scanner can be tested against this data.
|
||||
class SeedData {
|
||||
const SeedData._();
|
||||
|
||||
static List<Product> products() => const [
|
||||
// ------------------------------------------------------------ Dairy
|
||||
Product(
|
||||
id: 'p001',
|
||||
name: 'Amul Milk 1L',
|
||||
barcode: '8901234500011',
|
||||
sku: 'DRY-MLK-1000',
|
||||
category: ProductCategory.dairy,
|
||||
price: 62,
|
||||
mrp: 66,
|
||||
stock: 50,
|
||||
emoji: '🥛',
|
||||
unit: UnitOfMeasure.litre,
|
||||
gstRate: 0.05,
|
||||
brand: 'Amul',
|
||||
),
|
||||
Product(
|
||||
id: 'p002',
|
||||
name: 'Amul Butter 500g',
|
||||
barcode: '8901234500028',
|
||||
sku: 'DRY-BTR-0500',
|
||||
category: ProductCategory.dairy,
|
||||
price: 245,
|
||||
mrp: 265,
|
||||
stock: 30,
|
||||
emoji: '🧈',
|
||||
gstRate: 0.12,
|
||||
brand: 'Amul',
|
||||
),
|
||||
Product(
|
||||
id: 'p003',
|
||||
name: 'Curd 400g',
|
||||
barcode: '8901234500035',
|
||||
sku: 'DRY-CRD-0400',
|
||||
category: ProductCategory.dairy,
|
||||
price: 48,
|
||||
mrp: 52,
|
||||
stock: 40,
|
||||
emoji: '🥣',
|
||||
gstRate: 0.05,
|
||||
brand: 'Nandini',
|
||||
),
|
||||
Product(
|
||||
id: 'p004',
|
||||
name: 'Paneer 200g',
|
||||
barcode: '8901234500042',
|
||||
sku: 'DRY-PNR-0200',
|
||||
category: ProductCategory.dairy,
|
||||
price: 90,
|
||||
stock: 20,
|
||||
emoji: '🧀',
|
||||
gstRate: 0.05,
|
||||
brand: 'Milky Mist',
|
||||
),
|
||||
Product(
|
||||
id: 'p005',
|
||||
name: 'Cheese Slices 200g',
|
||||
barcode: '8901234500059',
|
||||
sku: 'DRY-CHS-0200',
|
||||
category: ProductCategory.dairy,
|
||||
price: 135,
|
||||
mrp: 145,
|
||||
stock: 25,
|
||||
emoji: '🧀',
|
||||
gstRate: 0.12,
|
||||
brand: 'Britannia',
|
||||
),
|
||||
|
||||
// ---------------------------------------------------------- Grocery
|
||||
Product(
|
||||
id: 'p010',
|
||||
name: 'Basmati Rice 1kg',
|
||||
barcode: '8901234500110',
|
||||
sku: 'GRO-RCE-1000',
|
||||
category: ProductCategory.grocery,
|
||||
price: 180,
|
||||
mrp: 199,
|
||||
stock: 4,
|
||||
emoji: '🍚',
|
||||
unit: UnitOfMeasure.kilogram,
|
||||
gstRate: 0.05,
|
||||
brand: 'India Gate',
|
||||
),
|
||||
Product(
|
||||
id: 'p011',
|
||||
name: 'Fortune Oil 1L',
|
||||
barcode: '8901234500127',
|
||||
sku: 'GRO-OIL-1000',
|
||||
category: ProductCategory.grocery,
|
||||
price: 145,
|
||||
mrp: 160,
|
||||
stock: 60,
|
||||
emoji: '🛢️',
|
||||
unit: UnitOfMeasure.litre,
|
||||
gstRate: 0.05,
|
||||
brand: 'Fortune',
|
||||
),
|
||||
Product(
|
||||
id: 'p012',
|
||||
name: 'Toor Dal 500g',
|
||||
barcode: '8901234500134',
|
||||
sku: 'GRO-DAL-0500',
|
||||
category: ProductCategory.grocery,
|
||||
price: 90,
|
||||
stock: 45,
|
||||
emoji: '🫘',
|
||||
gstRate: 0.05,
|
||||
brand: 'Tata Sampann',
|
||||
),
|
||||
Product(
|
||||
id: 'p013',
|
||||
name: 'Aashirvaad Atta 5kg',
|
||||
barcode: '8901234500141',
|
||||
sku: 'GRO-ATA-5000',
|
||||
category: ProductCategory.grocery,
|
||||
price: 285,
|
||||
mrp: 310,
|
||||
stock: 35,
|
||||
emoji: '🌾',
|
||||
unit: UnitOfMeasure.kilogram,
|
||||
gstRate: 0.05,
|
||||
brand: 'Aashirvaad',
|
||||
),
|
||||
Product(
|
||||
id: 'p014',
|
||||
name: 'Sugar 1kg',
|
||||
barcode: '8901234500158',
|
||||
sku: 'GRO-SGR-1000',
|
||||
category: ProductCategory.grocery,
|
||||
price: 48,
|
||||
stock: 80,
|
||||
emoji: '🍬',
|
||||
unit: UnitOfMeasure.kilogram,
|
||||
gstRate: 0.05,
|
||||
),
|
||||
Product(
|
||||
id: 'p015',
|
||||
name: 'Maggi 2-min',
|
||||
barcode: '8901234500165',
|
||||
sku: 'GRO-MAG-0070',
|
||||
category: ProductCategory.grocery,
|
||||
price: 14,
|
||||
stock: 120,
|
||||
emoji: '🍜',
|
||||
gstRate: 0.12,
|
||||
brand: 'Nestlé',
|
||||
),
|
||||
Product(
|
||||
id: 'p016',
|
||||
name: 'Tata Salt 1kg',
|
||||
barcode: '8901234500172',
|
||||
sku: 'GRO-SLT-1000',
|
||||
category: ProductCategory.grocery,
|
||||
price: 28,
|
||||
stock: 95,
|
||||
emoji: '🧂',
|
||||
unit: UnitOfMeasure.kilogram,
|
||||
gstRate: 0.05,
|
||||
brand: 'Tata',
|
||||
),
|
||||
|
||||
// -------------------------------------------------------- Beverages
|
||||
Product(
|
||||
id: 'p020',
|
||||
name: 'Coca-Cola 600ml',
|
||||
barcode: '8901234500219',
|
||||
sku: 'BEV-COK-0600',
|
||||
category: ProductCategory.beverages,
|
||||
price: 40,
|
||||
stock: 80,
|
||||
emoji: '🥤',
|
||||
unit: UnitOfMeasure.millilitre,
|
||||
gstRate: 0.28,
|
||||
brand: 'Coca-Cola',
|
||||
),
|
||||
Product(
|
||||
id: 'p021',
|
||||
name: 'Frooti 250ml',
|
||||
barcode: '8901234500226',
|
||||
sku: 'BEV-FRT-0250',
|
||||
category: ProductCategory.beverages,
|
||||
price: 15,
|
||||
stock: 100,
|
||||
emoji: '🥭',
|
||||
gstRate: 0.12,
|
||||
brand: 'Parle Agro',
|
||||
),
|
||||
Product(
|
||||
id: 'p022',
|
||||
name: 'Bisleri 1L',
|
||||
barcode: '8901234500233',
|
||||
sku: 'BEV-WTR-1000',
|
||||
category: ProductCategory.beverages,
|
||||
price: 20,
|
||||
stock: 150,
|
||||
emoji: '💧',
|
||||
unit: UnitOfMeasure.litre,
|
||||
gstRate: 0.18,
|
||||
brand: 'Bisleri',
|
||||
),
|
||||
Product(
|
||||
id: 'p023',
|
||||
name: 'Red Bull 250ml',
|
||||
barcode: '8901234500240',
|
||||
sku: 'BEV-RBL-0250',
|
||||
category: ProductCategory.beverages,
|
||||
price: 125,
|
||||
stock: 40,
|
||||
emoji: '🔋',
|
||||
gstRate: 0.28,
|
||||
brand: 'Red Bull',
|
||||
),
|
||||
Product(
|
||||
id: 'p024',
|
||||
name: 'Bru Coffee 100g',
|
||||
barcode: '8901234500257',
|
||||
sku: 'BEV-COF-0100',
|
||||
category: ProductCategory.beverages,
|
||||
price: 165,
|
||||
mrp: 180,
|
||||
stock: 30,
|
||||
emoji: '☕',
|
||||
gstRate: 0.18,
|
||||
brand: 'Bru',
|
||||
),
|
||||
|
||||
// ----------------------------------------------------------- Snacks
|
||||
Product(
|
||||
id: 'p030',
|
||||
name: 'Parle-G 800g',
|
||||
barcode: '8901234500318',
|
||||
sku: 'SNK-PGB-0800',
|
||||
category: ProductCategory.snacks,
|
||||
price: 40,
|
||||
stock: 90,
|
||||
emoji: '🍪',
|
||||
gstRate: 0.18,
|
||||
brand: 'Parle',
|
||||
),
|
||||
Product(
|
||||
id: 'p031',
|
||||
name: "Lay's Chips 26g",
|
||||
barcode: '8901234500325',
|
||||
sku: 'SNK-LAY-0026',
|
||||
category: ProductCategory.snacks,
|
||||
price: 20,
|
||||
stock: 110,
|
||||
emoji: '🥔',
|
||||
gstRate: 0.18,
|
||||
brand: "Lay's",
|
||||
),
|
||||
Product(
|
||||
id: 'p032',
|
||||
name: 'Dairy Milk 55g',
|
||||
barcode: '8901234500332',
|
||||
sku: 'SNK-DMK-0055',
|
||||
category: ProductCategory.snacks,
|
||||
price: 45,
|
||||
stock: 75,
|
||||
emoji: '🍫',
|
||||
gstRate: 0.18,
|
||||
brand: 'Cadbury',
|
||||
),
|
||||
Product(
|
||||
id: 'p033',
|
||||
name: 'Good Day 200g',
|
||||
barcode: '8901234500349',
|
||||
sku: 'SNK-GDY-0200',
|
||||
category: ProductCategory.snacks,
|
||||
price: 35,
|
||||
stock: 85,
|
||||
emoji: '🍪',
|
||||
gstRate: 0.18,
|
||||
brand: 'Britannia',
|
||||
),
|
||||
Product(
|
||||
id: 'p034',
|
||||
name: 'Haldiram Mixture 200g',
|
||||
barcode: '8901234500356',
|
||||
sku: 'SNK-HMX-0200',
|
||||
category: ProductCategory.snacks,
|
||||
price: 55,
|
||||
stock: 60,
|
||||
emoji: '🥜',
|
||||
gstRate: 0.12,
|
||||
brand: 'Haldiram',
|
||||
),
|
||||
|
||||
// ---------------------------------------------------- Personal care
|
||||
Product(
|
||||
id: 'p040',
|
||||
name: 'Colgate 200g',
|
||||
barcode: '8901234500417',
|
||||
sku: 'PER-CLG-0200',
|
||||
category: ProductCategory.personalCare,
|
||||
price: 110,
|
||||
mrp: 125,
|
||||
stock: 55,
|
||||
emoji: '🪥',
|
||||
gstRate: 0.18,
|
||||
brand: 'Colgate',
|
||||
),
|
||||
Product(
|
||||
id: 'p041',
|
||||
name: 'Dove Soap 100g',
|
||||
barcode: '8901234500424',
|
||||
sku: 'PER-DVE-0100',
|
||||
category: ProductCategory.personalCare,
|
||||
price: 65,
|
||||
stock: 70,
|
||||
emoji: '🧼',
|
||||
gstRate: 0.18,
|
||||
brand: 'Dove',
|
||||
),
|
||||
Product(
|
||||
id: 'p042',
|
||||
name: 'Head & Shoulders 340ml',
|
||||
barcode: '8901234500431',
|
||||
sku: 'PER-HNS-0340',
|
||||
category: ProductCategory.personalCare,
|
||||
price: 385,
|
||||
mrp: 420,
|
||||
stock: 25,
|
||||
emoji: '🧴',
|
||||
gstRate: 0.18,
|
||||
brand: 'P&G',
|
||||
),
|
||||
Product(
|
||||
id: 'p043',
|
||||
name: 'Nivea Lotion 200ml',
|
||||
barcode: '8901234500448',
|
||||
sku: 'PER-NVA-0200',
|
||||
category: ProductCategory.personalCare,
|
||||
price: 240,
|
||||
stock: 30,
|
||||
emoji: '🧴',
|
||||
gstRate: 0.18,
|
||||
brand: 'Nivea',
|
||||
),
|
||||
|
||||
// -------------------------------------------------------- Household
|
||||
Product(
|
||||
id: 'p050',
|
||||
name: 'Surf Excel 1kg',
|
||||
barcode: '8901234500516',
|
||||
sku: 'HSE-SRF-1000',
|
||||
category: ProductCategory.household,
|
||||
price: 165,
|
||||
mrp: 180,
|
||||
stock: 45,
|
||||
emoji: '🧺',
|
||||
unit: UnitOfMeasure.kilogram,
|
||||
gstRate: 0.18,
|
||||
brand: 'Surf Excel',
|
||||
),
|
||||
Product(
|
||||
id: 'p051',
|
||||
name: 'Vim Bar 300g',
|
||||
barcode: '8901234500523',
|
||||
sku: 'HSE-VIM-0300',
|
||||
category: ProductCategory.household,
|
||||
price: 30,
|
||||
stock: 90,
|
||||
emoji: '🧽',
|
||||
gstRate: 0.18,
|
||||
brand: 'Vim',
|
||||
),
|
||||
Product(
|
||||
id: 'p052',
|
||||
name: 'Harpic 500ml',
|
||||
barcode: '8901234500530',
|
||||
sku: 'HSE-HRP-0500',
|
||||
category: ProductCategory.household,
|
||||
price: 98,
|
||||
stock: 40,
|
||||
emoji: '🧴',
|
||||
gstRate: 0.18,
|
||||
brand: 'Harpic',
|
||||
),
|
||||
Product(
|
||||
id: 'p053',
|
||||
name: 'Garbage Bags 30pc',
|
||||
barcode: '8901234500547',
|
||||
sku: 'HSE-GBG-0030',
|
||||
category: ProductCategory.household,
|
||||
price: 145,
|
||||
stock: 35,
|
||||
emoji: '🗑️',
|
||||
gstRate: 0.18,
|
||||
),
|
||||
|
||||
// ----------------------------------------------------------- Fruits
|
||||
Product(
|
||||
id: 'p060',
|
||||
name: 'Banana 1kg',
|
||||
barcode: '8901234500615',
|
||||
sku: 'FRT-BAN-1000',
|
||||
category: ProductCategory.fruits,
|
||||
price: 55,
|
||||
stock: 40,
|
||||
emoji: '🍌',
|
||||
unit: UnitOfMeasure.kilogram,
|
||||
gstRate: 0,
|
||||
),
|
||||
Product(
|
||||
id: 'p061',
|
||||
name: 'Apple Shimla 1kg',
|
||||
barcode: '8901234500622',
|
||||
sku: 'FRT-APL-1000',
|
||||
category: ProductCategory.fruits,
|
||||
price: 180,
|
||||
stock: 25,
|
||||
emoji: '🍎',
|
||||
unit: UnitOfMeasure.kilogram,
|
||||
gstRate: 0,
|
||||
),
|
||||
Product(
|
||||
id: 'p062',
|
||||
name: 'Alphonso Mango 1kg',
|
||||
barcode: '8901234500639',
|
||||
sku: 'FRT-MNG-1000',
|
||||
category: ProductCategory.fruits,
|
||||
price: 320,
|
||||
stock: 15,
|
||||
emoji: '🥭',
|
||||
unit: UnitOfMeasure.kilogram,
|
||||
gstRate: 0,
|
||||
),
|
||||
|
||||
// ------------------------------------------------------- Vegetables
|
||||
Product(
|
||||
id: 'p070',
|
||||
name: 'Tomato 1kg',
|
||||
barcode: '8901234500714',
|
||||
sku: 'VEG-TOM-1000',
|
||||
category: ProductCategory.vegetables,
|
||||
price: 40,
|
||||
stock: 50,
|
||||
emoji: '🍅',
|
||||
unit: UnitOfMeasure.kilogram,
|
||||
gstRate: 0,
|
||||
),
|
||||
Product(
|
||||
id: 'p071',
|
||||
name: 'Onion 1kg',
|
||||
barcode: '8901234500721',
|
||||
sku: 'VEG-ONI-1000',
|
||||
category: ProductCategory.vegetables,
|
||||
price: 35,
|
||||
stock: 65,
|
||||
emoji: '🧅',
|
||||
unit: UnitOfMeasure.kilogram,
|
||||
gstRate: 0,
|
||||
),
|
||||
Product(
|
||||
id: 'p072',
|
||||
name: 'Potato 1kg',
|
||||
barcode: '8901234500738',
|
||||
sku: 'VEG-POT-1000',
|
||||
category: ProductCategory.vegetables,
|
||||
price: 30,
|
||||
stock: 70,
|
||||
emoji: '🥔',
|
||||
unit: UnitOfMeasure.kilogram,
|
||||
gstRate: 0,
|
||||
),
|
||||
Product(
|
||||
id: 'p073',
|
||||
name: 'Carrot 500g',
|
||||
barcode: '8901234500745',
|
||||
sku: 'VEG-CAR-0500',
|
||||
category: ProductCategory.vegetables,
|
||||
price: 32,
|
||||
stock: 8,
|
||||
emoji: '🥕',
|
||||
gstRate: 0,
|
||||
),
|
||||
];
|
||||
|
||||
static List<Customer> customers() => [
|
||||
Customer(
|
||||
id: 'c001',
|
||||
name: 'Abhishek',
|
||||
mobile: '9876543210',
|
||||
email: 'abhishek@example.com',
|
||||
gender: Gender.male,
|
||||
dateOfBirth: DateTime(1994, 3, 18),
|
||||
loyaltyPoints: 320,
|
||||
lifetimeSpend: 24500,
|
||||
visitCount: 41,
|
||||
createdAt: DateTime(2024, 1, 12),
|
||||
lastVisitAt: DateTime.now().subtract(const Duration(days: 3)),
|
||||
),
|
||||
Customer(
|
||||
id: 'c002',
|
||||
name: 'Priya Raman',
|
||||
mobile: '9812345678',
|
||||
email: 'priya.r@example.com',
|
||||
gender: Gender.female,
|
||||
dateOfBirth: DateTime(1990, 7, 2),
|
||||
loyaltyPoints: 1180,
|
||||
lifetimeSpend: 68200,
|
||||
visitCount: 96,
|
||||
createdAt: DateTime(2023, 6, 4),
|
||||
lastVisitAt: DateTime.now().subtract(const Duration(days: 1)),
|
||||
),
|
||||
Customer(
|
||||
id: 'c003',
|
||||
name: 'Karthik S',
|
||||
mobile: '9900112233',
|
||||
gender: Gender.male,
|
||||
loyaltyPoints: 45,
|
||||
lifetimeSpend: 3200,
|
||||
visitCount: 7,
|
||||
createdAt: DateTime(2025, 2, 20),
|
||||
lastVisitAt: DateTime.now().subtract(const Duration(days: 11)),
|
||||
),
|
||||
Customer(
|
||||
id: 'c004',
|
||||
name: 'Meena Lakshmi',
|
||||
mobile: '9445566778',
|
||||
email: 'meena.l@example.com',
|
||||
gender: Gender.female,
|
||||
dateOfBirth: DateTime(1986, 11, 30),
|
||||
loyaltyPoints: 2640,
|
||||
lifetimeSpend: 172000,
|
||||
visitCount: 210,
|
||||
createdAt: DateTime(2022, 9, 15),
|
||||
lastVisitAt: DateTime.now().subtract(const Duration(hours: 20)),
|
||||
),
|
||||
];
|
||||
}
|
||||
96
lib/data/repositories/customer_repository_impl.dart
Normal file
96
lib/data/repositories/customer_repository_impl.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../core/utils/extensions.dart';
|
||||
import '../../domain/entities/customer.dart';
|
||||
import '../../domain/repositories/customer_repository.dart';
|
||||
import '../datasources/local_store.dart';
|
||||
|
||||
class CustomerRepositoryImpl implements CustomerRepository {
|
||||
CustomerRepositoryImpl(this._store);
|
||||
|
||||
final LocalStore _store;
|
||||
static const _uuid = Uuid();
|
||||
|
||||
String _digits(String v) => v.replaceAll(RegExp(r'\D'), '');
|
||||
|
||||
@override
|
||||
Future<Customer?> findByMobile(String mobile) async {
|
||||
final needle = _digits(mobile);
|
||||
return _store.customers
|
||||
.firstWhereOrNull((c) => _digits(c.mobile) == needle);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Customer?> findById(String id) async => _store.customerById(id);
|
||||
|
||||
@override
|
||||
Future<Customer> create(Customer customer) async {
|
||||
final existing = await findByMobile(customer.mobile);
|
||||
if (existing != null) {
|
||||
throw StateError('A customer with this mobile number already exists.');
|
||||
}
|
||||
final created = Customer(
|
||||
id: _uuid.v4(),
|
||||
name: customer.name.trim(),
|
||||
mobile: _digits(customer.mobile),
|
||||
email: customer.email?.trim().isEmpty ?? true
|
||||
? null
|
||||
: customer.email!.trim(),
|
||||
gender: customer.gender,
|
||||
dateOfBirth: customer.dateOfBirth,
|
||||
loyaltyPoints: 0,
|
||||
lifetimeSpend: 0,
|
||||
visitCount: 0,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
_store.putCustomer(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Customer> update(Customer customer) async {
|
||||
_store.putCustomer(customer);
|
||||
return customer;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Customer> recordSale({
|
||||
required String customerId,
|
||||
required double amount,
|
||||
required int pointsEarned,
|
||||
required int pointsRedeemed,
|
||||
}) async {
|
||||
final current = _store.customerById(customerId);
|
||||
if (current == null) {
|
||||
throw StateError('Customer $customerId not found.');
|
||||
}
|
||||
final updated = current.copyWith(
|
||||
loyaltyPoints:
|
||||
(current.loyaltyPoints - pointsRedeemed + pointsEarned)
|
||||
.clamp(0, 1 << 31),
|
||||
lifetimeSpend: (current.lifetimeSpend + amount).asMoney,
|
||||
visitCount: current.visitCount + 1,
|
||||
lastVisitAt: DateTime.now(),
|
||||
);
|
||||
_store.putCustomer(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Customer>> search(String query) async {
|
||||
final q = query.trim().toLowerCase();
|
||||
if (q.isEmpty) return recent();
|
||||
return _store.customers
|
||||
.where((c) =>
|
||||
c.name.toLowerCase().contains(q) || _digits(c.mobile).contains(q))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Customer>> recent({int limit = 20}) async {
|
||||
final list = _store.customers.toList()
|
||||
..sort((a, b) => (b.lastVisitAt ?? DateTime(2000))
|
||||
.compareTo(a.lastVisitAt ?? DateTime(2000)));
|
||||
return list.take(limit).toList();
|
||||
}
|
||||
}
|
||||
68
lib/data/repositories/product_repository_impl.dart
Normal file
68
lib/data/repositories/product_repository_impl.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
import '../../core/utils/extensions.dart';
|
||||
import '../../domain/entities/product.dart';
|
||||
import '../../domain/repositories/product_repository.dart';
|
||||
import '../datasources/local_store.dart';
|
||||
|
||||
class ProductRepositoryImpl implements ProductRepository {
|
||||
ProductRepositoryImpl(this._store);
|
||||
|
||||
final LocalStore _store;
|
||||
|
||||
@override
|
||||
Future<List<Product>> getAll() async =>
|
||||
_store.products.where((p) => p.isActive).toList();
|
||||
|
||||
@override
|
||||
Future<List<Product>> getByCategory(ProductCategory category) async =>
|
||||
_store.products
|
||||
.where((p) => p.isActive && p.category == category)
|
||||
.toList();
|
||||
|
||||
@override
|
||||
Future<Product?> findByBarcode(String barcode) async {
|
||||
final needle = barcode.trim();
|
||||
return _store.products.firstWhereOrNull(
|
||||
(p) => p.barcode == needle && p.isActive,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Product?> findById(String id) async => _store.productById(id);
|
||||
|
||||
@override
|
||||
Future<List<Product>> search(String query) async {
|
||||
final q = query.trim();
|
||||
if (q.isEmpty) return getAll();
|
||||
|
||||
final results = _store.products.where((p) => p.isActive && p.matches(q));
|
||||
|
||||
// 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)));
|
||||
return ranked;
|
||||
}
|
||||
|
||||
int _score(Product p, String q) {
|
||||
final lq = q.toLowerCase();
|
||||
if (p.barcode == q) return 100;
|
||||
if (p.sku.toLowerCase() == lq) return 90;
|
||||
if (p.name.toLowerCase().startsWith(lq)) return 70;
|
||||
if (p.name.toLowerCase().contains(lq)) return 50;
|
||||
if (p.brand?.toLowerCase().contains(lq) ?? false) return 30;
|
||||
return 10;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> decrementStock(Map<String, double> quantities) async {
|
||||
quantities.forEach((id, qty) {
|
||||
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
|
||||
Future<void> upsert(Product product) async => _store.putProduct(product);
|
||||
}
|
||||
159
lib/data/repositories/sync_repository_impl.dart
Normal file
159
lib/data/repositories/sync_repository_impl.dart
Normal file
@@ -0,0 +1,159 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../core/utils/formatters.dart';
|
||||
import '../../domain/entities/shift_report.dart';
|
||||
import '../../domain/entities/sync_event.dart';
|
||||
import '../../domain/repositories/sync_repository.dart';
|
||||
import '../datasources/local_store.dart';
|
||||
import '../datasources/remote_catalogue_source.dart';
|
||||
|
||||
class SyncRepositoryImpl implements SyncRepository {
|
||||
SyncRepositoryImpl(this._store, this._catalogue, this._reports);
|
||||
|
||||
final LocalStore _store;
|
||||
final RemoteCatalogueSource _catalogue;
|
||||
final RemoteReportSink _reports;
|
||||
|
||||
static const _uuid = Uuid();
|
||||
|
||||
@override
|
||||
bool get hasCatalogue => _store.hasCatalogue;
|
||||
|
||||
@override
|
||||
DateTime? get lastImportAt => _store.lastImportAt;
|
||||
|
||||
@override
|
||||
String? get catalogueRevision => _store.catalogueRevision;
|
||||
|
||||
@override
|
||||
List<SyncEvent> get events => _store.events;
|
||||
|
||||
@override
|
||||
bool get hasUnsyncedEvents => _store.hasUnsyncedEvents;
|
||||
|
||||
@override
|
||||
Future<SyncEvent> importCatalogue({
|
||||
void Function(double progress, String stage)? onProgress,
|
||||
}) async {
|
||||
final event = SyncEvent(
|
||||
id: _uuid.v4(),
|
||||
type: SyncEventType.catalogueImport,
|
||||
status: SyncStatus.syncing,
|
||||
createdAt: DateTime.now(),
|
||||
summary: 'Catalogue import started',
|
||||
);
|
||||
_store.addEvent(event);
|
||||
|
||||
try {
|
||||
final snapshot = await _catalogue.fetch(onProgress: onProgress);
|
||||
|
||||
_store.importCatalogue(
|
||||
products: snapshot.products,
|
||||
customers: snapshot.customers,
|
||||
revision: snapshot.revision,
|
||||
at: snapshot.fetchedAt,
|
||||
);
|
||||
|
||||
final done = event.copyWith(
|
||||
status: SyncStatus.synced,
|
||||
syncedAt: DateTime.now(),
|
||||
attempts: 1,
|
||||
);
|
||||
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: {
|
||||
'products': snapshot.products.length,
|
||||
'customers': snapshot.customers.length,
|
||||
'revision': snapshot.revision,
|
||||
},
|
||||
syncedAt: done.syncedAt,
|
||||
attempts: 1,
|
||||
);
|
||||
_store.updateEvent(settled);
|
||||
return settled;
|
||||
} catch (e) {
|
||||
final failed = event.copyWith(
|
||||
status: SyncStatus.failed,
|
||||
error: e.toString(),
|
||||
attempts: 1,
|
||||
);
|
||||
_store.updateEvent(failed);
|
||||
return failed;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
ShiftReport buildShiftReport({
|
||||
required DateTime businessDate,
|
||||
required String terminalId,
|
||||
required String cashierName,
|
||||
}) {
|
||||
return ShiftReport.fromTransactions(
|
||||
transactions: _store.transactions,
|
||||
businessDate: businessDate,
|
||||
terminalId: terminalId,
|
||||
cashierName: cashierName,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SyncEvent> pushShiftReport(ShiftReport report) async {
|
||||
// Queued first, so the data is durable before the network is touched.
|
||||
final queued = SyncEvent(
|
||||
id: _uuid.v4(),
|
||||
type: SyncEventType.shiftReport,
|
||||
status: SyncStatus.pending,
|
||||
createdAt: DateTime.now(),
|
||||
summary: '${report.billCount} bills · '
|
||||
'${Formatters.money(report.grossSales)} · '
|
||||
'${Formatters.date(report.businessDate)}',
|
||||
payload: report.toPayload(),
|
||||
);
|
||||
_store.addEvent(queued);
|
||||
|
||||
return _attempt(queued);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SyncEvent> retry(String eventId) async {
|
||||
final matches = _store.events.where((e) => e.id == eventId).toList();
|
||||
if (matches.isEmpty) {
|
||||
throw StateError('No queued event with id $eventId');
|
||||
}
|
||||
final event = matches.first;
|
||||
if (event.type == SyncEventType.catalogueImport) {
|
||||
return importCatalogue();
|
||||
}
|
||||
return _attempt(event);
|
||||
}
|
||||
|
||||
/// Sends one event, leaving it queued if the push fails.
|
||||
Future<SyncEvent> _attempt(SyncEvent event) async {
|
||||
_store.updateEvent(event.copyWith(status: SyncStatus.syncing));
|
||||
|
||||
try {
|
||||
await _reports.push(event.payload);
|
||||
final done = event.copyWith(
|
||||
status: SyncStatus.synced,
|
||||
syncedAt: DateTime.now(),
|
||||
attempts: event.attempts + 1,
|
||||
clearError: true,
|
||||
);
|
||||
_store.updateEvent(done);
|
||||
return done.copyWith();
|
||||
} catch (e) {
|
||||
final failed = event.copyWith(
|
||||
status: SyncStatus.failed,
|
||||
error: e.toString(),
|
||||
attempts: event.attempts + 1,
|
||||
);
|
||||
_store.updateEvent(failed);
|
||||
return failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
49
lib/data/repositories/transaction_repository_impl.dart
Normal file
49
lib/data/repositories/transaction_repository_impl.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import '../../core/utils/extensions.dart';
|
||||
import '../../domain/entities/transaction.dart';
|
||||
import '../../domain/repositories/transaction_repository.dart';
|
||||
import '../datasources/local_store.dart';
|
||||
|
||||
class TransactionRepositoryImpl implements TransactionRepository {
|
||||
TransactionRepositoryImpl(this._store);
|
||||
|
||||
final LocalStore _store;
|
||||
|
||||
@override
|
||||
Future<SaleTransaction> save(SaleTransaction transaction) async {
|
||||
_store.addTransaction(transaction);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SaleTransaction>> history({int limit = 50}) async =>
|
||||
_store.transactions.take(limit).toList();
|
||||
|
||||
@override
|
||||
Future<SaleTransaction?> findByInvoice(String invoiceNumber) async =>
|
||||
_store.transactions
|
||||
.firstWhereOrNull((t) => t.invoiceNumber == invoiceNumber);
|
||||
|
||||
@override
|
||||
Future<int> nextInvoiceSequence() async => _store.nextInvoiceSequence();
|
||||
|
||||
@override
|
||||
Future<void> park(ParkedBill bill) async => _store.addParked(bill);
|
||||
|
||||
@override
|
||||
Future<List<ParkedBill>> parkedBills() async => _store.parked;
|
||||
|
||||
@override
|
||||
Future<void> removeParked(String id) async => _store.removeParked(id);
|
||||
|
||||
@override
|
||||
Future<double> salesTotalForDay(DateTime day) async {
|
||||
return _store.transactions
|
||||
.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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user