The role split was right; only its source was wrong. Signing in matched what was typed against two constants compiled into the app — admin@nearle.in and cashier@nearle.in — so which shell a person got was a property of the *build*. A shop could not add a third person, revoke either of the two it had, or stop anyone with the APK reading both passwords out of it. TerminalLogin survives unchanged in shape, because the shape was the good part: one flag the shell reads, a session that decides it, and a cashier sign-out that takes the catalogue with it while a supervisor's leaves it behind. Every consumer — visibleModulesProvider, resolvedModuleProvider, the sidebar, the page header, the sign-out dialog — is untouched. What changed is that the enum is now only constructible from a session the back office signed, so there is no path left where the terminal grants itself a permission the server did not send. It reads `can_manage_staff` rather than the role name or id. app_roles holds six rows for four distinct roles, a great many accounts carry a roleid that is not in the table at all, and the name comes back blank for most of them. Matching on either would mean shipping a copy of the role table in the app and keeping the two in step for ever. One boolean, decided server-side, cannot drift. It defaults to false, which matters on the restore path: a session saved by a build that predates the field comes back as a cashier, never silently as an admin. This also restores the sign-in layer itself — pos_auth_api, pos_session, session_store, the staff import and the bearer token — which an earlier commit removed wholesale from a stale checkout. Its parent was the commit that added them, so the deletion was a bad merge rather than a decision; the terminal has been running on the two constants since. The login screen loses its role tabs and its credential prefill. You do not choose what you are on the way in. The opener is now matched on the back office user id rather than on the first account with a matching role, so the first bill of a shift is attributed to whoever actually signed in. Tests: the smoke suite pinned only the supervisor shell, and it was passing for the wrong reason — the fake session omitted can_manage_staff, and the sidebar it asserted on was there because the role was hardcoded. Both halves are pinned now and the fake is parameterised. widget_test.dart was the stock Flutter counter template, restored by the same bad merge, testing a MyApp that has never existed in this repo. 292 tests pass; analyzer reports no errors and no warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
262 lines
9.6 KiB
Dart
262 lines
9.6 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/session_store.dart';
|
|
import '../data/local/terminal_identity.dart';
|
|
import '../data/remote/pos_auth_api.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 this store's live HTTP endpoint, so importing works out of the
|
|
/// box against real products rather than the offline demo catalogue.
|
|
/// Settings → Connectivity & sync → Configure re-points it to a different
|
|
/// store, endpoint, or transport without a rebuild.
|
|
///
|
|
/// Terminal id always comes 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(
|
|
transport: TransportKind.http,
|
|
httpBaseUrl: 'https://fiesta.nearle.app/live/api/v1/pos',
|
|
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)),
|
|
);
|
|
|
|
/// Signs a terminal in against the back office.
|
|
///
|
|
/// Points at the same base URL the uplinks use, so re-pointing a terminal in
|
|
/// Settings moves its sign-in with it rather than leaving it authenticating
|
|
/// against the endpoint it used to belong to.
|
|
final posAuthApiProvider = Provider<PosAuthApi>((ref) {
|
|
final api = PosAuthApi(baseUrl: ref.watch(syncConfigProvider).httpBaseUrl);
|
|
ref.onDispose(api.dispose);
|
|
return api;
|
|
});
|
|
|
|
/// Where the signed session survives a restart.
|
|
final sessionStoreProvider = Provider<SessionStore>((ref) => SessionStore());
|
|
|
|
/// The outlet, refreshed whenever staff or details change.
|
|
///
|
|
/// The email is the signed-in account's, not a constant. It used to be a
|
|
/// `DemoCredentials.email` compiled into the build — the same address on every
|
|
/// install, which is what made the store login decorative.
|
|
final storeAccountProvider = FutureProvider<StoreAccount>(
|
|
(ref) => ref.watch(storeRepositoryProvider).load(
|
|
email: ref.watch(authControllerProvider.notifier).session?.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());
|
|
});
|