Pull the catalogue from a real endpoint, with delta sync

Replaces the last simulation on the inbound side. RemoteCatalogueSource
returned SeedData after a fake progress bar; there was no wire format, no
endpoint, and no way to receive an update short of reinstalling.

Wire format (data/remote/catalogue_wire.dart)
- Tolerant where it should be: a catalogue of 4,000 products must not fail to
  import over one absent emoji, so optional fields take defaults and an
  unrecognised category files under Grocery — the item still scans, prices and
  bills.
- Strict where it matters: no id, name, barcode or price and the import fails.
  A silently dropped product is a shelf item that scans to nothing, discovered
  with a queue waiting.
- GST accepts 18 or 0.18 and reads both the same. Back offices disagree about
  which they mean, and getting it wrong silently changes the tax on every line.

HTTP source with paging and deltas
- GET {base}/catalogue?since={revision}&page={n}. Paged because a supermarket
  catalogue is tens of thousands of rows: one response times out on a shop's
  line and stalls the UI decoding it. Capped at 200 pages so a bad deployment
  cannot become an infinite request loop against a shop's connection.
- `since` carries the revision already held, so a normal morning fetches a
  handful of price changes rather than the whole book. A server that cannot do
  deltas ignores it and answers is_delta:false — the terminal reads the flag
  rather than assuming, so both work.
- A bad credential is non-retryable and says so, leaving the working catalogue
  in place so billing continues.

Applying deltas without losing local state
- A full snapshot withdraws what it omits; a delta must not. Read as a
  snapshot, the first morning price change would empty the shelf.
- Retired products are marked inactive, not deleted — order lines already
  recorded point at them, and a hard delete would orphan a bill's history.
- Locally registered shoppers survive a pull, as before.
- The unsynced-stock replay is now scoped to the products the pull actually
  overwrote. It exists because a server count predates local sales; running it
  over a delta that never carried that product would subtract those units a
  second time and quietly empty a shelf that is full. Both halves of that rule
  are tested.

MQTT stays the nudge, not the transport: a catalogue push on
pos/{store}/catalogue makes every terminal pull immediately, but the rows come
over HTTP, because a broker is the wrong shape for tens of thousands of them.

Tests: 210 -> 234. docs/sync-contract.md now covers both directions, including
a field-by-field table of what happens when something is missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-08-01 15:37:42 +05:30
parent 46d354ced1
commit fbfc02d140
13 changed files with 1259 additions and 111 deletions

View File

@@ -157,6 +157,70 @@ nothing is marked synced, and the batch goes again.
response. Carries an `idempotency-key` header that is stable across retries of response. Carries an `idempotency-key` header that is stable across retries of
the same bills. the same bills.
## Catalogue pull
The other direction: products and customers coming down.
```
GET {base}/catalogue?since={revision}&page={n}&store_id=…&terminal_id=…
Authorization: Bearer {apiKey}
```
```json
{
"revision": "rev-8821",
"is_delta": true,
"has_more": false,
"products": [ { "id": "…", "name": "…", "barcode": "…", "price": 62.0, } ],
"customers": [ { "id": "…", "name": "…", "mobile": "…", } ],
"retired_product_ids": ["sku-9912"]
}
```
**Paged.** A supermarket catalogue is tens of thousands of rows; one response
times out on a shop's line and stalls the UI while it decodes. Answer
`has_more: true` and the terminal asks for the next page, up to 200 — past that
it stops rather than looping against the shop's connection.
**`since` is the revision the terminal already holds.** Answer with what has
moved and set `is_delta: true`. On a normal morning that is a handful of price
changes rather than the whole book. A server that cannot do deltas ignores the
parameter and answers `is_delta: false`; the terminal reads the flag rather
than assuming, so both work.
**The flag matters more than it looks.** A full snapshot withdraws every product
it does not mention. A delta must not — read as a snapshot, the first morning
price change would empty the shelf. Withdraw items in a delta with
`retired_product_ids`; the terminal marks them inactive rather than deleting,
because order lines already recorded point at them.
**Send stock only when you mean it.** Any product in the payload has its count
overwritten by the server's figure, which predates sales this terminal has rung
but not yet uploaded. The terminal replays those sales — but only for products
the payload actually carried. A delta that ships a stale count for an untouched
product will quietly empty a shelf that is full.
### Field handling
| Field | Missing | Notes |
|---|---|---|
| `id`, `name`, `barcode`, `price` | **import fails** | A dropped product is a shelf item that scans to nothing |
| `sku` | falls back to `id` | |
| `stock` | `0` | Means "not tracked" |
| `gst_rate` | 18% | Accepts `18` or `0.18` — both read the same |
| `category` | Grocery | An unrecognised one also falls back; the item still sells |
| `unit` | piece | Matched by name or symbol |
| `is_active` | `true` | An omitted flag is not a withdrawn catalogue |
Dates are ISO 8601 or epoch milliseconds; both are accepted.
### Pushing a change mid-day
Publish to `pos/{store}/catalogue` (retained) and every terminal in the shop
pulls immediately instead of waiting for tomorrow morning. The message body is
only a nudge — the catalogue itself still comes over HTTP, because a broker is
the wrong shape for tens of thousands of rows.
## Retention on the terminal ## Retention on the terminal
Accepted bills stay for 7 days (`OrderDao.retentionWindow`) so a batch the Accepted bills stay for 7 days (`OrderDao.retentionWindow`) so a batch the
@@ -181,3 +245,7 @@ overstate the day.
- **Historical correction.** Bills already synced by an older build went up - **Historical correction.** Bills already synced by an older build went up
with an overstated total. Nothing here fixes that; it needs a server-side with an overstated total. Nothing here fixes that; it needs a server-side
reconciliation against `bill_discount`. reconciliation against `bill_discount`.
- **Pushing customers upward.** A shopper registered at the till stays on that
terminal and rides along on the bills they appear on. There is no
customer-create endpoint yet, so two terminals registering the same mobile
number will each hold their own row until the back office reconciles them.

View File

