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>
303 lines
10 KiB
Dart
303 lines
10 KiB
Dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../../app/providers.dart';
|
|
import '../../../core/constants/app_constants.dart';
|
|
import '../../../data/remote/mqtt_order_transport.dart';
|
|
import '../../../data/sync/health_reporter.dart';
|
|
import '../../../data/sync/presence_reporter.dart';
|
|
import '../../modules/providers/printer_settings.dart';
|
|
import '../../../domain/entities/shift_report.dart';
|
|
import '../../../domain/entities/sync_event.dart';
|
|
import '../../../domain/repositories/sync_repository.dart';
|
|
import '../../auth/providers/auth_controller.dart';
|
|
import '../../pos/providers/catalog_providers.dart';
|
|
|
|
/// Bumped after every import so catalogue-backed providers refetch.
|
|
final catalogueVersionProvider = StateProvider<int>((ref) => 0);
|
|
|
|
/// Bumped after every sale or sync so order-backed providers refetch.
|
|
final orderVersionProvider = StateProvider<int>((ref) => 0);
|
|
|
|
/// Whether products exist on this terminal. Billing is gated on it.
|
|
final catalogueReadyProvider = Provider<bool>((ref) {
|
|
ref.watch(catalogueVersionProvider);
|
|
return ref.watch(syncRepositoryProvider).hasCatalogue;
|
|
});
|
|
|
|
final lastImportAtProvider = Provider<DateTime?>((ref) {
|
|
ref.watch(catalogueVersionProvider);
|
|
return ref.watch(syncRepositoryProvider).lastImportAt;
|
|
});
|
|
|
|
// ------------------------------------------------------- Morning: import
|
|
sealed class ImportState {
|
|
const ImportState();
|
|
}
|
|
|
|
class ImportIdle extends ImportState {
|
|
const ImportIdle();
|
|
}
|
|
|
|
class ImportRunning extends ImportState {
|
|
const ImportRunning(this.progress, this.stage);
|
|
|
|
final double progress;
|
|
final String stage;
|
|
}
|
|
|
|
class ImportDone extends ImportState {
|
|
const ImportDone(this.event);
|
|
|
|
final SyncEvent event;
|
|
}
|
|
|
|
class ImportFailed extends ImportState {
|
|
const ImportFailed(this.message);
|
|
|
|
final String message;
|
|
}
|
|
|
|
class CatalogueImportController extends StateNotifier<ImportState> {
|
|
CatalogueImportController(this._ref) : super(const ImportIdle());
|
|
|
|
final Ref _ref;
|
|
|
|
Future<bool> run() async {
|
|
if (state is ImportRunning) return false;
|
|
state = const ImportRunning(0, 'Starting…');
|
|
|
|
final event = await _ref.read(syncRepositoryProvider).importCatalogue(
|
|
onProgress: (progress, stage) {
|
|
if (mounted) state = ImportRunning(progress, stage);
|
|
},
|
|
);
|
|
|
|
if (event.status == SyncStatus.synced) {
|
|
_ref.read(catalogueVersionProvider.notifier).state++;
|
|
_ref.invalidate(allProductsProvider);
|
|
_ref.invalidate(visibleProductsProvider);
|
|
_ref.invalidate(categoryCountsProvider);
|
|
_ref.invalidate(lowStockProductsProvider);
|
|
state = ImportDone(event);
|
|
return true;
|
|
}
|
|
|
|
state = ImportFailed(event.error ?? 'Import failed.');
|
|
return false;
|
|
}
|
|
|
|
/// Drops a stale success or failure banner.
|
|
void reset() => state = const ImportIdle();
|
|
}
|
|
|
|
final catalogueImportProvider =
|
|
StateNotifierProvider<CatalogueImportController, ImportState>(
|
|
(ref) => CatalogueImportController(ref),
|
|
);
|
|
|
|
// ---------------------------------------------------- Business hours: read
|
|
/// Bills still held on this terminal at sync_status = 0.
|
|
final unsyncedCountProvider = FutureProvider<int>((ref) {
|
|
ref.watch(orderVersionProvider);
|
|
return ref.watch(syncRepositoryProvider).unsyncedCount();
|
|
});
|
|
|
|
/// Everything this terminal traded today, across every operator.
|
|
final todayReportProvider = FutureProvider<ShiftReport>((ref) {
|
|
ref.watch(orderVersionProvider);
|
|
final session = ref.watch(cashierSessionProvider);
|
|
final user = ref.watch(currentUserProvider);
|
|
|
|
return ref.watch(syncRepositoryProvider).todayReport(
|
|
terminalId: session.terminalId,
|
|
cashierName: user?.name ?? session.name,
|
|
);
|
|
});
|
|
|
|
/// Only the bills the signed-in operator rang.
|
|
///
|
|
/// This is the figure a cashier counts their drawer against at the end of a
|
|
/// shift, so it must not include anyone else's sales.
|
|
final myShiftReportProvider = FutureProvider<ShiftReport>((ref) {
|
|
ref.watch(orderVersionProvider);
|
|
final session = ref.watch(cashierSessionProvider);
|
|
final user = ref.watch(currentUserProvider);
|
|
|
|
return ref.watch(syncRepositoryProvider).todayReport(
|
|
terminalId: session.terminalId,
|
|
cashierName: user?.name ?? session.name,
|
|
scopeToCashier: true,
|
|
);
|
|
});
|
|
|
|
/// Per-order sync state for the events log.
|
|
final orderSyncRowsProvider = FutureProvider<List<OrderSyncRow>>((ref) {
|
|
ref.watch(orderVersionProvider);
|
|
return ref.watch(syncRepositoryProvider).orderSyncRows();
|
|
});
|
|
|
|
final syncEventsProvider = Provider<List<SyncEvent>>((ref) {
|
|
ref.watch(orderVersionProvider);
|
|
ref.watch(catalogueVersionProvider);
|
|
return ref.watch(syncRepositoryProvider).events;
|
|
});
|
|
|
|
// ------------------------------------------------------ End of day: upload
|
|
sealed class OrderSyncState {
|
|
const OrderSyncState();
|
|
}
|
|
|
|
class SyncIdle extends OrderSyncState {
|
|
const SyncIdle();
|
|
}
|
|
|
|
class SyncRunning extends OrderSyncState {
|
|
const SyncRunning(this.progress, this.stage);
|
|
|
|
final double progress;
|
|
final String stage;
|
|
}
|
|
|
|
class SyncFinished extends OrderSyncState {
|
|
const SyncFinished(this.outcome);
|
|
|
|
final SyncOutcome outcome;
|
|
}
|
|
|
|
class OrderSyncController extends StateNotifier<OrderSyncState> {
|
|
OrderSyncController(this._ref) : super(const SyncIdle());
|
|
|
|
final Ref _ref;
|
|
|
|
bool get isRunning => state is SyncRunning;
|
|
|
|
/// Uploads every bill at sync_status = 0 and flips the accepted ones to 1.
|
|
///
|
|
/// Goes through the engine rather than straight to the repository, so a
|
|
/// cashier pressing sync while a background drain is already mid-flight
|
|
/// joins it instead of starting a second pass over the same rows. It also
|
|
/// clears a halt: pressing the button is how you retry after the back office
|
|
/// has been fixed.
|
|
Future<SyncOutcome> run() async {
|
|
if (isRunning) {
|
|
return const SyncOutcome(attempted: 0, uploaded: 0);
|
|
}
|
|
|
|
state = const SyncRunning(0, 'Starting…');
|
|
|
|
final outcome = await _ref.read(syncEngineProvider).syncNow(
|
|
onProgress: (progress, stage) {
|
|
if (mounted) state = SyncRunning(progress, stage);
|
|
},
|
|
);
|
|
|
|
_ref.read(orderVersionProvider.notifier).state++;
|
|
if (mounted) state = SyncFinished(outcome);
|
|
return outcome;
|
|
}
|
|
|
|
void reset() => state = const SyncIdle();
|
|
}
|
|
|
|
final orderSyncProvider =
|
|
StateNotifierProvider<OrderSyncController, OrderSyncState>(
|
|
(ref) => OrderSyncController(ref),
|
|
);
|
|
|
|
// ------------------------------------------------------- Background drain
|
|
/// Brings the queue-and-drain machinery up, once, when the shell mounts.
|
|
///
|
|
/// Deliberately not gated on sign-in: a terminal that boots holding yesterday's
|
|
/// bills should be emptying its queue before anyone reaches the till.
|
|
///
|
|
/// Overridden to a no-op in widget tests, which have no network stack and
|
|
/// cannot drive real disk I/O on a fake clock.
|
|
final syncBootstrapProvider = FutureProvider<void>((ref) async {
|
|
// Restore the route this terminal was pointed at. Without this the settings
|
|
// are written on Save and then silently ignored on the next launch, which
|
|
// reads exactly like they never saved.
|
|
final store = ref.read(localStoreProvider);
|
|
if (store.isReady) {
|
|
ref.read(syncConfigProvider.notifier).state =
|
|
await store.syncConfig.load(ref.read(syncConfigProvider));
|
|
}
|
|
|
|
// Bring back the session this terminal was signed in under.
|
|
//
|
|
// Runs before the engine starts, and that ordering is load-bearing: the
|
|
// session carries both the bearer token and the outlet, so a drain that began
|
|
// first would upload the day's bills unauthenticated — and, once the backend
|
|
// is enforcing, have them refused.
|
|
//
|
|
// A till signs in when a shop opens and trades all day. Without this a reboot
|
|
// mid-shift would put a login screen in front of a queue of customers, which
|
|
// is a worse outage than the one it protects against.
|
|
await ref.read(authControllerProvider.notifier).restore();
|
|
|
|
await ref.read(connectivityServiceProvider).start();
|
|
|
|
final engine = ref.read(syncEngineProvider);
|
|
|
|
// A background drain moves bills out of the pending set, so the tallies and
|
|
// shift totals on screen are stale the moment one finishes.
|
|
var wasSyncing = false;
|
|
final subscription = engine.states.listen((state) {
|
|
if (wasSyncing && !state.isSyncing) {
|
|
ref.read(orderVersionProvider.notifier).state++;
|
|
}
|
|
wasSyncing = state.isSyncing;
|
|
});
|
|
ref.onDispose(subscription.cancel);
|
|
|
|
await engine.start();
|
|
|
|
// The retained presence record needs a Last Will to pair with, so it genuinely
|
|
// only exists on the broker. The heartbeat below does not, and is started for
|
|
// every route.
|
|
final transport = ref.read(orderTransportProvider);
|
|
if (transport is MqttOrderTransport) {
|
|
final reporter = PresenceReporter(
|
|
transport: transport,
|
|
terminal: ref.read(terminalIdentityProvider),
|
|
config: ref.read(syncConfigProvider),
|
|
engine: engine,
|
|
appVersion: AppConstants.appVersion,
|
|
catalogueRevision: () async =>
|
|
ref.read(syncRepositoryProvider).catalogueRevision,
|
|
);
|
|
ref.onDispose(reporter.dispose);
|
|
await reporter.start();
|
|
}
|
|
|
|
// The 30-second heartbeat the head-office board reads. Separate from the
|
|
// retained presence record above: that one is paired with the Last Will and
|
|
// answers "is this till alive", while this carries queue depth, today's
|
|
// trading and hardware state — what tells a till that is merely quiet from
|
|
// one that has stopped uploading.
|
|
//
|
|
// Outside the MQTT check on purpose. It used to be inside, which meant a shop
|
|
// on the HTTP route uploaded every bill correctly and never appeared on the
|
|
// board at all — with nothing logged, because nothing had failed. Both routes
|
|
// can carry a heartbeat now, and the transport decides how.
|
|
{
|
|
final health = HealthReporter(
|
|
transport: transport,
|
|
terminal: ref.read(terminalIdentityProvider),
|
|
config: ref.read(syncConfigProvider),
|
|
engine: engine,
|
|
repository: ref.read(syncRepositoryProvider),
|
|
appVersion: AppConstants.appVersion,
|
|
// Read on each beat, so re-pointing the printer in Settings takes effect
|
|
// without a restart.
|
|
printerEndpoint: () {
|
|
final printer = ref.read(printerSettingsProvider);
|
|
final host = printer.drawerHost;
|
|
if (host == null || host.isEmpty) return null;
|
|
return (host: host, port: printer.drawerPort);
|
|
},
|
|
);
|
|
ref.onDispose(health.dispose);
|
|
await health.start();
|
|
}
|
|
});
|