import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app/providers.dart'; import '../../../data/local/app_database.dart'; import '../../../data/remote/pos_auth_api.dart'; import '../../../domain/entities/pos_session.dart'; import '../../../domain/entities/store_account.dart'; /// Sign-in state for the terminal. sealed class AuthState { const AuthState(); bool get isAuthenticated => this is Authenticated; } class Unauthenticated extends AuthState { const Unauthenticated(); } class Authenticating extends AuthState { const Authenticating(); } class Authenticated extends AuthState { const Authenticated({ required this.store, required this.user, required this.login, required this.session, }); final StoreAccount store; final StaffUser user; /// Which credential opened this session. The authority on what the terminal /// is allowed to show — not [user], which can be swapped at the till. final TerminalLogin login; /// What the back office answered with. Holds the bearer token every later /// call needs, and the outlet this terminal is trading as. final PosSession session; StaffRole get role => login.role; bool get isAdmin => login == TerminalLogin.admin; bool get isCashier => login == TerminalLogin.cashier; } class AuthFailure extends AuthState { const AuthFailure(this.message); final String message; } /// The two shapes this terminal can take. /// /// No longer a credential — the back office owns those now. This is the mode /// the shell runs in, decided from the role the login response came back with /// (see [PosSession.isCashier]). /// /// The split is what the two are *for*, not decoration: /// /// * [admin] runs the whole shell and is the only mode that can pull the /// catalogue. Signing out leaves the products on the terminal. /// * [cashier] gets the billing screen and nothing else, and signing out /// takes the catalogue with it. enum TerminalLogin { admin( label: 'Admin', role: StaffRole.admin, blurb: 'Full shell — import products, promos, settings.', ), cashier( label: 'Cashier', role: StaffRole.cashier, blurb: 'Billing only, on the products the admin imported.', ); const TerminalLogin({ required this.label, required this.role, required this.blurb, }); final String label; final StaffRole role; final String blurb; /// The catalogue is pulled once by an admin and billed against by whoever is /// on the counter, so only the cashier's sign-out drops it. An admin closing /// the shell is a handover, not the end of the day. bool get clearsCatalogueOnSignOut => this == TerminalLogin.cashier; /// Which shell the back office's role name lands in. static TerminalLogin forSession(PosSession session) => session.isCashier ? TerminalLogin.cashier : TerminalLogin.admin; } /// Validates store credentials against the back office and holds the session. class AuthController extends StateNotifier { AuthController(this._ref) : super(const Unauthenticated()); final Ref _ref; /// Whether signing out right now would wipe the products off this terminal. /// /// Read *before* [signOut] by anything that needs to warn the operator, since /// the session is gone by the time it returns. bool get clearsCatalogueOnSignOut { final current = state; return current is Authenticated && current.login.clearsCatalogueOnSignOut; } /// The live bearer token, or null when nobody is signed in. String? get token { final current = state; return current is Authenticated ? current.session.token : null; } /// Signs in against `POST /pos/login`. /// /// [authname] is the account the back office issued for this till, e.g. /// `supervisor.1135@pos.nearle.in`. The role that comes back — not anything /// chosen on this screen — decides whether the terminal opens the admin /// shell or the cashier till. Future signIn({ required String authname, required String password, }) async { state = const Authenticating(); try { final session = await _ref.read(posAuthApiProvider).login( authname: authname, password: password, deviceId: _ref.read(terminalIdentityProvider).deviceId, ); await _open(session, persist: true); return true; } on AuthApiException catch (e) { state = AuthFailure(e.message); return false; } on Object catch (e, stack) { debugPrint('Sign-in failed: $e\n$stack'); state = const AuthFailure( 'Sign-in failed unexpectedly. Please try again.', ); return false; } } /// Re-opens the session stored on this terminal, if there is a live one. /// /// Called once at startup, before the first frame, so a till that was signed /// in when it lost power comes back up on the same shell rather than at a /// login screen someone has to find the credentials for. /// /// Returns false — and leaves the terminal signed out — when there is no /// session, or the token has expired. Future restore() async { final session = await _ref.read(sessionStoreProvider).read(); if (session == null) return false; try { // Already on disk, so nothing to persist. Details are re-applied because // a shop that changed its GSTIN in the back office should not print the // old one just because this terminal never signed out. await _open(session, persist: false); return true; } on Object catch (e, stack) { debugPrint('Session restore failed: $e\n$stack'); await _ref.read(sessionStoreProvider).clear(); state = const Unauthenticated(); return false; } } /// Turns a session into a live shell. Future _open(PosSession session, {required bool persist}) async { if (persist) await _ref.read(sessionStoreProvider).save(session); await _applyStoreDetails(session); // Read directly rather than through `storeAccountProvider`: that provider // watches this controller, so going through it here would rebuild it in // the middle of the sign-in that is about to populate it. Setting the // state below is what refreshes it, once. final store = await _ref.read(storeRepositoryProvider).load( email: session.authname, ); state = Authenticated( store: store, // The operator is whoever the back office says signed in, not a local // seeded account that happens to share a role. user: session.user, login: TerminalLogin.forSession(session), session: session, ); } /// Copies the outlet's details out of the login response into this /// terminal's own record. /// /// The name, address, GSTIN and phone are printed on every invoice, where /// they are a legal requirement rather than decoration — so the back office /// is the source of truth for them, and a correction made there reaches the /// till on the next sign-in. Blank fields are skipped, so a partial response /// never erases details that are already right. /// /// Delete this method if the terminal should keep whatever was typed into /// Settings instead; nothing else depends on it. Future _applyStoreDetails(PosSession session) async { final catalogue = _ref.read(localStoreProvider).catalogue; Future put(String key, String value) async { if (value.trim().isEmpty) return; await catalogue.setMeta(key, value.trim()); } await put(MetaKeys.storeName, session.locationName); await put(MetaKeys.storeAddress, session.address); await put(MetaKeys.storeGstin, session.gstin.toUpperCase()); await put(MetaKeys.storePhone, session.phone); await _followOutlet(session); } /// Re-points the terminal at the outlet the session belongs to. /// /// The outlet id namespaces every sync topic, so a till moved between shops /// would otherwise keep publishing its bills into the previous shop's books. /// /// Goes through the identity store rather than writing the meta row alone: /// the in-memory [TerminalIdentity] is what `syncConfigProvider` reads, and a /// row on disk that nothing has re-read is a change that appears to have /// worked and has not. Future _followOutlet(PosSession session) async { if (session.storeId.trim().isEmpty) return; final local = _ref.read(localStoreProvider); if (local.terminal.storeId == session.storeId) return; await local.identityStore.rename(storeId: session.storeId); local.terminal = await local.identityStore.load(); // Rebuilds the sync configuration, and with it the catalogue source and // the order transport, onto the new outlet's topics. _ref.invalidate(terminalIdentityProvider); } /// Switches the active operator, checking their PIN. /// /// Every bill is stamped with whoever is active, so this is the boundary that /// decides who a sale is attributed to — it cannot be a bare selection from a /// list. It changes who the bill names, never what the session may open: /// [Authenticated.login] is untouched, so a cashier terminal stays a cashier /// terminal. Future switchUser(String pin) async { final current = state; if (current is! Authenticated) return false; final store = _ref.read(localStoreProvider); final user = await store.staff.authenticate(pin); if (user == null) return false; state = Authenticated( store: current.store, user: user, login: current.login, session: current.session, ); return true; } /// Re-reads the store after staff or details change, keeping the session. Future refreshStore() async { final current = state; if (current is! Authenticated) return; final store = await _ref.read(storeRepositoryProvider).load( email: current.session.authname, ); _ref.invalidate(storeAccountProvider); // The signed-in account comes from the back office and is not in this // terminal's staff table, so a miss here means "not a local operator", // not "deactivated". Only a local operator who has actually disappeared // hands the session back to the account that opened it. final me = store.staff.where((s) => s.id == current.user.id); state = Authenticated( store: store, user: me.isNotEmpty ? me.first : current.session.user, login: current.login, session: current.session, ); } /// Ends the session, drops the stored copy of it, and — for a cashier only — /// takes the catalogue with it. /// /// Every cashier sign-out drops the products, whatever the reason for it. /// The next shift should bill against what the back office answers with, /// never a catalogue carried over, and a terminal left at a login screen /// must not be sitting on a shop's prices and stock. /// /// An admin signing out is the opposite case. They have just pulled the /// products *so that* a cashier can pick the terminal up, so dropping the /// table here would make the import pointless. Future signOut() async { if (clearsCatalogueOnSignOut) { await _ref.read(localStoreProvider).clearCatalogue(); } // Unconditional, and before the state change: the token and the staff PINs // in that blob must not survive a sign-out, and nothing below may be able // to leave them on disk. await _ref.read(sessionStoreProvider).clear(); state = const Unauthenticated(); } void clearError() { if (state is AuthFailure) state = const Unauthenticated(); } } final authControllerProvider = StateNotifierProvider( AuthController.new, ); /// The signed-in store, or null before sign-in. final currentStoreProvider = Provider((ref) { final s = ref.watch(authControllerProvider); return s is Authenticated ? s.store : null; }); /// The active operator, or null before sign-in. final currentUserProvider = Provider((ref) { final s = ref.watch(authControllerProvider); return s is Authenticated ? s.user : null; }); /// What the back office answered with, or null before sign-in. /// /// Read this for the bearer token, the tenant, or the outlet list. final posSessionProvider = Provider((ref) { final s = ref.watch(authControllerProvider); return s is Authenticated ? s.session : null; }); /// The account this terminal is signed in as. final sessionAuthnameProvider = Provider( (ref) => ref.watch(posSessionProvider)?.authname ?? '', ); /// Which mode is holding this session open, or null before sign-in. final terminalLoginProvider = Provider((ref) { final s = ref.watch(authControllerProvider); return s is Authenticated ? s.login : null; }); /// True when the terminal is locked down to the billing screen. /// /// The one flag the shell reads: no sidebar, no back-office modules, sign-out /// and events promoted to the header. final isCashierModeProvider = Provider( (ref) => ref.watch(terminalLoginProvider) == TerminalLogin.cashier, ); final isAdminModeProvider = Provider( (ref) => ref.watch(terminalLoginProvider) == TerminalLogin.admin, ); /// True while anyone is still on a seeded or admin-reset PIN. final mustChangePinProvider = Provider((ref) { final user = ref.watch(currentUserProvider); return user?.mustChangePin ?? false; }); /// Re-opens a stored session before the first frame. /// /// Awaited by the app shell, so the router never briefly shows a login screen /// to a terminal that was already signed in. final sessionBootstrapProvider = FutureProvider( (ref) => ref.read(authControllerProvider.notifier).restore(), );