@@ -7,7 +7,9 @@ import '../core/services/sound_service.dart';
import '../data/datasources/local_store.dart'; import '../data/datasources/local_store.dart';
import '../data/repositories/customer_repository_impl.dart'; import '../data/repositories/customer_repository_impl.dart';
import '../data/repositories/product_repository_impl.dart'; import '../data/repositories/product_repository_impl.dart';
import '../data/datasources/remote_catalogue_source.dart'; import '../data/remote/catalogue_source.dart';
import '../data/remote/http_catalogue_source.dart';
import '../data/remote/simulated_catalogue_source.dart';
import '../data/remote/http_order_transport.dart'; import '../data/remote/http_order_transport.dart';
import '../data/remote/mqtt_order_transport.dart'; import '../data/remote/mqtt_order_transport.dart';
import '../data/remote/order_transport.dart'; import '../data/remote/order_transport.dart';
@@ -48,13 +50,31 @@ final transactionRepositoryProvider = Provider<TransactionRepository>(
/// purpose, which reads as a real network fault. /// purpose, which reads as a real network fault.
final simulateOfflineProvider = StateProvider<bool>((ref) => false); final simulateOfflineProvider = StateProvider<bool>((ref) => false);
/// Simulated back-office endpoints. Held as singletons so the offline toggle /// Where products and customers come from.
/// in Settings affects every call. ///
final remoteCatalogueProvider = Provider<RemoteCatalogueSource>( /// Rebuilt when the route changes, and the old one closed, so a re-pointed
(ref) => RemoteCatalogueSource( /// terminal does not keep a stale client alive.
isOffline: () => ref.read(simulateOfflineProvider), final catalogueSourceProvider = Provider<CatalogueSource>((ref) {
), final config = ref.watch(syncConfigProvider);
);
final source = switch (config.transport) {
// MQTT carries the *notification* that the catalogue moved; the catalogue
// itself is a bulk pull, which is an HTTP job. A broker is the wrong shape
// for tens of thousands of rows.
TransportKind.http || TransportKind.mqtt =>
config.httpBaseUrl.isEmpty
? SimulatedCatalogueSource(
isOffline: () => ref.read(simulateOfflineProvider),
)
: HttpCatalogueSource(config: config),
TransportKind.simulated => SimulatedCatalogueSource(
isOffline: () => ref.read(simulateOfflineProvider),
),
};
ref.onDispose(source.dispose);
return source;
});
// ------------------------------------------------------------------- Sync // ------------------------------------------------------------------- Sync
/// How this terminal reaches the back office. /// How this terminal reaches the back office.
@@ -102,7 +122,7 @@ final orderTransportProvider = Provider<OrderTransport>((ref) {
final syncRepositoryProvider = Provider<SyncRepository>( final syncRepositoryProvider = Provider<SyncRepository>(
(ref) => SyncRepositoryImpl( (ref) => SyncRepositoryImpl(
ref.watch(localStoreProvider), ref.watch(localStoreProvider),
ref.watch(remoteCatalogueProvider), ref.watch(catalogueSourceProvider),
ref.watch(orderTransportProvider), ref.watch(orderTransportProvider),
batchSize: ref.watch(syncConfigProvider).batchSize, batchSize: ref.watch(syncConfigProvider).batchSize,
), ),

View File

@@ -149,13 +149,41 @@ class LocalStore {
required List<Customer> customers, required List<Customer> customers,
required String revision, required String revision,
required DateTime at, required DateTime at,
bool isDelta = false,
List<String> retiredProductIds = const [],
}) async { }) async {
await catalogue.replaceCatalogue(products: products, customers: customers); if (isDelta) {
// A change set must not withdraw what it does not mention. Applied as a
// full snapshot, the first morning price change would empty the shelf.
await catalogue.applyCatalogueDelta(
products: products,
customers: customers,
retiredProductIds: retiredProductIds,
);
} else {
await catalogue.replaceCatalogue(
products: products,
customers: customers,
);
}
// The server's stock figure predates any sale this terminal has made but // The server's stock figure predates any sale this terminal has made but
// not yet uploaded, so those units would reappear on the shelf. Replay them // not yet uploaded, so those units would reappear on the shelf. Replay them
// before anyone can bill against the inflated count. // before anyone can bill against the inflated count.
final committed = await orders.unsyncedStockCommitments(); //
// Scoped to the products the pull actually overwrote. A full snapshot
// rewrites every row, so the replay covers everything; a delta rewrites
// only what it carried, and replaying the rest would subtract those units a
// second time from a count that was never reset — quietly emptying a shelf
// that is full.
var committed = await orders.unsyncedStockCommitments();
if (isDelta) {
final touched = products.map((p) => p.id).toSet();
committed = {
for (final entry in committed.entries)
if (touched.contains(entry.key)) entry.key: entry.value,
};
}
if (committed.isNotEmpty) { if (committed.isNotEmpty) {
await catalogue.decrementStock(committed); await catalogue.decrementStock(committed);
} }

View File

@@ -1,86 +0,0 @@
import '../../domain/entities/customer.dart';
import '../../domain/entities/product.dart';
import 'local_store.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({required this.isOffline}) {
LocalStore.registerSeed(
products: SeedData.products,
customers: SeedData.customers,
);
}
/// Reads the Settings switch on every call.
///
/// Deliberately a callback rather than a stored bool: a copied flag can fall
/// out of step with the switch, which makes the terminal behave as offline
/// while showing that it is not.
final bool Function() isOffline;
/// 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 (isOffline()) {
throw const CatalogueSyncException(
'Simulate offline is ON in Settings, so the catalogue pull was '
'failed on purpose. Turn it off to import.',
);
}
onProgress?.call(progress, stage);
}
return CatalogueSnapshot(
products: SeedData.products(),
customers: SeedData.customers(),
fetchedAt: DateTime.now(),
revision: 'rev-${DateTime.now().millisecondsSinceEpoch % 100000}',
);
}
}

View File

