Files
nearle_pos/lib/app/providers.dart
Suriya fbfc02d140 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>
2026-08-01 15:37:42 +05:30

238 lines
8.5 KiB
Dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/config/sync_config.dart';
import '../core/services/connectivity_service.dart';
import '../core/services/receipt_service.dart';
import '../core/services/sound_service.dart';
import '../data/datasources/local_store.dart';
import '../data/repositories/customer_repository_impl.dart';
import '../data/repositories/product_repository_impl.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/mqtt_order_transport.dart';
import '../data/remote/order_transport.dart';
import '../data/remote/simulated_order_transport.dart';
import '../data/repositories/store_repository_impl.dart';
import '../data/repositories/sync_repository_impl.dart';
import '../data/repositories/transaction_repository_impl.dart';
import '../data/local/terminal_identity.dart';
import '../data/sync/sync_engine.dart';
import '../domain/repositories/customer_repository.dart';
import '../domain/repositories/product_repository.dart';
import '../domain/repositories/sync_repository.dart';
import '../domain/repositories/transaction_repository.dart';
import '../domain/entities/promo.dart';
import '../domain/entities/store_account.dart';
import '../domain/usecases/checkout_sale.dart';
import '../presentation/auth/providers/auth_controller.dart';
/// Root data source. Overridden in tests with an in-memory double.
final localStoreProvider = Provider<LocalStore>((ref) => LocalStore.instance);
// ---------------------------------------------------------- Repositories
final productRepositoryProvider = Provider<ProductRepository>(
(ref) => ProductRepositoryImpl(ref.watch(localStoreProvider)),
);
final customerRepositoryProvider = Provider<CustomerRepository>(
(ref) => CustomerRepositoryImpl(ref.watch(localStoreProvider)),
);
final transactionRepositoryProvider = Provider<TransactionRepository>(
(ref) => TransactionRepositoryImpl(ref.watch(localStoreProvider)),
);
/// Mirrors the Settings "Simulate offline" switch so the header can show it.
///
/// Without this the terminal claims LIVE while every call is being failed on
/// purpose, which reads as a real network fault.
final simulateOfflineProvider = StateProvider<bool>((ref) => false);
/// Where products and customers come from.
///
/// Rebuilt when the route changes, and the old one closed, so a re-pointed
/// terminal does not keep a stale client alive.
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
/// How this terminal reaches the back office.
///
/// Defaults to the simulated route so a fresh install is usable with no broker
/// and no endpoint; Settings re-points it.
///
/// Store and terminal ids always come from this device's own identity, never
/// from a literal — two terminals publishing on the same topic is the failure
/// this exists to prevent.
final syncConfigProvider = StateProvider<SyncConfig>((ref) {
final terminal = ref.watch(terminalIdentityProvider);
return SyncConfig(
storeId: terminal.storeId,
terminalId: terminal.code,
);
});
/// Real network state, folded with the Settings offline switch.
final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
final service = ConnectivityService(
isSimulatedOffline: () => ref.read(simulateOfflineProvider),
);
ref.onDispose(service.dispose);
return service;
});
/// The wire itself. Rebuilt when the configuration changes, and the old one is
/// closed so a re-pointed terminal does not keep a stale broker session open.
final orderTransportProvider = Provider<OrderTransport>((ref) {
final config = ref.watch(syncConfigProvider);
final transport = switch (config.transport) {
TransportKind.mqtt => MqttOrderTransport(config: config),
TransportKind.http => HttpOrderTransport(config: config),
TransportKind.simulated => SimulatedOrderTransport(
isOffline: () => ref.read(simulateOfflineProvider),
),
};
ref.onDispose(transport.dispose);
return transport;
});
final syncRepositoryProvider = Provider<SyncRepository>(
(ref) => SyncRepositoryImpl(
ref.watch(localStoreProvider),
ref.watch(catalogueSourceProvider),
ref.watch(orderTransportProvider),
batchSize: ref.watch(syncConfigProvider).batchSize,
),
);
/// Decides when bills are uploaded. Started once, by the app shell.
final syncEngineProvider = Provider<SyncEngine>((ref) {
final transport = ref.watch(orderTransportProvider);
final engine = SyncEngine(
repository: ref.watch(syncRepositoryProvider),
connectivity: ref.watch(connectivityServiceProvider).onlineChanges,
downlink: transport.downlink,
onCatalogueChanged: () async {
await ref.read(syncRepositoryProvider).importCatalogue();
ref.invalidate(localStoreProvider);
},
);
ref.onDispose(engine.dispose);
return engine;
});
/// Live engine state for the header pill and the events screen.
final syncEngineStateProvider = StreamProvider<SyncEngineState>((ref) {
final engine = ref.watch(syncEngineProvider);
return engine.states.map((s) => s);
});
/// Store details and staff, read from this terminal's database.
final storeRepositoryProvider = Provider<StoreRepositoryImpl>(
(ref) => StoreRepositoryImpl(ref.watch(localStoreProvider)),
);
/// The outlet, refreshed whenever staff or details change.
final storeAccountProvider = FutureProvider<StoreAccount>(
(ref) => ref.watch(storeRepositoryProvider).load(
email: DemoCredentials.email,
),
);
/// Campaigns stored on this terminal.
final promosProvider = FutureProvider<List<Promo>>(
(ref) => ref.watch(localStoreProvider).promos.all(),
);
/// Only the campaigns the till should be applying right now.
final activePromosProvider = FutureProvider<List<Promo>>(
(ref) => ref.watch(localStoreProvider).promos.all(activeOnly: true),
);
// ------------------------------------------------------------- Use cases
final checkoutSaleProvider = Provider<CheckoutSale>(
(ref) => CheckoutSale(
productRepository: ref.watch(productRepositoryProvider),
customerRepository: ref.watch(customerRepositoryProvider),
transactionRepository: ref.watch(transactionRepositoryProvider),
),
);
// -------------------------------------------------------------- Services
final soundServiceProvider =
Provider<SoundService>((ref) => SoundService.instance);
final receiptServiceProvider =
Provider<ReceiptService>((ref) => ReceiptService.instance);
// --------------------------------------------------------------- Session
class CashierSession {
const CashierSession({
required this.name,
required this.role,
required this.terminalId,
});
final String name;
final String role;
final String terminalId;
}
/// Identity of the physical till, read from its own database.
///
/// Falls back only before the store has opened; every real read happens after
/// `LocalStore.init`, which mints the identity if this device has never run
/// before.
final terminalIdentityProvider = Provider<TerminalIdentity>((ref) {
final store = ref.watch(localStoreProvider);
return store.isReady
? store.terminal
: const TerminalIdentity(
deviceId: 'unopened',
code: 'T0000',
name: 'Terminal',
storeId: 'store-01',
);
});
final cashierSessionProvider = StateProvider<CashierSession>((ref) {
final terminal = ref.watch(terminalIdentityProvider);
return CashierSession(
name: 'Suriya',
role: 'ADMIN',
terminalId: terminal.code,
);
});
/// Ticks once a minute to drive the header clock without rebuilding on every
/// frame.
final clockProvider = StreamProvider<DateTime>((ref) async* {
yield DateTime.now();
yield* Stream.periodic(const Duration(seconds: 20), (_) => DateTime.now());
});