added local db

This commit is contained in:
2026-07-29 13:06:39 +05:30
parent d72522e737
commit d9886880cc
38 changed files with 2895 additions and 2261 deletions

View File

@@ -43,13 +43,13 @@ class CustomerRepositoryImpl implements CustomerRepository {
visitCount: 0,
createdAt: DateTime.now(),
);
_store.putCustomer(created);
await _store.putCustomer(created);
return created;
}
@override
Future<Customer> update(Customer customer) async {
_store.putCustomer(customer);
await _store.putCustomer(customer);
return customer;
}
@@ -72,7 +72,7 @@ class CustomerRepositoryImpl implements CustomerRepository {
visitCount: current.visitCount + 1,
lastVisitAt: DateTime.now(),
);
_store.putCustomer(updated);
await _store.putCustomer(updated);
return updated;
}

View File

@@ -1,8 +1,9 @@
import '../../core/utils/extensions.dart';
import '../../domain/entities/product.dart';
import '../../domain/repositories/product_repository.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 {
ProductRepositoryImpl(this._store);
@@ -19,12 +20,8 @@ class ProductRepositoryImpl implements ProductRepository {
.toList();
@override
Future<Product?> findByBarcode(String barcode) async {
final needle = barcode.trim();
return _store.products.firstWhereOrNull(
(p) => p.barcode == needle && p.isActive,
);
}
Future<Product?> findByBarcode(String barcode) async =>
_store.productByBarcode(barcode);
@override
Future<Product?> findById(String id) async => _store.productById(id);
@@ -34,15 +31,13 @@ class ProductRepositoryImpl implements ProductRepository {
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()
final ranked = _store.products.where((p) => p.isActive && p.matches(q)).toList()
..sort((a, b) => _score(b, q).compareTo(_score(a, q)));
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) {
final lq = q.toLowerCase();
if (p.barcode == q) return 100;
@@ -54,15 +49,9 @@ class ProductRepositoryImpl implements ProductRepository {
}
@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));
});
}
Future<void> decrementStock(Map<String, double> quantities) =>
_store.applyStockMovement(quantities);
@override
Future<void> upsert(Product product) async => _store.putProduct(product);
Future<void> upsert(Product product) => _store.putProduct(product);
}

View File

