import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app/providers.dart'; import '../../../core/config/api_config.dart'; import '../../../data/local/app_database.dart'; import '../../../data/local/staff_dao.dart'; import '../../../data/remote/auth_api.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, 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. Null only on the offline demo path, /// where there was no back office to answer. /// /// Everything downstream reads from here rather than from a constant: the /// bearer token for the catalogue pull and the order push, the location id /// they are scoped to, and the tenant name shown beside the logo. final LoginSession? session; StaffRole get role => login.role; bool get isAdmin => login == TerminalLogin.admin; bool get isCashier => login == TerminalLogin.cashier; /// The name beside the logo: the tenant, then the outlet, then whatever the /// store record on this terminal says. String get storeName { final fromApi = session?.displayStoreName ?? ''; return fromApi.isNotEmpty ? fromApi : store.name; } } class AuthFailure extends AuthState { const AuthFailure(this.message); final String message; } /// What this terminal is allowed to open. /// /// No longer a credential — the back office decides the role now, and /// [AuthController.signIn] maps its answer onto one of these. The two values /// remain because the whole shell keys off them: /// /// * [admin] runs the full 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 every way out of /// the session takes the catalogue with it. /// /// The email and password fields are the built-in demo accounts, used only by /// the offline path — see [ApiConfig.allowOfflineDemoLogin]. 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; } } /// The built-in accounts, used only by the offline path. 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'; } /// Signs the terminal in 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; } /// Signs in against `POST /login`. /// /// The back office decides everything the terminal then does: the role that /// picks admin shell or billing screen, the location the catalogue is pulled /// for, and the token every later call carries. Nothing here is chosen at /// the login screen any more. Future signIn({ required String email, required String password, }) async { state = const Authenticating(); final store = _ref.read(localStoreProvider); // This till's own minted identity, not a fresh uuid — the back office can // recognise a terminal across restarts and refuse one it has not // registered. final deviceId = store.isReady ? store.terminal.deviceId : 'unopened'; LoginSession session; try { session = await _ref.read(authApiProvider).login( authname: email, password: password, deviceId: deviceId, ); } on ApiException catch (e) { // A refusal is final. Only a shop with no line at all may fall through, // and only onto the built-in accounts — a wrong password must never // quietly become a local sign-in. if (ApiConfig.allowOfflineDemoLogin && e.isNetworkFault) { final offline = await _signInOffline(email, password); if (offline) return true; } state = AuthFailure(e.message); return false; } try { await _applySession(session); } on Object catch (e) { state = AuthFailure( 'Signed in, but this terminal could not store the store details: $e', ); return false; } return true; } /// Writes everything the session decided into the terminal, then opens it. /// /// Order matters. The store id and terminal identity are written first, /// because `syncConfigProvider` is derived from them and from the session — /// setting the session last means the catalogue and order transports are /// rebuilt once, already pointing at the right location with the right /// token. Future _applySession(LoginSession session) async { final store = _ref.read(localStoreProvider); final catalogue = store.catalogue; // 1. The outlet, as the back office describes it. These are printed on // every GST invoice, so they come from the server rather than from the // build's constants. await catalogue.setMeta(MetaKeys.storeId, session.storeId); if (session.displayStoreName.isNotEmpty) { await catalogue.setMeta(MetaKeys.storeName, session.displayStoreName); } if (session.address.isNotEmpty) { await catalogue.setMeta(MetaKeys.storeAddress, session.address); } if (session.phone.isNotEmpty) { await catalogue.setMeta(MetaKeys.storePhone, session.phone); } // 2. This till now belongs to that outlet. Reloaded rather than left to a // restart: the cached identity is what every bill and topic reads. await store.identityStore.rename(storeId: session.storeId); await store.reloadTerminal(); _ref.invalidate(terminalIdentityProvider); // 3. The staff list, so PIN switching at the counter works against the // people head office actually employs. final me = await _syncStaff(session); // 4. Open the session. syncConfigProvider watches this, so the catalogue // pull and the order push pick up the token and location id from here. final account = await _ref.read(storeRepositoryProvider).load( email: session.email.isEmpty ? DemoCredentials.email : session.email, ); state = Authenticated( store: account, user: me, login: session.isAdmin ? TerminalLogin.admin : TerminalLogin.cashier, session: session, ); _ref.invalidate(storeAccountProvider); } /// Mirrors the back office's staff list onto this terminal, and returns the /// row for whoever just signed in. /// /// Additive on purpose. Deactivating everyone the server did not mention /// would lock a shop out of its own till the first time the endpoint answers /// with an empty array — which is exactly what the sample response does. Future _syncStaff(LoginSession session) async { final dao = _ref.read(localStoreProvider).staff; for (final member in session.staff) { if (member.userId == 0) continue; await dao.upsertFromServer( id: StaffDao.serverId(member.userId), name: member.fullName, role: member.staffRole, pin: member.pin, isActive: member.isActive, ); } // The person who signed in may not appear in that list — `staff` comes // back empty for a single-operator shop. They still need a row, because // every bill is stamped with a staff id. return dao.upsertFromServer( id: StaffDao.serverId(session.userId), name: session.displayUserName, role: session.staffRole, pin: session.whoAmI?.pin, ); } /// The built-in accounts, for a terminal with no line to the back office. /// /// Development only — see [ApiConfig.allowOfflineDemoLogin]. It reaches no /// server, so it sets no token: the catalogue cannot be pulled and bills /// cannot be pushed until a real sign-in happens. Future _signInOffline(String email, String password) async { final login = TerminalLogin.byEmail(email); if (login == null || password != login.password) return false; final store = await _ref.read(storeRepositoryProvider).load(email: email); if (store.staff.isEmpty) return false; final opener = store.staff.firstWhere( (s) => s.role == login.role, orElse: () => store.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, 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; _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, session: current.session, ); } /// Ends the session, and — for a cashier only — the catalogue with it. /// /// Every way out of a cashier session clears the products: ending a shift, /// and a temporary logout alike. There is no exception for stepping away for /// ten minutes, because the terminal is left unattended either way and the /// next session should bill against what the back office answers with rather /// than a catalogue carried over. /// /// 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. /// /// The bearer token needs no clearing: it lives on the session, so it goes /// when the state does, and `syncConfigProvider` is derived from it. 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; }); /// What the back office answered with, or null before sign-in. /// /// `select` rather than a plain watch: the whole sync configuration is derived /// from this, and rebuilding the transports on every intermediate auth state /// would tear down a connection mid-request. final apiSessionProvider = Provider((ref) { return ref.watch( authControllerProvider.select( (s) => s is Authenticated ? s.session : null, ), ); }); /// The name shown beside the logo — tenant first, then the outlet, then the /// store record on this terminal. final storeDisplayNameProvider = Provider((ref) { final s = ref.watch(authControllerProvider); return s is Authenticated ? s.storeName : ''; }); /// The outlet, for the line under the store name. final locationDisplayNameProvider = Provider((ref) { return ref.watch(apiSessionProvider)?.locationName.trim() ?? ''; }); /// 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; });