import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app/providers.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, }); 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; 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 ways into this terminal. /// /// Store-level credentials, not a person's: they are replaced wholesale when /// the terminal is registered against a real back office. Staff PINs — the /// credential that actually opens a till drawer — are not here. They live /// hashed in the database. /// /// The split is what the two roles are *for*, not decoration: /// /// * [admin] runs the whole shell and is the only login 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', email: 'admin@nearle.in', password: 'nearle123', role: StaffRole.admin, blurb: 'Full shell — import products, promos, settings.', ), cashier( label: 'Cashier', email: 'cashier@nearle.in', password: 'cashier123', role: StaffRole.cashier, blurb: 'Billing only, on the products the admin imported.', ); const TerminalLogin({ required this.label, required this.email, required this.password, required this.role, required this.blurb, }); final String label; final String email; final String password; 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; static TerminalLogin? byEmail(String email) { final normalised = email.trim().toLowerCase(); for (final login in TerminalLogin.values) { if (login.email == normalised) return login; } return null; } } /// Kept for the store record, which is keyed on the outlet's own address. class DemoCredentials { const DemoCredentials._(); static const String email = 'admin@nearle.in'; static const String password = 'nearle123'; static const String cashierEmail = 'cashier@nearle.in'; static const String cashierPassword = 'cashier123'; } /// Validates store credentials and holds the signed-in 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; } Future signIn({ required String email, required String password, }) async { state = const Authenticating(); // Stand-in for the network round trip. await Future.delayed(const Duration(milliseconds: 600)); final login = TerminalLogin.byEmail(email); if (login == null) { state = const AuthFailure('No account is registered against that email.'); return false; } if (password != login.password) { state = const AuthFailure('Incorrect password. Please try again.'); return false; } final store = await _ref.read(storeAccountProvider.future); final staff = store.staff; if (staff.isEmpty) { state = const AuthFailure( 'This terminal has no staff accounts. Reinstall to seed them.', ); return false; } // Whoever on this terminal matches the role that just signed in. Falls // back rather than failing: the session's permissions come from [login], // so a shop with no cashier row still gets a usable till — the bills are // just stamped with the account that is there. final opener = staff.firstWhere( (s) => s.role == login.role, orElse: () => staff.first, ); state = Authenticated(store: store, user: opener, login: login); return true; } /// 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, ); 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; _ref.invalidate(storeAccountProvider); final store = await _ref.read(storeAccountProvider.future); final me = store.staff.where((s) => s.id == current.user.id); state = Authenticated( store: store, // Signed out if the active operator was just deactivated — carrying on // would keep stamping bills with an account the shop has revoked. user: me.isEmpty ? store.staff.first : me.first, login: current.login, ); } /// Ends the session, and — for a cashier only — 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(); } 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; }); /// Which credential 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; });