second commit
This commit is contained in:
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