From 4a2474ee6c8ebe3e3faee192b41196fe02074846 Mon Sep 17 00:00:00 2001 From: Anbarasu22004 Date: Fri, 7 Aug 2026 17:07:49 +0530 Subject: [PATCH] added login --- lib/app/app.dart | 14 + lib/app/providers.dart | 35 +- lib/core/router/app_router.dart | 106 +++++- lib/data/local/session_store.dart | 83 +++++ lib/data/remote/pos_auth_api.dart | 163 ++++++++ lib/domain/entities/pos_session.dart | 287 ++++++++++++++ .../auth/providers/auth_controller.dart | 251 +++++++++---- .../auth/screens/login_screen.dart | 352 +++--------------- 8 files changed, 908 insertions(+), 383 deletions(-) create mode 100644 lib/data/local/session_store.dart create mode 100644 lib/data/remote/pos_auth_api.dart create mode 100644 lib/domain/entities/pos_session.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index 4c7eb3a..b796ec5 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -3,7 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/constants/app_constants.dart'; import '../core/router/app_router.dart'; +import '../core/theme/app_colors.dart'; import '../core/theme/app_theme.dart'; +import '../presentation/auth/providers/auth_controller.dart'; import '../presentation/sync/providers/sync_controller.dart'; class NearlePosApp extends ConsumerWidget { @@ -15,6 +17,18 @@ class NearlePosApp extends ConsumerWidget { // behind it. Nothing on screen depends on this having finished. ref.watch(syncBootstrapProvider); + // This one *is* awaited. Re-opening a stored session is a keystore read — + // a few milliseconds — and building the router before it lands would show + // an already-signed-in terminal the login screen and then snatch it away. + final restored = ref.watch(sessionBootstrapProvider); + + if (restored.isLoading) { + return const MaterialApp( + debugShowCheckedModeBanner: false, + home: ColoredBox(color: AppColors.background), + ); + } + return MaterialApp.router( title: AppConstants.appName, debugShowCheckedModeBanner: false, diff --git a/lib/app/providers.dart b/lib/app/providers.dart index c04e1c5..6d3a0c9 100644 --- a/lib/app/providers.dart +++ b/lib/app/providers.dart @@ -13,10 +13,12 @@ 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/pos_auth_api.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/sync/sync_engine.dart'; import '../domain/repositories/customer_repository.dart'; @@ -31,6 +33,34 @@ import '../presentation/auth/providers/auth_controller.dart'; /// Root data source. Overridden in tests with an in-memory double. final localStoreProvider = Provider((ref) => LocalStore.instance); +// ------------------------------------------------------------------- Auth +/// Where the back office lives. +/// +/// Its own provider, and deliberately free of any dependency on the session: +/// [posAuthApiProvider] needs it *before* anyone is signed in, so a base URL +/// derived from the session would be a cycle — sign-in needing the thing that +/// sign-in produces. +final backOfficeBaseUrlProvider = Provider( + (ref) => 'https://fiesta.nearle.app/live/api/v1/pos', +); + +/// Which back-office configuration this build's terminals belong to. Sent as +/// `configid` on every sign-in. +final posConfigIdProvider = Provider((ref) => 1); + +/// `POST /login`. One client, closed when the endpoint is re-pointed. +final posAuthApiProvider = Provider((ref) { + final api = PosAuthApi( + baseUrl: ref.watch(backOfficeBaseUrlProvider), + configId: ref.watch(posConfigIdProvider), + ); + ref.onDispose(api.dispose); + return api; +}); + +/// The signed-in session on disk, in the platform keystore. +final sessionStoreProvider = Provider((ref) => SessionStore()); + // ---------------------------------------------------------- Repositories final productRepositoryProvider = Provider( (ref) => ProductRepositoryImpl(ref.watch(localStoreProvider)), @@ -91,7 +121,7 @@ final syncConfigProvider = StateProvider((ref) { final terminal = ref.watch(terminalIdentityProvider); return SyncConfig( transport: TransportKind.http, - httpBaseUrl: 'https://fiesta.nearle.app/live/api/v1/pos', + httpBaseUrl: ref.watch(backOfficeBaseUrlProvider), storeId: terminal.storeId, terminalId: terminal.code, ); @@ -164,7 +194,8 @@ final storeRepositoryProvider = Provider( /// The outlet, refreshed whenever staff or details change. final storeAccountProvider = FutureProvider( (ref) => ref.watch(storeRepositoryProvider).load( - email: DemoCredentials.email, + // The account the terminal is signed in as, or blank before sign-in. + email: ref.watch(sessionAuthnameProvider), ), ); diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index d4dee76..627129b 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -15,42 +15,91 @@ class AppRoutes { static const String login = '/login'; - /// The terminal itself. Signing in lands here directly — customer capture - /// happens at checkout, not before the sale. + /// The admin shell: billing plus catalogue import, promos, staff, settings. + static const String adminDashboard = '/admin'; + + /// The cashier shell: billing, and nothing else. + static const String cashierDashboard = '/cashier'; + + /// Alias for "wherever this session lives". + /// + /// Kept because everything that returns to billing — finishing a receipt, + /// abandoning a payment — should land on the caller's own dashboard without + /// having to know which one that is. It never renders; [routerProvider] + /// resolves it to one of the two above. static const String pos = '/'; + static const String payment = '/payment'; - static const String receipt = '/receipt'; /// Drawer count and hand-over. Reached from the session-end chooser, never /// linked to directly, and guarded like every other signed-in route. static const String endShift = '/end-shift'; + + static const String receipt = '/receipt'; + + /// Which dashboard a session owns. + /// + /// The single place the role-to-screen decision is written down. `null` — + /// nobody signed in — resolves to the cashier till, which is the smaller of + /// the two; the guard sends an unauthenticated terminal to [login] before + /// this is ever reached. + static String homeFor(TerminalLogin? login) => + login == TerminalLogin.admin ? adminDashboard : cashierDashboard; + + /// True for the two dashboards, so the guard can spot a session sitting on + /// the wrong one. + static bool isDashboard(String location) => + location == adminDashboard || location == cashierDashboard; } -/// Router with an authentication guard. +/// Router with an authentication and role guard. /// -/// Every route except [AppRoutes.login] requires a signed-in store, and an -/// already-signed-in terminal is bounced away from the login screen. +/// Three rules, in order: +/// +/// 1. Every route except [AppRoutes.login] requires a signed-in session. +/// 2. A signed-in terminal is bounced off the login screen onto its own +/// dashboard — admin for Admin/Supervisor/Manager/Owner accounts, cashier +/// for everything else. See [PosSession.isCashier]. +/// 3. A session on the *other* role's dashboard is moved to its own. Typing +/// `/admin` on a cashier till must not open the back office, and the guard +/// is what makes that true regardless of how the route was reached. final routerProvider = Provider((ref) { - // GoRouter re-evaluates `redirect` whenever this notifier fires. - final authChanged = ValueNotifier( - ref.read(authControllerProvider).isAuthenticated, + // GoRouter re-evaluates `redirect` whenever this notifier fires. It carries + // the destination rather than a bare bool, so a role change — a cashier + // signing out and a supervisor signing in — also moves the terminal, which + // watching `isAuthenticated` alone would miss. + String? home(AuthState state) => state is Authenticated + ? AppRoutes.homeFor(state.login) + : null; + + final destination = ValueNotifier( + home(ref.read(authControllerProvider)), ); ref.listen( authControllerProvider, - (_, next) => authChanged.value = next.isAuthenticated, + (_, next) => destination.value = home(next), ); - ref.onDispose(authChanged.dispose); + ref.onDispose(destination.dispose); return GoRouter( initialLocation: AppRoutes.login, - refreshListenable: authChanged, + refreshListenable: destination, debugLogDiagnostics: false, redirect: (context, state) { - final signedIn = ref.read(authControllerProvider).isAuthenticated; - final atLogin = state.matchedLocation == AppRoutes.login; + final auth = ref.read(authControllerProvider); + final location = state.matchedLocation; + final atLogin = location == AppRoutes.login; + + if (auth is! Authenticated) return atLogin ? null : AppRoutes.login; + + final myHome = AppRoutes.homeFor(auth.login); + + // Signed in and still on the login screen, or on the `/` alias. + if (atLogin || location == AppRoutes.pos) return myHome; + + // On the other role's dashboard. + if (AppRoutes.isDashboard(location) && location != myHome) return myHome; - if (!signedIn) return atLogin ? null : AppRoutes.login; - if (atLogin) return AppRoutes.pos; return null; }, routes: [ @@ -59,12 +108,35 @@ final routerProvider = Provider((ref) { name: 'login', pageBuilder: (context, state) => _fade(state, const LoginScreen()), ), + + // Redirect-only. `/` is an alias, never a screen — the guard above has + // already resolved it, and this exists so the path matches a route at + // all rather than falling through to [errorBuilder]. GoRoute( path: AppRoutes.pos, - name: 'pos', + name: 'home', + redirect: (context, state) => AppRoutes.homeFor( + ref.read(terminalLoginProvider) ?? TerminalLogin.cashier, + ), + ), + + // Both dashboards are the same shell. It reads `isCashierModeProvider` + // and hides the sidebar, the catalogue and the back-office modules in + // cashier mode — so the two routes are the *addresses* of two shapes of + // one screen, not two screens to keep in step with each other. + GoRoute( + path: AppRoutes.adminDashboard, + name: 'adminDashboard', pageBuilder: (context, state) => _fade(state, const PosDashboardScreen()), ), + GoRoute( + path: AppRoutes.cashierDashboard, + name: 'cashierDashboard', + pageBuilder: (context, state) => + _fade(state, const PosDashboardScreen()), + ), + GoRoute( path: AppRoutes.endShift, name: 'endShift', diff --git a/lib/data/local/session_store.dart b/lib/data/local/session_store.dart new file mode 100644 index 0000000..1ffdc3e --- /dev/null +++ b/lib/data/local/session_store.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../../domain/entities/pos_session.dart'; + +/// Where the signed-in session lives between launches. +/// +/// The platform keystore, not SQLite — Keychain on macOS, Credential Manager +/// on Windows, the Android Keystore on a tablet. The response carries a bearer +/// token and every staff PIN in the clear, and the SQLite file sits on a +/// machine behind a shop counter readable by anything that can open it. +/// +/// Stored as one blob rather than field by field so [clear] is a single +/// delete. A sign-out that leaves half a session behind is worse than one that +/// leaves none. +class SessionStore { + SessionStore({FlutterSecureStorage? secureStorage}) + : _secure = secureStorage ?? const FlutterSecureStorage(); + + final FlutterSecureStorage _secure; + + static const String _key = 'pos.session'; + + /// The stored session, or null if there is none, it cannot be read, or it + /// has expired. + /// + /// An expired token is treated as absent and swept: carrying it forward only + /// moves the failure to the first call that uses it, which is a cashier + /// discovering it mid-sale rather than at the login screen. + Future read() async { + String? raw; + try { + raw = await _secure.read(key: _key); + } on Object catch (e) { + // No keystore — a headless test host, or a Linux box with no secret + // service. The terminal still runs, it just asks for credentials. + debugPrint('SessionStore: keystore unavailable ($e)'); + return null; + } + + if (raw == null || raw.isEmpty) return null; + + PosSession session; + try { + session = PosSession.fromJson(jsonDecode(raw) as Map); + } on Object catch (e) { + // A blob this build cannot parse — an upgrade that changed the shape. + // Drop it rather than failing every launch from here on. + debugPrint('SessionStore: unreadable session dropped ($e)'); + await clear(); + return null; + } + + if (session.token.isEmpty || session.isExpired) { + await clear(); + return null; + } + + return session; + } + + Future save(PosSession session) async { + try { + await _secure.write(key: _key, value: jsonEncode(session.toJson())); + } on Object catch (e) { + // Not fatal: the session is live in memory and this shift carries on. + // The next launch just asks for credentials again. + debugPrint('SessionStore: could not persist session ($e)'); + } + } + + /// Removes the session. Called on every sign-out, and on an expired or + /// rejected token. + Future clear() async { + try { + await _secure.delete(key: _key); + } on Object catch (e) { + debugPrint('SessionStore: could not clear session ($e)'); + } + } +} diff --git a/lib/data/remote/pos_auth_api.dart b/lib/data/remote/pos_auth_api.dart new file mode 100644 index 0000000..1ba0d13 --- /dev/null +++ b/lib/data/remote/pos_auth_api.dart @@ -0,0 +1,163 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../../domain/entities/pos_session.dart'; + +/// A sign-in that did not produce a session. +/// +/// Carries a message written for the person at the counter, not a status code. +/// [isCredentialFailure] separates "you typed the wrong password" from "the +/// shop's internet is down", because the first is the operator's problem to +/// fix and the second is not. +class AuthApiException implements Exception { + const AuthApiException( + this.message, { + this.isCredentialFailure = false, + this.statusCode, + }); + + final String message; + final bool isCredentialFailure; + final int? statusCode; + + @override + String toString() => message; +} + +/// Signs the terminal in against the back office. +/// +/// ``` +/// POST {base}/login +/// Content-Type: application/json +/// +/// { "authname": …, "password": …, "device_id": …, "configid": 1 } +/// ``` +/// +/// answering +/// +/// ```json +/// { "code": 200, "status": true, "message": "Login successful", +/// "details": { "token": …, "role": "Supervisor", … } } +/// ``` +/// +/// The envelope is checked rather than the HTTP status alone: this API answers +/// `200` with `status: false` for a rejected credential, so trusting the +/// status code would sign a terminal in on a failed login. +class PosAuthApi { + PosAuthApi({ + required this.baseUrl, + this.configId = 1, + http.Client? client, + }) : _client = client ?? http.Client(); + + /// Same base as the catalogue and order endpoints, e.g. + /// `https://fiesta.nearle.app/live/api/v1/pos`. + final String baseUrl; + + /// Which back-office configuration this terminal belongs to. + final int configId; + + final http.Client _client; + + static const Duration _timeout = Duration(seconds: 20); + + Future login({ + required String authname, + required String password, + required String deviceId, + }) async { + if (baseUrl.isEmpty) { + throw const AuthApiException( + 'No back-office URL is configured for this terminal. Set one in ' + 'Settings → Connectivity & sync → Configure.', + ); + } + + final uri = Uri.parse('${baseUrl.replaceAll(RegExp(r'/+$'), '')}/login'); + + http.Response response; + try { + response = await _client + .post( + uri, + headers: const { + 'content-type': 'application/json', + 'accept': 'application/json', + }, + body: jsonEncode({ + 'authname': authname.trim(), + 'password': password, + // This device's own identity, minted on first run. Two terminals + // must never sign in as the same device — the back office keys + // sessions on it. + 'device_id': deviceId, + 'configid': configId, + }), + ) + .timeout(_timeout); + } on TimeoutException { + throw const AuthApiException( + 'The back office did not answer in time. Check the connection and ' + 'try again.', + ); + } on http.ClientException { + // DNS failure, refused connection, dropped socket — the shop's line + // rather than the operator's credentials. + throw const AuthApiException( + 'Could not reach the back office. Check this terminal\'s internet ' + 'connection.', + ); + } on Exception catch (e) { + throw AuthApiException('Could not reach the back office: $e'); + } + + Map body; + try { + body = jsonDecode(response.body) as Map; + } on Object { + throw AuthApiException( + 'The back office answered with something this terminal could not ' + 'read (${response.statusCode}).', + statusCode: response.statusCode, + ); + } + + final ok = body['status'] == true && response.statusCode < 300; + + if (!ok) { + final raw = body['message']; + final message = raw is String ? raw.trim() : ''; + const rejectedCodes = {400, 401, 403, 422}; + + throw AuthApiException( + // The server's own wording, when it gave one. It knows whether the + // account is disabled, the device is unregistered or the password is + // simply wrong, and a generic message would throw that away. + message.isEmpty ? 'Sign-in failed (${response.statusCode}).' : message, + isCredentialFailure: rejectedCodes.contains(response.statusCode), + statusCode: response.statusCode, + ); + } + + final details = body['details']; + if (details is! Map) { + throw const AuthApiException( + 'The back office accepted the sign-in but sent no session back.', + ); + } + + final session = PosSession.fromDetails(details, authname: authname.trim()); + + if (session.token.isEmpty) { + throw const AuthApiException( + 'The back office accepted the sign-in but issued no token.', + ); + } + + return session; + } + + void dispose() => _client.close(); +} diff --git a/lib/domain/entities/pos_session.dart b/lib/domain/entities/pos_session.dart new file mode 100644 index 0000000..96a0760 --- /dev/null +++ b/lib/domain/entities/pos_session.dart @@ -0,0 +1,287 @@ +import 'package:equatable/equatable.dart'; + +import 'store_account.dart'; + +/// One outlet the signed-in account is allowed to work. +/// +/// A supervisor at a single-shop tenant gets one entry; a multi-outlet account +/// gets the list, which is what an outlet picker would be built from. +class SessionLocation extends Equatable { + const SessionLocation({ + required this.locationId, + required this.locationName, + required this.address, + required this.city, + required this.status, + }); + + final int locationId; + final String locationName; + final String address; + final String city; + final String status; + + bool get isActive => status.toLowerCase() == 'active'; + + factory SessionLocation.fromJson(Map json) => + SessionLocation( + locationId: _asInt(json['location_id']), + locationName: _asString(json['location_name']), + address: _asString(json['address']), + city: _asString(json['city']), + status: _asString(json['status']), + ); + + Map toJson() => { + 'location_id': locationId, + 'location_name': locationName, + 'address': address, + 'city': city, + 'status': status, + }; + + @override + List get props => [locationId, locationName, address, city, status]; +} + +/// A person the back office says may work this terminal. +/// +/// The `pin` the server returns is in the clear. It is kept because switching +/// operators at the till is a PIN entry and nothing else, but it is the reason +/// [PosSession] is written to the platform keystore rather than to SQLite — +/// and it is worth pushing the back office to return a hash instead. +class SessionStaff extends Equatable { + const SessionStaff({ + required this.userId, + required this.fullName, + required this.role, + required this.pin, + required this.status, + }); + + final int userId; + final String fullName; + final String role; + final String pin; + final String status; + + bool get isActive => status.toLowerCase() == 'active'; + + factory SessionStaff.fromJson(Map json) => SessionStaff( + userId: _asInt(json['user_id']), + fullName: _asString(json['full_name']), + role: _asString(json['role']), + pin: _asString(json['pin']), + status: _asString(json['status']), + ); + + Map toJson() => { + 'user_id': userId, + 'full_name': fullName, + 'role': role, + 'pin': pin, + 'status': status, + }; + + @override + List get props => [userId, fullName, role, pin, status]; +} + +/// Everything `POST /pos/login` answered with, plus the account it was issued +/// to. +/// +/// This is the whole session: the bearer token every later call needs, who is +/// signed in, and which outlet the terminal is now trading as. It is persisted +/// verbatim so a restart does not force a fresh sign-in, and dropped entirely +/// on sign-out. +class PosSession extends Equatable { + const PosSession({ + required this.token, + required this.authname, + required this.userId, + required this.fullName, + required this.roleId, + required this.role, + required this.tenantId, + required this.tenantName, + required this.storeId, + required this.locationId, + required this.locationName, + required this.address, + required this.gstin, + required this.phone, + this.expiresAt, + this.canManageStaff = false, + this.locations = const [], + this.staff = const [], + }); + + /// Bearer token for every subsequent call. Never logged, never printed. + final String token; + + /// The credential this session was opened with. Kept only so the login + /// screen can pre-fill it on the next shift. + final String authname; + + final int userId; + final String fullName; + + /// Numeric role from the back office (7 = Supervisor on this tenant). + /// + /// Recorded, but never the thing that decides what the terminal opens — see + /// [isCashier]. Ids are tenant configuration and can be renumbered; the role + /// name is the stable contract. + final int roleId; + + /// Role name as the server spells it — `Supervisor`, `Cashier`, `Admin`. + final String role; + + final bool canManageStaff; + + final int tenantId; + final String tenantName; + + /// The outlet, as a string, matching what the sync topics are namespaced on. + final String storeId; + final int locationId; + final String locationName; + + /// Printed on every invoice, so these come from the back office rather than + /// from anything typed into this terminal. + final String address; + final String gstin; + final String phone; + + final DateTime? expiresAt; + final List locations; + final List staff; + + /// Roles that get the full shell: catalogue import, promos, settings, staff. + static const Set adminRoles = { + 'admin', + 'administrator', + 'owner', + 'supervisor', + 'manager', + 'store manager', + }; + + /// Whether this session is locked down to the billing screen. + /// + /// Anything not in [adminRoles] lands here, including a role this build has + /// never seen. A new back-office role must not silently inherit catalogue + /// and settings access because nobody remembered to list it — the failure + /// should be "the supervisor sees a till", which someone reports in a + /// minute, not "the cashier can edit prices", which nobody notices. + bool get isCashier => !adminRoles.contains(role.trim().toLowerCase()); + + /// How this account maps onto the terminal's own permission model. + /// + /// Two values, not four: the shell has exactly two shapes, and every + /// non-cashier role the back office issues is expected to be able to import + /// products and edit the store's details — which is what `StaffRole.admin` + /// unlocks locally. + StaffRole get staffRole => isCashier ? StaffRole.cashier : StaffRole.admin; + + /// The operator, in the shape the rest of the app already speaks. + StaffUser get user => StaffUser( + id: '$userId', + name: fullName, + role: staffRole, + // The back office owns this credential now, so the terminal never + // forces a PIN change on an account it did not seed. + mustChangePin: false, + ); + + bool get isExpired => + expiresAt != null && !DateTime.now().toUtc().isBefore(expiresAt!.toUtc()); + + /// Reads the `details` object of a successful login response. + factory PosSession.fromDetails( + Map details, { + required String authname, + }) => + PosSession( + token: _asString(details['token']), + authname: authname, + userId: _asInt(details['user_id']), + fullName: _asString(details['full_name']), + roleId: _asInt(details['role_id']), + role: _asString(details['role']), + canManageStaff: _asBool(details['can_manage_staff']), + tenantId: _asInt(details['tenant_id']), + tenantName: _asString(details['tenant_name']), + storeId: _asString(details['store_id']), + locationId: _asInt(details['location_id']), + locationName: _asString(details['location_name']), + address: _asString(details['address']), + gstin: _asString(details['gstin']), + phone: _asString(details['phone']), + expiresAt: DateTime.tryParse(_asString(details['expires_at'])), + locations: _asList(details['locations'], SessionLocation.fromJson), + staff: _asList(details['staff'], SessionStaff.fromJson), + ); + + /// Round-trips through [toJson], for reading back out of the keystore. + factory PosSession.fromJson(Map json) => + PosSession.fromDetails(json, authname: _asString(json['authname'])); + + Map toJson() => { + 'token': token, + 'authname': authname, + 'user_id': userId, + 'full_name': fullName, + 'role_id': roleId, + 'role': role, + 'can_manage_staff': canManageStaff, + 'tenant_id': tenantId, + 'tenant_name': tenantName, + 'store_id': storeId, + 'location_id': locationId, + 'location_name': locationName, + 'address': address, + 'gstin': gstin, + 'phone': phone, + 'expires_at': expiresAt?.toUtc().toIso8601String(), + 'locations': locations.map((l) => l.toJson()).toList(), + 'staff': staff.map((s) => s.toJson()).toList(), + }; + + @override + List get props => [token, userId, roleId, role, locationId]; + + /// Never let a token reach a log line or a crash report. + @override + String toString() => + 'PosSession($fullName, $role, $locationName, expires $expiresAt)'; +} + +// --------------------------------------------------------------- Decoding +// +// Tolerant on purpose. `store_id` arrives as a string and `location_id` as a +// number for the same outlet, and a field the back office adds later must not +// crash a till mid-shift. + +String _asString(Object? value) => value == null ? '' : '$value'; + +int _asInt(Object? value) => switch (value) { + final int v => v, + final num v => v.toInt(), + final String v => int.tryParse(v) ?? 0, + _ => 0, + }; + +bool _asBool(Object? value) => switch (value) { + final bool v => v, + final num v => v != 0, + final String v => v.toLowerCase() == 'true' || v == '1', + _ => false, + }; + +List _asList(Object? raw, T Function(Map) decode) { + if (raw is! List) return const []; + return raw + .whereType>() + .map(decode) + .toList(growable: false); +} diff --git a/lib/presentation/auth/providers/auth_controller.dart b/lib/presentation/auth/providers/auth_controller.dart index acfa0df..30cbc2d 100644 --- a/lib/presentation/auth/providers/auth_controller.dart +++ b/lib/presentation/auth/providers/auth_controller.dart @@ -1,6 +1,10 @@ +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. @@ -23,6 +27,7 @@ class Authenticated extends AuthState { required this.store, required this.user, required this.login, + required this.session, }); final StoreAccount store; @@ -32,6 +37,10 @@ class Authenticated extends AuthState { /// 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; @@ -44,46 +53,37 @@ class AuthFailure extends AuthState { final String message; } -/// The two ways into this terminal. +/// The two shapes this terminal can take. /// -/// 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. +/// 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 roles are *for*, not decoration: +/// The split is what the two are *for*, not decoration: /// -/// * [admin] runs the whole shell and is the only login that can pull the +/// * [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', - 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; @@ -92,27 +92,12 @@ enum TerminalLogin { /// 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; - } + /// Which shell the back office's role name lands in. + static TerminalLogin forSession(PosSession session) => + session.isCashier ? TerminalLogin.cashier : TerminalLogin.admin; } -/// 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. +/// Validates store credentials against the back office and holds the session. class AuthController extends StateNotifier { AuthController(this._ref) : super(const Unauthenticated()); @@ -127,48 +112,143 @@ class AuthController extends StateNotifier { 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 email, + required String authname, required String password, }) async { state = const Authenticating(); - // Stand-in for the network round trip. - await Future.delayed(const Duration(milliseconds: 600)); + try { + final session = await _ref.read(posAuthApiProvider).login( + authname: authname, + password: password, + deviceId: _ref.read(terminalIdentityProvider).deviceId, + ); - final login = TerminalLogin.byEmail(email); - - if (login == null) { - state = const AuthFailure('No account is registered against that email.'); + await _open(session, persist: true); + return true; + } on AuthApiException catch (e) { + state = AuthFailure(e.message); 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) { + } on Object catch (e, stack) { + debugPrint('Sign-in failed: $e\n$stack'); state = const AuthFailure( - 'This terminal has no staff accounts. Reinstall to seed them.', + 'Sign-in failed unexpectedly. Please try again.', ); 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, + /// 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, ); + } - state = Authenticated(store: store, user: opener, login: login); - return true; + /// 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. @@ -190,6 +270,7 @@ class AuthController extends StateNotifier { store: current.store, user: user, login: current.login, + session: current.session, ); return true; } @@ -199,20 +280,27 @@ class AuthController extends StateNotifier { final current = state; if (current is! Authenticated) return; + final store = await _ref.read(storeRepositoryProvider).load( + email: current.session.authname, + ); _ref.invalidate(storeAccountProvider); - final store = await _ref.read(storeAccountProvider.future); + // 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, - // 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, + user: me.isNotEmpty ? me.first : current.session.user, login: current.login, + session: current.session, ); } - /// Ends the session, and — for a cashier only — the catalogue with it. + /// 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, @@ -226,6 +314,12 @@ class AuthController extends StateNotifier { 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(); } @@ -250,7 +344,20 @@ final currentUserProvider = Provider((ref) { return s is Authenticated ? s.user : null; }); -/// Which credential is holding this session open, or null before sign-in. +/// 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; @@ -273,3 +380,11 @@ 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(), +); diff --git a/lib/presentation/auth/screens/login_screen.dart b/lib/presentation/auth/screens/login_screen.dart index c35c54a..d627162 100644 --- a/lib/presentation/auth/screens/login_screen.dart +++ b/lib/presentation/auth/screens/login_screen.dart @@ -1,5 +1,3 @@ -import 'dart:ui' as ui; - import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -10,12 +8,11 @@ import '../../../core/constants/asset_paths.dart'; import '../../../core/router/app_router.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; -import '../../../core/utils/validators.dart'; -import '../../../core/widgets/brand_mark.dart'; import '../../../core/widgets/primary_button.dart'; import '../providers/auth_controller.dart'; -/// Store sign-in. The terminal shows this until a valid account is entered. +/// Store sign-in. The terminal shows this until the back office issues a +/// session. /// /// One centred card on a plain background, at every width. The split-screen /// version put a marketing panel beside the form, which meant the thing the @@ -23,6 +20,11 @@ import '../providers/auth_controller.dart'; /// different width on every monitor, and collapsed into a different layout /// below 1000px. A till is signed into at the start of a shift by someone who /// already bought the product; the pitch was costing the form its position. +/// +/// There is no role picker. The role comes back in the login response and is +/// the server's to decide — a tab on this screen would only ever have been a +/// hint, and a hint that disagreed with the response would be a bug someone +/// spends an afternoon on. class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); @@ -33,30 +35,15 @@ class LoginScreen extends ConsumerStatefulWidget { class _LoginScreenState extends ConsumerState { final _formKey = GlobalKey(); - /// Which of the two default accounts the tabs are pointing at. Only a - /// convenience for filling the fields — [AuthController.signIn] decides the - /// role from the email that is actually submitted, so typing a different - /// address over the top still signs in as that account. - TerminalLogin _login = TerminalLogin.admin; - - late final _email = TextEditingController(text: _login.email); - late final _password = TextEditingController(text: _login.password); + final _authname = TextEditingController(); + final _password = TextEditingController(); bool _obscure = true; bool _rememberTerminal = true; - void _selectLogin(TerminalLogin login) { - setState(() { - _login = login; - _email.text = login.email; - _password.text = login.password; - }); - ref.read(authControllerProvider.notifier).clearError(); - } - @override void dispose() { - _email.dispose(); + _authname.dispose(); _password.dispose(); super.dispose(); } @@ -66,11 +53,15 @@ class _LoginScreenState extends ConsumerState { if (!(_formKey.currentState?.validate() ?? false)) return; final ok = await ref.read(authControllerProvider.notifier).signIn( - email: _email.text, + authname: _authname.text, password: _password.text, ); - if (ok && mounted) context.go(AppRoutes.pos); + if (!ok || !mounted) return; + + // Admin accounts land on the full shell, cashiers on the till. Read from + // the session that was just opened rather than from anything typed here. + context.go(AppRoutes.homeFor(ref.read(terminalLoginProvider))); } @override @@ -109,21 +100,18 @@ class _LoginScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // _Masthead(tight: tight), SizedBox( height: tight ? AppSpacing.lg : AppSpacing.xl, ), _Card( formKey: _formKey, - email: _email, + authname: _authname, password: _password, obscure: _obscure, rememberTerminal: _rememberTerminal, - login: _login, busy: busy, failure: auth is AuthFailure ? auth.message : null, - onSelectLogin: _selectLogin, onToggleObscure: () => setState(() => _obscure = !_obscure), onToggleRemember: (v) => @@ -157,11 +145,10 @@ class _LoginScreenState extends ConsumerState { /// The shopfront behind the form. /// -/// Blurred hard and darkened, because it is atmosphere rather than something -/// to read: a sharp photograph under a sign-in card competes with the two -/// fields the person came here to fill in. Scaled up slightly before the blur -/// so the softened edges are pushed off-screen instead of showing as a pale -/// border. +/// Darkened, because it is atmosphere rather than something to read: a sharp +/// photograph under a sign-in card competes with the two fields the person came +/// here to fill in. Scaled up slightly so the edges are pushed off-screen +/// instead of showing as a pale border. /// /// Falls back to the plain background colour if the asset is missing, so an /// undeclared file costs the login screen its atmosphere and not its function. @@ -174,24 +161,21 @@ class _Backdrop extends StatelessWidget { fit: StackFit.expand, children: [ ClipRect( - child: ImageFiltered( - imageFilter: ui.ImageFilter.blur(sigmaX: 0, sigmaY: 0), - child: Transform.scale( - scale: 1.12, - child: Image.asset( - AssetPaths.loginBackground, - fit: BoxFit.cover, - filterQuality: FilterQuality.medium, - errorBuilder: (context, _, __) => - const ColoredBox(color: AppColors.background), - ), + child: Transform.scale( + scale: 1.12, + child: Image.asset( + AssetPaths.loginBackground, + fit: BoxFit.cover, + filterQuality: FilterQuality.medium, + errorBuilder: (context, _, __) => + const ColoredBox(color: AppColors.background), ), ), ), - // Two layers, not one: the flat wash guarantees contrast for the white - // masthead wherever the photograph happens to be pale, and the gradient - // puts the darkest part behind the text rather than spreading it evenly - // and flattening the image out. + // Two layers, not one: the flat wash guarantees contrast wherever the + // photograph happens to be pale, and the gradient puts the darkest part + // behind the card rather than spreading it evenly and flattening the + // image out. const ColoredBox(color: Color(0x8A1A0B22)), const DecoratedBox( decoration: BoxDecoration( @@ -207,74 +191,27 @@ class _Backdrop extends StatelessWidget { } } -/// Mark and product name, directly above the card. -class _Masthead extends StatelessWidget { - const _Masthead({required this.tight}); - - final bool tight; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - BrandMark(size: tight ? 48 : 60), - const SizedBox(height: AppSpacing.md), - const Text( - 'Nearle POS', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - letterSpacing: -0.4, - color: Colors.white, - height: 1.2, - shadows: [ - Shadow(color: Color(0x66000000), blurRadius: 12), - ], - ), - ), - if (!tight) ...[ - const SizedBox(height: 2), - Text( - 'Scanner-first billing for Indian retail', - style: TextStyle( - fontSize: 13, - color: Colors.white.withValues(alpha: 0.82), - shadows: const [ - Shadow(color: Color(0x55000000), blurRadius: 10), - ], - ), - ), - ], - ], - ).animate().fadeIn(duration: 260.ms); - } -} - class _Card extends StatelessWidget { const _Card({ required this.formKey, - required this.email, + required this.authname, required this.password, required this.obscure, required this.rememberTerminal, - required this.login, required this.busy, required this.failure, - required this.onSelectLogin, required this.onToggleObscure, required this.onToggleRemember, required this.onSubmit, }); final GlobalKey formKey; - final TextEditingController email; + final TextEditingController authname; final TextEditingController password; final bool obscure; final bool rememberTerminal; - final TerminalLogin login; final bool busy; final String? failure; - final ValueChanged onSelectLogin; final VoidCallback onToggleObscure; final ValueChanged onToggleRemember; final VoidCallback onSubmit; @@ -313,7 +250,8 @@ class _Card extends StatelessWidget { ), const SizedBox(height: 2), const Text( - 'Use the credentials issued when your outlet was registered.', + 'Use the terminal account issued when your outlet was ' + 'registered.', style: TextStyle( fontSize: 13, color: AppColors.textSecondary, @@ -322,33 +260,21 @@ class _Card extends StatelessWidget { ), const SizedBox(height: AppSpacing.xl), - _RoleSwitch( - selected: login, - enabled: !busy, - onSelect: onSelectLogin, - ), - const SizedBox(height: AppSpacing.sm), - Text( - login.blurb, - style: const TextStyle( - fontSize: 12.5, - color: AppColors.textTertiary, - height: 1.45, - ), - ), - const SizedBox(height: AppSpacing.xl), - - const _Label('Store email'), + const _Label('Terminal account'), TextFormField( - controller: email, + controller: authname, keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, enabled: !busy, + autocorrect: false, + // Deliberately not validated as an email address. It looks like + // one, but it is an account name the back office issues and its + // shape is theirs to change. validator: (v) => (v ?? '').trim().isEmpty - ? 'Store email is required' - : Validators.emailOptional(v), + ? 'The terminal account is required' + : null, decoration: const InputDecoration( - hintText: 'store@example.in', + hintText: 'supervisor.1135@pos.nearle.in', prefixIcon: Icon(Icons.storefront_outlined), ), ), @@ -361,9 +287,11 @@ class _Card extends StatelessWidget { enabled: !busy, textInputAction: TextInputAction.done, onFieldSubmitted: (_) => onSubmit(), - validator: (v) => (v ?? '').isEmpty - ? 'Password is required' - : ((v ?? '').length < 6 ? 'Password looks too short' : null), + // Length is the server's rule to enforce. Refusing to *send* a + // short password only produces a second, different error message + // for the same wrong credential. + validator: (v) => + (v ?? '').isEmpty ? 'Password is required' : null, decoration: InputDecoration( hintText: 'Enter your password', prefixIcon: const Icon(Icons.lock_outline_rounded), @@ -432,8 +360,11 @@ class _Card extends StatelessWidget { ), child: Row( children: [ - const Icon(Icons.error_outline_rounded, - color: AppColors.danger, size: 18,), + const Icon( + Icons.error_outline_rounded, + color: AppColors.danger, + size: 18, + ), const SizedBox(width: AppSpacing.sm), Expanded( child: Text( @@ -457,12 +388,6 @@ class _Card extends StatelessWidget { busy: busy, onPressed: onSubmit, ), - - const SizedBox(height: AppSpacing.lg), - _DemoHint( - login: login, - onFill: busy ? null : () => onSelectLogin(login), - ), ], ), ), @@ -490,168 +415,3 @@ class _Label extends StatelessWidget { ); } } - -/// Which of the two default accounts is being signed into. -/// -/// The roles are not cosmetic — they decide whether the terminal opens the -/// full shell or the billing screen alone, and whether signing out leaves the -/// products behind — so the choice is made before the credentials rather than -/// inferred from them afterwards. -class _RoleSwitch extends StatelessWidget { - const _RoleSwitch({ - required this.selected, - required this.enabled, - required this.onSelect, - }); - - final TerminalLogin selected; - final bool enabled; - final ValueChanged onSelect; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: AppColors.surfaceAlt, - borderRadius: AppRadius.brSm, - border: Border.all(color: AppColors.border), - ), - child: Row( - children: [ - for (final login in TerminalLogin.values) - Expanded( - child: _RoleTab( - login: login, - selected: login == selected, - enabled: enabled, - onTap: () => onSelect(login), - ), - ), - ], - ), - ); - } -} - -class _RoleTab extends StatelessWidget { - const _RoleTab({ - required this.login, - required this.selected, - required this.enabled, - required this.onTap, - }); - - final TerminalLogin login; - final bool selected; - final bool enabled; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final icon = login == TerminalLogin.admin - ? Icons.admin_panel_settings_outlined - : Icons.point_of_sale_rounded; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: enabled ? onTap : null, - borderRadius: AppRadius.brXs, - child: AnimatedContainer( - duration: const Duration(milliseconds: 160), - height: 40, - alignment: Alignment.center, - decoration: BoxDecoration( - color: selected ? AppColors.surface : Colors.transparent, - borderRadius: AppRadius.brXs, - border: Border.all( - color: selected ? AppColors.primaryBorder : Colors.transparent, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: 17, - color: selected ? AppColors.primary : AppColors.textSecondary, - ), - const SizedBox(width: AppSpacing.sm), - Flexible( - child: Text( - login.label, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13.5, - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, - color: - selected ? AppColors.primary : AppColors.textSecondary, - ), - ), - ), - ], - ), - ), - ), - ); - } -} - -class _DemoHint extends StatelessWidget { - const _DemoHint({required this.login, this.onFill}); - - final TerminalLogin login; - final VoidCallback? onFill; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppColors.primarySurface, - borderRadius: AppRadius.brSm, - border: Border.all(color: AppColors.primaryBorder), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.info_outline_rounded, - size: 17, color: AppColors.primary,), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Default ${login.label.toLowerCase()} account', - style: const TextStyle( - fontSize: 12.5, - fontWeight: FontWeight.w600, - color: AppColors.primary, - ), - ), - const SizedBox(height: 2), - SelectableText( - '${login.email} \u00b7 ${login.password}', - style: const TextStyle( - fontSize: 12, - color: AppColors.textSecondary, - ), - ), - ], - ), - ), - TextButton( - onPressed: onFill, - style: TextButton.styleFrom( - minimumSize: const Size(0, 32), - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), - ), - child: const Text('Fill', style: TextStyle(fontSize: 12.5)), - ), - ], - ), - ); - } -}