@@ -173,6 +173,60 @@ class CatalogueDao {
}); });
} }
/// Applies a change set, leaving everything it does not mention alone.
///
/// The counterpart to [replaceCatalogue], and the difference matters: a full
/// snapshot withdraws anything it omits, a delta must not. Reading a delta as
/// if it were a snapshot would empty the shelf on the first morning price
/// change.
///
/// Stock is deliberately *not* taken from a delta unless the back office
/// sends it. A price change that carried a stale count would undo every sale
/// the terminal has rung since the last pull.
Future<void> applyCatalogueDelta({
required List<Product> products,
required List<Customer> customers,
List<String> retiredProductIds = const [],
}) async {
await _db.transaction((txn) async {
final batch = txn.batch();
for (final id in retiredProductIds) {
// Withdrawn rather than deleted: an order line already recorded points
// at this product, and a hard delete would orphan a bill's history.
batch.update(
Tables.products,
{
'is_active': 0,
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
where: 'id = ?',
whereArgs: [id],
);
}
for (final p in products) {
batch.insert(
Tables.products,
productToRow(p),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
// As in a full pull: locally registered shoppers must survive, so an
// existing row is left alone rather than overwritten.
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. /// Applies stock movement after a sale, clamped at zero.
Future<void> decrementStock(Map<String, double> quantities) async { Future<void> decrementStock(Map<String, double> quantities) async {
if (quantities.isEmpty) return; if (quantities.isEmpty) return;

View File

@@ -0,0 +1,77 @@
import '../../domain/entities/customer.dart';
import '../../domain/entities/product.dart';
/// What one catalogue pull returned.
class CatalogueSnapshot {
const CatalogueSnapshot({
required this.products,
required this.customers,
required this.fetchedAt,
required this.revision,
this.isDelta = false,
this.retiredProductIds = const [],
});
final List<Product> products;
final List<Customer> customers;
final DateTime fetchedAt;
/// Server-side catalogue version, so the cashier can tell whether a
/// re-import actually changed anything, and so the next pull can ask for
/// only what has moved since.
final String revision;
/// Whether this is a change set rather than the whole catalogue.
///
/// The distinction matters at the point of applying it: a full snapshot
/// withdraws anything it does not mention, a delta must not.
final bool isDelta;
/// Products the back office has withdrawn. Only meaningful on a delta —
/// a full snapshot expresses the same thing by omission.
final List<String> retiredProductIds;
bool get isEmpty =>
products.isEmpty && customers.isEmpty && retiredProductIds.isEmpty;
int get changeCount =>
products.length + customers.length + retiredProductIds.length;
}
/// Raised when the catalogue cannot be pulled.
class CatalogueSyncException implements Exception {
const CatalogueSyncException(this.message, {this.retryable = true});
final String message;
/// False for a bad credential or an unconfigured endpoint — retrying cannot
/// fix either, and the cashier needs to be told rather than watched to spin.
final bool retryable;
@override
String toString() => message;
}
/// Where the terminal gets its products and customers.
///
/// The counterpart to `OrderTransport`: that one is how bills leave, this is
/// how the catalogue arrives. Same shape, for the same reason — the repository
/// should not know or care whether the answer came from a real endpoint or a
/// local stub.
abstract class CatalogueSource {
/// Shown in the events log, so a cashier reporting a problem can say which
/// route the terminal was using.
String get label;
/// Pulls the catalogue.
///
/// Passing [since] asks for only what has changed, and an implementation
/// that cannot do deltas is free to ignore it and return everything — the
/// caller checks [CatalogueSnapshot.isDelta] rather than assuming.
Future<CatalogueSnapshot> fetch({
String? since,
void Function(double progress, String stage)? onProgress,
});
void dispose() {}
}

View File

@@ -0,0 +1,178 @@
import '../../core/constants/app_constants.dart';
import '../../domain/entities/customer.dart';
import '../../domain/entities/product.dart';
/// Raised when the back office sends something this build cannot read.
class CatalogueFormatException implements Exception {
const CatalogueFormatException(this.message);
final String message;
@override
String toString() => message;
}
/// Translates between the back office's JSON and the terminal's entities.
///
/// Deliberately tolerant in one direction and strict in the other. A missing
/// optional field takes a sensible default, because a catalogue of 4,000
/// products should not fail to import over one absent emoji. A missing
/// *required* field throws, because a product with no price or no barcode
/// cannot be sold and silently dropping it would leave a shelf item that
/// scans to nothing.
class CatalogueWire {
const CatalogueWire._();
// ------------------------------------------------------------- Products
static Product productFromJson(Map<String, Object?> json) {
final id = _requireString(json, 'id');
return Product(
id: id,
name: _requireString(json, 'name', context: id),
barcode: _requireString(json, 'barcode', context: id),
sku: (json['sku'] as String?) ?? id,
category: _category(json['category']),
price: _requireNumber(json, 'price', context: id),
// Absent stock means "not tracked", not "none left" — a terminal that
// read it as zero would refuse to sell the item at all.
stock: (json['stock'] as num?)?.toDouble() ?? 0,
mrp: (json['mrp'] as num?)?.toDouble(),
emoji: (json['emoji'] as String?) ?? '📦',
imageUrl: json['image_url'] as String?,
unit: _unit(json['unit']),
gstRate: _gstRate(json['gst_rate']),
hsnCode: json['hsn_code'] as String?,
brand: json['brand'] as String?,
// Absent means active. A back office that omits the flag is not saying
// its whole catalogue is withdrawn.
isActive: json['is_active'] as bool? ?? true,
);
}
static Map<String, Object?> productToJson(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,
'hsn_code': p.hsnCode,
'brand': p.brand,
'is_active': p.isActive,
};
// ------------------------------------------------------------ Customers
static Customer customerFromJson(Map<String, Object?> json) {
final id = _requireString(json, 'id');
return Customer(
id: id,
name: _requireString(json, 'name', context: id),
mobile: _requireString(json, 'mobile', context: id),
email: json['email'] as String?,
gender: Gender.values
.where((g) => g.name == json['gender'])
.firstOrNull ??
Gender.unspecified,
dateOfBirth: _date(json['date_of_birth']),
loyaltyPoints: (json['loyalty_points'] as num?)?.toInt() ?? 0,
lifetimeSpend: (json['lifetime_spend'] as num?)?.toDouble() ?? 0,
visitCount: (json['visit_count'] as num?)?.toInt() ?? 0,
createdAt: _date(json['created_at']),
lastVisitAt: _date(json['last_visit_at']),
);
}
static Map<String, Object?> customerToJson(Customer c) => {
'id': c.id,
'name': c.name,
'mobile': c.mobile,
'email': c.email,
'gender': c.gender.name,
'date_of_birth': c.dateOfBirth?.toIso8601String(),
'loyalty_points': c.loyaltyPoints,
'lifetime_spend': c.lifetimeSpend,
'visit_count': c.visitCount,
'created_at': c.createdAt?.toIso8601String(),
'last_visit_at': c.lastVisitAt?.toIso8601String(),
};
// ------------------------------------------------------------ Internals
static String _requireString(
Map<String, Object?> json,
String key, {
String? context,
}) {
final value = json[key];
if (value is String && value.trim().isNotEmpty) return value.trim();
throw CatalogueFormatException(
'Product or customer${context == null ? '' : ' $context'} has no "$key". '
'The terminal cannot sell or identify a record without it.',
);
}
static double _requireNumber(
Map<String, Object?> json,
String key, {
String? context,
}) {
final value = json[key];
if (value is num) return value.toDouble();
throw CatalogueFormatException(
'Product${context == null ? '' : ' $context'} has no numeric "$key".',
);
}
/// Falls back rather than throwing.
///
/// A category the terminal does not recognise is a display problem — the
/// item still scans, still prices, still bills. Refusing the whole import
/// over one would be a far worse outcome than filing it under Grocery.
static ProductCategory _category(Object? raw) {
if (raw is! String) return ProductCategory.grocery;
final needle = raw.trim().toLowerCase();
return ProductCategory.values.firstWhere(
(c) => c.name.toLowerCase() == needle || c.label.toLowerCase() == needle,
orElse: () => ProductCategory.grocery,
);
}
static UnitOfMeasure _unit(Object? raw) {
if (raw is! String) return UnitOfMeasure.piece;
final needle = raw.trim().toLowerCase();
return UnitOfMeasure.values.firstWhere(
(u) =>
u.name.toLowerCase() == needle ||
u.symbol.toLowerCase() == needle,
orElse: () => UnitOfMeasure.piece,
);
}
/// Accepts a fraction (0.18) or a percentage (18), because back offices
/// disagree about which they mean and getting it wrong silently changes the
/// tax on every line.
static double _gstRate(Object? raw) {
if (raw is! num) return AppConstants.defaultGstRate;
final value = raw.toDouble();
if (value < 0) return AppConstants.defaultGstRate;
return value > 1 ? value / 100 : value;
}
static DateTime? _date(Object? raw) {
if (raw == null) return null;
if (raw is num) return DateTime.fromMillisecondsSinceEpoch(raw.toInt());
if (raw is String) return DateTime.tryParse(raw);
return null;
}
}

View File

@@ -0,0 +1,205 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../core/config/sync_config.dart';
import '../../domain/entities/customer.dart';
import '../../domain/entities/product.dart';
import 'catalogue_source.dart';
import 'catalogue_wire.dart';
/// Pulls the catalogue from the back office over HTTP.
///
/// ```
/// GET {base}/catalogue?since={revision}&page={n}
/// Authorization: Bearer {apiKey}
/// ```
///
/// ```json
/// {
/// "revision": "rev-8821",
/// "is_delta": true,
/// "has_more": false,
/// "products": [ … ],
/// "customers": [ … ],
/// "retired_product_ids": ["sku-9912"]
/// }
/// ```
///
/// ### Why paged
///
/// A supermarket catalogue is tens of thousands of rows. Asking for it in one
/// response means a request that times out on a shop's line and a JSON decode
/// that stalls the UI thread for seconds. Pages arrive, get counted, and the
/// progress bar moves — which is also the difference between a cashier waiting
/// and a cashier restarting the app.
///
/// ### Why deltas
///
/// `since` carries the revision the terminal already holds. The back office
/// answers with what has moved since, which on a normal morning is a handful of
/// price changes rather than the whole book. A server that cannot do deltas
/// ignores the parameter and answers `is_delta: false`; the terminal reads the
/// flag rather than assuming, so both work.
class HttpCatalogueSource implements CatalogueSource {
HttpCatalogueSource({required this.config, http.Client? client})
: _client = client ?? http.Client();
final SyncConfig config;
final http.Client _client;
/// Guards against a server that always answers `has_more: true`. Without it
/// a bad deployment turns into an infinite request loop against a shop's
/// connection.
static const int maxPages = 200;
static const Duration _timeout = Duration(seconds: 30);
@override
String get label => 'HTTP ${config.httpBaseUrl}';
@override
Future<CatalogueSnapshot> fetch({
String? since,
void Function(double progress, String stage)? onProgress,
}) async {
if (config.httpBaseUrl.isEmpty) {
throw const CatalogueSyncException(
'No back-office URL is configured for this terminal. Set one in '
'Settings → Connectivity → Configure.',
retryable: false,
);
}
final products = <Product>[];
final customers = <Customer>[];
final retired = <String>[];
var revision = since ?? '';
var isDelta = false;
var page = 1;
onProgress?.call(0.05, 'Contacting the back office…');
while (page <= maxPages) {
final body = await _fetchPage(since: since, page: page);
revision = (body['revision'] as String?) ?? revision;
isDelta = body['is_delta'] as bool? ?? false;
products.addAll(
_decodeList(body['products'], CatalogueWire.productFromJson),
);
customers.addAll(
_decodeList(body['customers'], CatalogueWire.customerFromJson),
);
retired.addAll(
(body['retired_product_ids'] as List<Object?>? ?? const [])
.whereType<String>(),
);
final hasMore = body['has_more'] as bool? ?? false;
if (!hasMore) break;
page++;
// The total is unknown until the last page, so this walks towards 0.9
// instead of pretending to know how far along it is.
onProgress?.call(
(0.1 + page * 0.05).clamp(0.1, 0.9),
'${products.length} products…',
);
}
if (page > maxPages) {
throw const CatalogueSyncException(
'The back office kept asking for another page past $maxPages. '
'Stopping rather than looping — nothing was changed on this terminal.',
);
}
onProgress?.call(1, 'Writing to local storage…');
return CatalogueSnapshot(
products: products,
customers: customers,
fetchedAt: DateTime.now(),
revision: revision.isEmpty ? 'rev-unknown' : revision,
isDelta: isDelta,
retiredProductIds: retired,
);
}
Future<Map<String, Object?>> _fetchPage({
required String? since,
required int page,
}) async {
final uri = Uri.parse('${config.httpBaseUrl}/catalogue').replace(
queryParameters: {
if (since != null && since.isNotEmpty) 'since': since,
'page': '$page',
'store_id': config.storeId,
'terminal_id': config.terminalId,
},
);
http.Response response;
try {
response = await _client.get(
uri,
headers: {
'accept': 'application/json',
if (config.apiKey != null) 'authorization': 'Bearer ${config.apiKey}',
},
).timeout(_timeout);
} on Exception catch (e) {
throw CatalogueSyncException('Could not reach the back office: $e');
}
if (response.statusCode == 401 || response.statusCode == 403) {
throw CatalogueSyncException(
'The back office rejected this terminal\'s credentials '
'(${response.statusCode}). The catalogue already on this terminal is '
'untouched, so billing continues.',
retryable: false,
);
}
if (response.statusCode >= 300) {
throw CatalogueSyncException(
'Back office returned ${response.statusCode}: ${_trim(response.body)}',
);
}
try {
return jsonDecode(response.body) as Map<String, Object?>;
} on Object {
throw CatalogueSyncException(
'The back office answered with something this terminal could not '
'read: ${_trim(response.body)}',
);
}
}
/// Decodes a list, letting one bad record fail the import rather than
/// silently vanish.
///
/// A dropped product is a shelf item that scans to nothing, which a cashier
/// discovers with a queue waiting. Better to refuse the import and leave the
/// working catalogue in place.
static List<T> _decodeList<T>(
Object? raw,
T Function(Map<String, Object?>) decode,
) {
if (raw is! List) return const [];
return raw
.whereType<Map<String, Object?>>()
.map(decode)
.toList(growable: false);
}
static String _trim(String body) =>
body.length <= 200 ? body : '${body.substring(0, 200)}';
@override
void dispose() => _client.close();
}

View File

@@ -0,0 +1,55 @@
import '../datasources/seed_data.dart';
import 'catalogue_source.dart';
/// Stands in for the back office when no endpoint is configured.
///
/// Keeps a fresh install demonstrable: products exist, a shift can be rehearsed
/// end to end, and the offline switch in Settings fails the pull exactly the
/// way a dead line would.
class SimulatedCatalogueSource implements CatalogueSource {
const SimulatedCatalogueSource({required this.isOffline});
/// Read on every call rather than copied, so the Settings switch takes effect
/// immediately instead of at the next restart.
final bool Function() isOffline;
@override
String get label => 'Simulated';
@override
Future<CatalogueSnapshot> fetch({
String? since,
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 (isOffline()) {
throw const CatalogueSyncException(
'Simulate offline is ON in Settings, so the catalogue pull was '
'failed on purpose. Turn it off to import.',
);
}
onProgress?.call(progress, stage);
}
return CatalogueSnapshot(
products: SeedData.products(),
customers: SeedData.customers(),
fetchedAt: DateTime.now(),
revision: 'rev-${DateTime.now().millisecondsSinceEpoch % 100000}',
);
}
@override
void dispose() {}
}

View File

@@ -8,8 +8,8 @@ import '../../domain/entities/sync_event.dart';
import '../../domain/entities/transaction.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 '../local/order_dao.dart'; import '../local/order_dao.dart';
import '../remote/catalogue_source.dart';
import '../remote/order_transport.dart'; import '../remote/order_transport.dart';
class SyncRepositoryImpl implements SyncRepository { class SyncRepositoryImpl implements SyncRepository {
@@ -21,7 +21,7 @@ class SyncRepositoryImpl implements SyncRepository {
}); });
final LocalStore _store; final LocalStore _store;
final RemoteCatalogueSource _catalogue; final CatalogueSource _catalogue;
final OrderTransport _transport; final OrderTransport _transport;
/// Bills per publish. Kept modest because an MQTT broker will refuse an /// Bills per publish. Kept modest because an MQTT broker will refuse an
@@ -57,13 +57,21 @@ class SyncRepositoryImpl implements SyncRepository {
final started = DateTime.now(); final started = DateTime.now();
try { try {
final snapshot = await _catalogue.fetch(onProgress: onProgress); // Carries the revision this terminal already holds, so the back office
// can answer with just what has moved. On a normal morning that is a
// handful of price changes rather than the whole book.
final snapshot = await _catalogue.fetch(
since: _store.catalogueRevision,
onProgress: onProgress,
);
await _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,
isDelta: snapshot.isDelta,
retiredProductIds: snapshot.retiredProductIds,
); );
final event = SyncEvent( final event = SyncEvent(
@@ -72,12 +80,20 @@ class SyncRepositoryImpl implements SyncRepository {
status: SyncStatus.synced, status: SyncStatus.synced,
createdAt: started, createdAt: started,
syncedAt: DateTime.now(), syncedAt: DateTime.now(),
summary: '${snapshot.products.length} products saved to SQLite ' summary: snapshot.isDelta
'· ${snapshot.revision}', ? (snapshot.isEmpty
? 'Already up to date · ${snapshot.revision}'
: '${snapshot.changeCount} changes applied '
'· ${snapshot.revision}')
: '${snapshot.products.length} products saved to SQLite '
'· ${snapshot.revision}',
payload: { payload: {
'products': snapshot.products.length, 'products': snapshot.products.length,
'customers': snapshot.customers.length, 'customers': snapshot.customers.length,
'retired': snapshot.retiredProductIds.length,
'delta': snapshot.isDelta,
'revision': snapshot.revision, 'revision': snapshot.revision,
'via': _catalogue.label,
}, },
attempts: 1, attempts: 1,
); );

View File

@@ -0,0 +1,533 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:nearle_pos/core/config/sync_config.dart';
import 'package:nearle_pos/data/datasources/local_store.dart';
import 'package:nearle_pos/data/datasources/seed_data.dart';
import 'package:nearle_pos/data/local/app_database.dart';
import 'package:nearle_pos/data/remote/catalogue_source.dart';
import 'package:nearle_pos/data/remote/catalogue_wire.dart';
import 'package:nearle_pos/data/remote/http_catalogue_source.dart';
import 'package:nearle_pos/data/repositories/product_repository_impl.dart';
import 'package:nearle_pos/data/repositories/customer_repository_impl.dart';
import 'package:nearle_pos/data/repositories/transaction_repository_impl.dart';
import 'package:nearle_pos/domain/entities/cart.dart';
import 'package:nearle_pos/domain/entities/customer.dart';
import 'package:nearle_pos/domain/entities/product.dart';
import 'package:nearle_pos/domain/entities/transaction.dart';
import 'package:nearle_pos/domain/usecases/checkout_sale.dart';
const _config = SyncConfig(
transport: TransportKind.http,
httpBaseUrl: 'https://api.example.com',
apiKey: 'k',
storeId: 'store-01',
terminalId: 'T4A9',
);
Map<String, Object?> _product(String id, {double price = 50}) => {
'id': id,
'name': 'Product $id',
'barcode': 'bc-$id',
'sku': 'sku-$id',
'category': 'grocery',
'price': price,
'stock': 20,
'gst_rate': 0.05,
};
void main() {
group('wire format', () {
test('a full record round-trips', () {
final product = CatalogueWire.productFromJson({
..._product('p1'),
'mrp': 60.0,
'emoji': '🍞',
'unit': 'kilogram',
'hsn_code': '1905',
'brand': 'Nearle',
'is_active': false,
});
expect(product.id, 'p1');
expect(product.mrp, 60);
expect(product.unit, UnitOfMeasure.kilogram);
expect(product.hsnCode, '1905');
expect(product.isActive, isFalse);
final json = CatalogueWire.productToJson(product);
expect(CatalogueWire.productFromJson(json), product);
});
test('missing optional fields take sensible defaults', () {
// A catalogue of 4,000 products must not fail to import over one absent
// emoji.
final product = CatalogueWire.productFromJson({
'id': 'p1',
'name': 'Bread',
'barcode': '890',
'price': 40.0,
});
expect(product.sku, 'p1', reason: 'falls back to the id');
expect(product.emoji, '📦');
expect(product.unit, UnitOfMeasure.piece);
expect(product.isActive, isTrue,
reason: 'an omitted flag is not a withdrawn catalogue',);
});
test('a record with no price or barcode is refused, not dropped', () {
// A silently dropped product is a shelf item that scans to nothing,
// discovered with a queue waiting.
expect(
() => CatalogueWire.productFromJson({'id': 'p1', 'name': 'X'}),
throwsA(isA<CatalogueFormatException>()),
);
expect(
() => CatalogueWire.productFromJson({
'id': 'p1',
'name': 'X',
'barcode': '890',
}),
throwsA(isA<CatalogueFormatException>()),
);
});
test('a GST rate is read the same whether sent as 18 or 0.18', () {
// Back offices disagree about which they mean, and getting it wrong
// silently changes the tax on every line.
double rate(Object? raw) => CatalogueWire.productFromJson({
..._product('p1'),
'gst_rate': raw,
}).gstRate;
expect(rate(18), 0.18);
expect(rate(0.18), 0.18);
expect(rate(5), 0.05);
expect(rate(0), 0);
});
test('an unknown category files under Grocery rather than failing', () {
// The item still scans, still prices, still bills. Refusing the whole
// import over a display detail would be far worse.
final product = CatalogueWire.productFromJson({
..._product('p1'),
'category': 'frozen-desserts',
});
expect(product.category, ProductCategory.grocery);
});
test('a category is matched by name or by label', () {
ProductCategory of(String raw) => CatalogueWire.productFromJson({
..._product('p1'),
'category': raw,
}).category;
expect(of('personalCare'), ProductCategory.personalCare);
expect(of('Personal Care'), ProductCategory.personalCare);
expect(of('BEVERAGES'), ProductCategory.beverages);
});
test('customers carry their loyalty balance across', () {
final customer = CatalogueWire.customerFromJson({
'id': 'c1',
'name': 'Meena',
'mobile': '9840000001',
'loyalty_points': 420,
'lifetime_spend': 61000.0,
'last_visit_at': '2026-07-30T10:00:00.000Z',
});
expect(customer.loyaltyPoints, 420);
expect(customer.tier, MembershipTier.gold);
expect(customer.lastVisitAt, isNotNull);
});
test('a date is accepted as ISO text or epoch milliseconds', () {
DateTime? born(Object? raw) => CatalogueWire.customerFromJson({
'id': 'c1',
'name': 'M',
'mobile': '98',
'date_of_birth': raw,
}).dateOfBirth;
expect(born('1990-05-02'), DateTime(1990, 5, 2));
expect(born(641606400000), isNotNull);
expect(born('not a date'), isNull);
expect(born(null), isNull);
});
});
group('http source', () {
HttpCatalogueSource sourceReturning(
List<Map<String, Object?>> pages, {
List<Uri>? record,
}) {
var index = 0;
return HttpCatalogueSource(
config: _config,
client: _FakeClient((request) {
record?.add(request.url);
final body = pages[index < pages.length ? index : pages.length - 1];
index++;
return http.Response(jsonEncode(body), 200);
}),
);
}
test('a single page is read straight through', () async {
final source = sourceReturning([
{
'revision': 'rev-1',
'is_delta': false,
'has_more': false,
'products': [_product('p1'), _product('p2')],
'customers': [
{'id': 'c1', 'name': 'Meena', 'mobile': '9840000001'},
],
},
]);
final snapshot = await source.fetch();
expect(snapshot.products, hasLength(2));
expect(snapshot.customers, hasLength(1));
expect(snapshot.revision, 'rev-1');
expect(snapshot.isDelta, isFalse);
});
test('every page is followed until has_more stops', () async {
// A supermarket catalogue is tens of thousands of rows; a single response
// times out on a shop's line.
final source = sourceReturning([
{
'revision': 'rev-2',
'has_more': true,
'products': [_product('p1')],
},
{
'revision': 'rev-2',
'has_more': true,
'products': [_product('p2')],
},
{
'revision': 'rev-2',
'has_more': false,
'products': [_product('p3')],
},
]);
final snapshot = await source.fetch();
expect(snapshot.products.map((p) => p.id), ['p1', 'p2', 'p3']);
});
test('the revision already held is sent as `since`', () async {
final urls = <Uri>[];
final source = sourceReturning(
[
{'revision': 'rev-9', 'is_delta': true, 'has_more': false},
],
record: urls,
);
await source.fetch(since: 'rev-8');
expect(urls.single.queryParameters['since'], 'rev-8');
expect(urls.single.queryParameters['terminal_id'], 'T4A9');
});
test('a delta is flagged as one, and carries its withdrawals', () async {
final source = sourceReturning([
{
'revision': 'rev-9',
'is_delta': true,
'has_more': false,
'products': [_product('p1', price: 55)],
'retired_product_ids': ['p7', 'p8'],
},
]);
final snapshot = await source.fetch(since: 'rev-8');
expect(snapshot.isDelta, isTrue);
expect(snapshot.retiredProductIds, ['p7', 'p8']);
expect(snapshot.changeCount, 3);
});
test('an empty delta means already up to date', () async {
final source = sourceReturning([
{'revision': 'rev-8', 'is_delta': true, 'has_more': false},
]);
final snapshot = await source.fetch(since: 'rev-8');
expect(snapshot.isEmpty, isTrue);
});
test('a bad credential is not retryable', () async {
final source = HttpCatalogueSource(
config: _config,
client: _FakeClient((_) => http.Response('nope', 401)),
);
await expectLater(
source.fetch(),
throwsA(isA<CatalogueSyncException>()
.having((e) => e.retryable, 'retryable', isFalse),),
);
});
test('a server error is retryable', () async {
final source = HttpCatalogueSource(
config: _config,
client: _FakeClient((_) => http.Response('boom', 503)),
);
await expectLater(
source.fetch(),
throwsA(isA<CatalogueSyncException>()
.having((e) => e.retryable, 'retryable', isTrue),),
);
});
test('an unconfigured endpoint fails fast', () async {
final source = HttpCatalogueSource(
config: const SyncConfig(transport: TransportKind.http),
client: _FakeClient((_) => http.Response('{}', 200)),
);
await expectLater(
source.fetch(),
throwsA(isA<CatalogueSyncException>()
.having((e) => e.retryable, 'retryable', isFalse),),
);
});
test('a server that never stops paging is cut off', () async {
// A bad deployment must not become an infinite request loop against a
// shop's connection.
var calls = 0;
final source = HttpCatalogueSource(
config: _config,
client: _FakeClient((_) {
calls++;
return http.Response(
jsonEncode({'revision': 'r', 'has_more': true, 'products': []}),
200,
);
}),
);
await expectLater(source.fetch(), throwsA(isA<CatalogueSyncException>()));
expect(calls, lessThanOrEqualTo(HttpCatalogueSource.maxPages + 1));
});
test('one malformed product fails the import rather than vanishing',
() async {
final source = sourceReturning([
{
'revision': 'rev-1',
'has_more': false,
'products': [
_product('p1'),
{'id': 'p2', 'name': 'No barcode'},
],
},
]);
await expectLater(
source.fetch(),
throwsA(isA<CatalogueFormatException>()),
);
});
});
group('applying to the terminal', () {
late LocalStore store;
setUpAll(() {
LocalStore.registerSeed(
products: SeedData.products,
customers: SeedData.customers,
);
});
setUp(() async {
store = LocalStore.instance;
await store.reset(withCatalogue: true);
});
test('a delta leaves everything it does not mention alone', () async {
// Applied as a full snapshot, the first morning price change would empty
// the shelf.
final before = store.products.length;
final milk = store.products.firstWhere((p) => p.barcode == '8901234500011');
await store.importCatalogue(
products: [milk.copyWith(price: 71)],
customers: const [],
revision: 'rev-2',
at: DateTime.now(),
isDelta: true,
);
expect(store.products, hasLength(before));
expect(store.productById(milk.id)!.price, 71);
});
test('a full snapshot withdraws what it omits', () async {
final milk = store.products.firstWhere((p) => p.barcode == '8901234500011');
await store.importCatalogue(
products: [milk],
customers: const [],
revision: 'rev-3',
at: DateTime.now(),
);
expect(store.products, hasLength(1));
});
test('a retired product is withdrawn, not deleted', () async {
// An order line already recorded points at it; a hard delete would orphan
// a bill's history.
final milk = store.products.firstWhere((p) => p.barcode == '8901234500011');
await store.importCatalogue(
products: const [],
customers: const [],
revision: 'rev-4',
at: DateTime.now(),
isDelta: true,
retiredProductIds: [milk.id],
);
expect(store.productByBarcode('8901234500011'), isNull,
reason: 'a withdrawn product must not scan',);
// The cache holds only what is sellable, so the surviving row has to be
// checked on disk — which is the point: an order line already recorded
// still resolves to a real product.
final rows = await AppDatabase.instance.db.query(
'products',
where: 'id = ?',
whereArgs: [milk.id],
);
expect(rows, hasLength(1));
expect(rows.single['is_active'], 0);
});
test('a locally added shopper survives a pull', () async {
final customers = CustomerRepositoryImpl(store);
final walkIn = await customers.create(const Customer(
id: 'ignored',
name: 'Local Only',
mobile: '9000000077',
),);
await store.importCatalogue(
products: const [],
customers: const [],
revision: 'rev-5',
at: DateTime.now(),
isDelta: true,
);
expect(await customers.findById(walkIn.id), isNotNull);
});
test('a delta does not subtract already-sold stock a second time',
() async {
// The replay exists because a *full* pull overwrites stock with a server
// figure that predates local sales. Running it over a delta that never
// carried that product would quietly empty a shelf that is full.
final products = ProductRepositoryImpl(store);
final checkout = CheckoutSale(
productRepository: products,
customerRepository: CustomerRepositoryImpl(store),
transactionRepository: TransactionRepositoryImpl(store),
);
final milk = (await products.findByBarcode('8901234500011'))!;
final opening = milk.stock;
await checkout(
cart: Cart(lines: [CartLine(product: milk, quantity: 4)]),
payments: const [
PaymentSplit(method: PaymentMethod.cash, amount: 248, tendered: 250),
],
cashierName: 'Divya',
terminalId: 'T0TEST',
);
expect((await products.findById(milk.id))!.stock, opening - 4);
// A delta about a *different* product must not touch the milk count.
final other = store.products.firstWhere((p) => p.id != milk.id);
await store.importCatalogue(
products: [other.copyWith(price: other.price + 1)],
customers: const [],
revision: 'rev-6',
at: DateTime.now(),
isDelta: true,
);
expect((await products.findById(milk.id))!.stock, opening - 4,
reason: 'the sold units must not be subtracted again',);
});
test('a delta carrying the sold product replays its committed stock',
() async {
// The other half of the same rule: when the server *does* send a fresh
// count for a product, that figure predates the local sale and the units
// would otherwise reappear on the shelf.
final products = ProductRepositoryImpl(store);
final checkout = CheckoutSale(
productRepository: products,
customerRepository: CustomerRepositoryImpl(store),
transactionRepository: TransactionRepositoryImpl(store),
);
final milk = (await products.findByBarcode('8901234500011'))!;
final opening = milk.stock;
await checkout(
cart: Cart(lines: [CartLine(product: milk, quantity: 4)]),
payments: const [
PaymentSplit(method: PaymentMethod.cash, amount: 248, tendered: 250),
],
cashierName: 'Divya',
terminalId: 'T0TEST',
);
await store.importCatalogue(
products: [milk],
customers: const [],
revision: 'rev-7',
at: DateTime.now(),
isDelta: true,
);
expect((await products.findById(milk.id))!.stock, opening - 4,
reason: 'the server count is stale by exactly the unsynced sales',);
});
});
}
class _FakeClient extends http.BaseClient {
_FakeClient(this.respond);
final http.Response Function(http.BaseRequest) respond;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final response = respond(request);
return http.StreamedResponse(
Stream.value(utf8.encode(response.body)),
response.statusCode,
request: request,
);
}
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:nearle_pos/data/datasources/local_store.dart'; import 'package:nearle_pos/data/datasources/local_store.dart';
import 'package:nearle_pos/data/datasources/remote_catalogue_source.dart'; import 'package:nearle_pos/data/remote/simulated_catalogue_source.dart';
import 'package:nearle_pos/data/datasources/seed_data.dart'; import 'package:nearle_pos/data/datasources/seed_data.dart';
import 'package:nearle_pos/data/remote/simulated_order_transport.dart'; import 'package:nearle_pos/data/remote/simulated_order_transport.dart';
import 'package:nearle_pos/data/repositories/customer_repository_impl.dart'; import 'package:nearle_pos/data/repositories/customer_repository_impl.dart';
@@ -276,7 +276,7 @@ void main() {
test('the archived day totals match what was charged', () async { test('the archived day totals match what was charged', () async {
final sync = SyncRepositoryImpl( final sync = SyncRepositoryImpl(
store, store,
RemoteCatalogueSource(isOffline: () => false), SimulatedCatalogueSource(isOffline: () => false),
SimulatedOrderTransport(isOffline: () => false), SimulatedOrderTransport(isOffline: () => false),
); );
@@ -321,7 +321,7 @@ void main() {
() async { () async {
final sync = SyncRepositoryImpl( final sync = SyncRepositoryImpl(
store, store,
RemoteCatalogueSource(isOffline: () => false), SimulatedCatalogueSource(isOffline: () => false),
SimulatedOrderTransport(isOffline: () => false), SimulatedOrderTransport(isOffline: () => false),
); );
@@ -351,7 +351,7 @@ void main() {
test('a failed import is recorded with its error', () async { test('a failed import is recorded with its error', () async {
final sync = SyncRepositoryImpl( final sync = SyncRepositoryImpl(
store, store,
RemoteCatalogueSource(isOffline: () => true), SimulatedCatalogueSource(isOffline: () => true),
SimulatedOrderTransport(isOffline: () => true), SimulatedOrderTransport(isOffline: () => true),
); );
@@ -380,7 +380,7 @@ void main() {
test('a cashier settles their own till, not the terminal', () async { test('a cashier settles their own till, not the terminal', () async {
final sync = SyncRepositoryImpl( final sync = SyncRepositoryImpl(
store, store,
RemoteCatalogueSource(isOffline: () => false), SimulatedCatalogueSource(isOffline: () => false),
SimulatedOrderTransport(isOffline: () => false), SimulatedOrderTransport(isOffline: () => false),
); );
@@ -406,7 +406,7 @@ void main() {
test('scoping holds after the bills are uploaded and deleted', () async { test('scoping holds after the bills are uploaded and deleted', () async {
final sync = SyncRepositoryImpl( final sync = SyncRepositoryImpl(
store, store,
RemoteCatalogueSource(isOffline: () => false), SimulatedCatalogueSource(isOffline: () => false),
SimulatedOrderTransport(isOffline: () => false), SimulatedOrderTransport(isOffline: () => false),
); );

View File

@@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:nearle_pos/data/datasources/local_store.dart'; import 'package:nearle_pos/data/datasources/local_store.dart';
import 'package:nearle_pos/data/datasources/remote_catalogue_source.dart'; import 'package:nearle_pos/data/remote/simulated_catalogue_source.dart';
import 'package:nearle_pos/data/datasources/seed_data.dart'; import 'package:nearle_pos/data/datasources/seed_data.dart';
import 'package:nearle_pos/data/local/app_database.dart'; import 'package:nearle_pos/data/local/app_database.dart';
import 'package:nearle_pos/data/local/order_dao.dart'; import 'package:nearle_pos/data/local/order_dao.dart';
@@ -84,7 +84,7 @@ void main() {
SyncRepositoryImpl syncWith(OrderTransport transport) => SyncRepositoryImpl( SyncRepositoryImpl syncWith(OrderTransport transport) => SyncRepositoryImpl(
store, store,
RemoteCatalogueSource(isOffline: () => false), SimulatedCatalogueSource(isOffline: () => false),
transport, transport,
); );
@@ -342,7 +342,7 @@ void main() {
final sync = SyncRepositoryImpl( final sync = SyncRepositoryImpl(
store, store,
RemoteCatalogueSource(isOffline: () => false), SimulatedCatalogueSource(isOffline: () => false),
transport, transport,
batchSize: 2, batchSize: 2,
); );