@@ -3,19 +3,25 @@ import 'package:uuid/uuid.dart';
import '../../core/utils/formatters.dart';
import '../../domain/entities/shift_report.dart';
import '../../domain/entities/sync_event.dart';
import '../../domain/entities/transaction.dart';
import '../../domain/repositories/sync_repository.dart';
import '../datasources/local_store.dart';
import '../datasources/remote_catalogue_source.dart';
import '../local/order_dao.dart';
class SyncRepositoryImpl implements SyncRepository {
SyncRepositoryImpl(this._store, this._catalogue, this._reports);
SyncRepositoryImpl(this._store, this._catalogue, this._orderSink);
final LocalStore _store;
final RemoteCatalogueSource _catalogue;
final RemoteReportSink _reports;
final RemoteOrderSink _orderSink;
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
bool get hasCatalogue => _store.hasCatalogue;
@@ -26,134 +32,216 @@ class SyncRepositoryImpl implements SyncRepository {
String? get catalogueRevision => _store.catalogueRevision;
@override
List<SyncEvent> get events => _store.events;
List<SyncEvent> get events => List.unmodifiable(_events.reversed);
@override
bool get hasUnsyncedEvents => _store.hasUnsyncedEvents;
void _log(SyncEvent e) => _events.add(e);
// ------------------------------------------------------- Morning: import
@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);
final id = _uuid.v4();
final started = DateTime.now();
try {
final snapshot = await _catalogue.fetch(onProgress: onProgress);
_store.importCatalogue(
await _store.importCatalogue(
products: snapshot.products,
customers: snapshot.customers,
revision: snapshot.revision,
at: snapshot.fetchedAt,
);
final done = event.copyWith(
final event = SyncEvent(
id: id,
type: SyncEventType.catalogueImport,
status: SyncStatus.synced,
createdAt: started,
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}',
summary: '${snapshot.products.length} products saved to SQLite '
'· ${snapshot.revision}',
payload: {
'products': snapshot.products.length,
'customers': snapshot.customers.length,
'revision': snapshot.revision,
},
syncedAt: done.syncedAt,
attempts: 1,
);
_store.updateEvent(settled);
return settled;
_log(event);
return event;
} catch (e) {
final failed = event.copyWith(
final event = SyncEvent(
id: id,
type: SyncEventType.catalogueImport,
status: SyncStatus.failed,
createdAt: started,
summary: 'Catalogue import failed',
error: e.toString(),
attempts: 1,
);
_store.updateEvent(failed);
return failed;
_log(event);
return event;
}
}
// --------------------------------------------------- Business hours: read
@override
ShiftReport buildShiftReport({
required DateTime businessDate,
Future<int> unsyncedCount() => _store.orders.unsyncedCount();
@override
Future<List<SaleTransaction>> unsyncedOrders() => _store.orders.unsynced();
@override
Future<ShiftReport> todayReport({
required String terminalId,
required String cashierName,
}) {
}) async {
final today = DateTime.now();
final orders = await _store.orders.forBusinessDate(today);
return ShiftReport.fromTransactions(
transactions: _store.transactions,
businessDate: businessDate,
transactions: orders,
businessDate: today,
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);
Future<List<OrderSyncRow>> orderSyncRows({int limit = 200}) async {
final rows = await _store.orders.syncRows(limit: limit);
return rows
.map((r) => OrderSyncRow(
orderId: r['id']! as String,
invoiceNumber: r['invoice_number']! as String,
total: (r['total']! as num).toDouble(),
createdAt:
DateTime.fromMillisecondsSinceEpoch(r['created_at']! as int),
isSynced: (r['sync_status']! as int) == OrderDao.synced,
syncedAt: r['synced_at'] == null
? null
: DateTime.fromMillisecondsSinceEpoch(r['synced_at']! as int),
attempts: (r['sync_attempts'] as int?) ?? 0,
error: r['sync_error'] as String?,
))
.toList();
}
// ------------------------------------------------------ End of day: sync
@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);
}
Future<SyncOutcome> syncOrders({
void Function(double progress, String stage)? onProgress,
}) async {
onProgress?.call(0.05, 'Collecting unsynced bills…');
/// Sends one event, leaving it queued if the push fails.
Future<SyncEvent> _attempt(SyncEvent event) async {
_store.updateEvent(event.copyWith(status: SyncStatus.syncing));
final pending = await _store.orders.unsynced();
if (pending.isEmpty) {
onProgress?.call(1, 'Nothing to upload');
return const SyncOutcome(attempted: 0, uploaded: 0);
}
final started = DateTime.now();
final ids = pending.map((o) => o.id).toList();
onProgress?.call(0.35, 'Uploading ${pending.length} bills…');
try {
await _reports.push(event.payload);
final done = event.copyWith(
final accepted = await _orderSink.pushOrders(
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,
createdAt: started,
syncedAt: DateTime.now(),
attempts: event.attempts + 1,
clearError: true,
);
_store.updateEvent(done);
return done.copyWith();
summary: '${accepted.length} of ${pending.length} bills uploaded',
attempts: 1,
));
return SyncOutcome(attempted: pending.length, uploaded: accepted.length);
} 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,
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(),
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,
},
],
};
}

View File

@@ -1,8 +1,8 @@
import '../../core/utils/extensions.dart';
import '../../domain/entities/transaction.dart';
import '../../domain/repositories/transaction_repository.dart';
import '../datasources/local_store.dart';
/// All bill persistence goes straight to SQLite.
class TransactionRepositoryImpl implements TransactionRepository {
TransactionRepositoryImpl(this._store);
@@ -10,40 +10,33 @@ class TransactionRepositoryImpl implements TransactionRepository {
@override
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;
}
@override
Future<List<SaleTransaction>> history({int limit = 50}) async =>
_store.transactions.take(limit).toList();
Future<List<SaleTransaction>> history({int limit = 50}) =>
_store.orders.recent(limit: limit);
@override
Future<SaleTransaction?> findByInvoice(String invoiceNumber) async =>
_store.transactions
.firstWhereOrNull((t) => t.invoiceNumber == invoiceNumber);
Future<SaleTransaction?> findByInvoice(String invoiceNumber) =>
_store.orders.byInvoice(invoiceNumber);
@override
Future<int> nextInvoiceSequence() async => _store.nextInvoiceSequence();
Future<int> nextInvoiceSequence() => _store.catalogue.nextInvoiceSequence();
@override
Future<void> park(ParkedBill bill) async => _store.addParked(bill);
Future<void> park(ParkedBill bill) => _store.orders.park(bill);
@override
Future<List<ParkedBill>> parkedBills() async => _store.parked;
Future<List<ParkedBill>> parkedBills() => _store.orders.parkedBills();
@override
Future<void> removeParked(String id) async => _store.removeParked(id);
Future<void> removeParked(String id) => _store.orders.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;
}
Future<double> salesTotalForDay(DateTime day) =>
_store.orders.salesTotalForDay(day);
}