From ad44402232470130acff6c439ce4a1818bbee6ee Mon Sep 17 00:00:00 2001 From: Anbarasu22004 Date: Fri, 7 Aug 2026 15:31:20 +0530 Subject: [PATCH] check --- lib/app/providers.dart | 42 +- lib/core/config/api_config.dart | 41 -- lib/core/constants/asset_paths.dart | 4 + lib/data/datasources/local_store.dart | 11 - lib/data/local/staff_dao.dart | 81 ---- lib/data/remote/auth_api.dart | 378 ------------------ .../auth/providers/auth_controller.dart | 245 ++---------- .../auth/screens/login_screen.dart | 342 ++++++++++++++-- .../widgets/customer_capture_sheet.dart | 306 +++++--------- .../modules/widgets/module_widgets.dart | 6 + .../payment/screens/payment_screen.dart | 5 - .../pos/screens/pos_dashboard_screen.dart | 7 + lib/presentation/pos/widgets/app_sidebar.dart | 39 +- .../pos/widgets/billing_panel.dart | 60 +-- lib/presentation/pos/widgets/page_header.dart | 12 +- .../shift/screens/end_shift_screen.dart | 106 +++-- .../shift/widgets/session_end_sheet.dart | 100 ++--- 17 files changed, 598 insertions(+), 1187 deletions(-) delete mode 100644 lib/core/config/api_config.dart delete mode 100644 lib/data/remote/auth_api.dart diff --git a/lib/app/providers.dart b/lib/app/providers.dart index b5b50a3..c04e1c5 100644 --- a/lib/app/providers.dart +++ b/lib/app/providers.dart @@ -1,6 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../core/config/api_config.dart'; import '../core/config/sync_config.dart'; import '../core/services/connectivity_service.dart'; import '../core/services/receipt_service.dart'; @@ -8,7 +7,6 @@ import '../core/services/sound_service.dart'; import '../data/datasources/local_store.dart'; import '../data/repositories/customer_repository_impl.dart'; import '../data/repositories/product_repository_impl.dart'; -import '../data/remote/auth_api.dart'; import '../data/remote/catalogue_source.dart'; import '../data/remote/http_catalogue_source.dart'; import '../data/remote/simulated_catalogue_source.dart'; @@ -91,30 +89,14 @@ final catalogueSourceProvider = Provider((ref) { /// exists to prevent. final syncConfigProvider = StateProvider((ref) { final terminal = ref.watch(terminalIdentityProvider); - final session = ref.watch(apiSessionProvider); - return SyncConfig( transport: TransportKind.http, - httpBaseUrl: ApiConfig.baseUrl, - // The location the sign-in was scoped to, falling back to whatever this - // device was last pointed at. Never a literal: two outlets sharing a - // store id would post sales into each other's books. - storeId: session?.storeId ?? terminal.storeId, + httpBaseUrl: 'https://fiesta.nearle.app/live/api/v1/pos', + storeId: terminal.storeId, terminalId: terminal.code, - // The bearer token from `POST /login`. Held here rather than in the - // keystore because it expires — see `LoginSession.expiresAt` — so it - // belongs to the session and goes when the session does. - apiKey: session?.token, ); }); -/// Signs the terminal in. Owns its own HTTP client, closed with the provider. -final authApiProvider = Provider((ref) { - final api = AuthApi(); - ref.onDispose(api.dispose); - return api; -}); - /// Real network state, folded with the Settings offline switch. final connectivityServiceProvider = Provider((ref) { final service = ConnectivityService( @@ -180,15 +162,11 @@ final storeRepositoryProvider = Provider( ); /// The outlet, refreshed whenever staff or details change. -final storeAccountProvider = FutureProvider((ref) { - // The address the back office signed in, so Settings and the invoice header - // show the account that actually owns this till rather than a build - // constant. Falls back only before anyone has signed in. - final email = ref.watch(apiSessionProvider)?.email; - return ref.watch(storeRepositoryProvider).load( - email: (email == null || email.isEmpty) ? DemoCredentials.email : email, - ); -}); +final storeAccountProvider = FutureProvider( + (ref) => ref.watch(storeRepositoryProvider).load( + email: DemoCredentials.email, + ), +); /// Campaigns stored on this terminal. final promosProvider = FutureProvider>( @@ -248,11 +226,9 @@ final terminalIdentityProvider = Provider((ref) { final cashierSessionProvider = StateProvider((ref) { final terminal = ref.watch(terminalIdentityProvider); - final session = ref.watch(apiSessionProvider); - return CashierSession( - name: session?.displayUserName ?? 'Operator', - role: (session?.isAdmin ?? false) ? 'ADMIN' : 'CASHIER', + name: 'Suriya', + role: 'ADMIN', terminalId: terminal.code, ); }); diff --git a/lib/core/config/api_config.dart b/lib/core/config/api_config.dart deleted file mode 100644 index 61b3041..0000000 --- a/lib/core/config/api_config.dart +++ /dev/null @@ -1,41 +0,0 @@ -/// Where the back office lives, and the fixed parts of a request to it. -/// -/// Kept apart from [SyncConfig] on purpose. That one describes how *this -/// terminal* was configured — which broker, which store, which credentials — -/// and can be re-pointed from Settings without a rebuild. This one is the -/// deployment's own address, needed before anyone has signed in and therefore -/// before there is a configuration to read. -class ApiConfig { - const ApiConfig._(); - - /// Root of the POS API. Every other path below hangs off it. - /// - /// The same base the catalogue and order endpoints already use, so pointing - /// a build at staging is one edit rather than three. - static const String baseUrl = 'https://fiesta.nearle.app/live/api/v1/pos'; - - /// `POST {baseUrl}{loginPath}` - /// - /// ```json - /// { "authname": "…", "password": "…", "device_id": "…", "configid": 1 } - /// ``` - static const String loginPath = '/login'; - - /// Which configuration profile the back office should answer with. Fixed for - /// this build; the server rejects a value it does not recognise. - static const int configId = 1; - - /// Sign-in is the one call a person is watching, so it fails faster than the - /// catalogue pull does — thirty seconds of a spinner at the start of a shift - /// reads as a hung terminal. - static const Duration timeout = Duration(seconds: 25); - - /// Whether the built-in demo accounts still work when the back office cannot - /// be reached at all. - /// - /// Set false for a real deployment. It exists so the app can be run against - /// no server during development; it deliberately does **not** trigger on a - /// rejected password, only on a network fault, so a wrong password never - /// silently falls through to a local account. - static const bool allowOfflineDemoLogin = true; -} diff --git a/lib/core/constants/asset_paths.dart b/lib/core/constants/asset_paths.dart index d2cbd80..69845e8 100644 --- a/lib/core/constants/asset_paths.dart +++ b/lib/core/constants/asset_paths.dart @@ -13,6 +13,10 @@ class AssetPaths { /// the login screen, the sidebar and the cashier header. static const String logo = '$_img/logo.png'; + /// Shopfront photograph behind the sign-in screen. Blurred and darkened in + /// place, so it reads as atmosphere rather than as something to look at. + static const String loginBackground = '$_img/bg.webp'; + static const String beepSuccess = '$_snd/beep_success.wav'; static const String beepError = '$_snd/beep_error.wav'; static const String chargeComplete = '$_snd/charge_complete.wav'; diff --git a/lib/data/datasources/local_store.dart b/lib/data/datasources/local_store.dart index 212f199..d98798f 100644 --- a/lib/data/datasources/local_store.dart +++ b/lib/data/datasources/local_store.dart @@ -76,17 +76,6 @@ class LocalStore { _ready = true; } - /// Re-reads the terminal's identity after it has been re-pointed at another - /// outlet. - /// - /// [terminal] is cached on this object because every bill, topic and presence - /// record reads it — but signing in can change which store the till belongs - /// to, and the cached copy would go on naming the old one until a restart. - Future reloadTerminal() async { - terminal = await identityStore.load(); - return terminal; - } - /// Re-reads cached state from disk. Called on start and after an import. Future hydrate() async { _products diff --git a/lib/data/local/staff_dao.dart b/lib/data/local/staff_dao.dart index cc6619c..414d9d6 100644 --- a/lib/data/local/staff_dao.dart +++ b/lib/data/local/staff_dao.dart @@ -146,87 +146,6 @@ class StaffDao { ); } - /// Writes a staff member the back office owns, creating or updating the row. - /// - /// Deliberately skips [_assertPinIsAcceptable] and the duplicate-PIN check. - /// Those rules exist to stop *this terminal* from accepting a weak PIN - /// someone typed at the counter; they are not this terminal's to enforce on - /// a list the back office has already published. Applying them here would - /// mean a shop whose head office issued `1111` simply never receives its - /// staff, and the till falls back to seeded demo accounts — a worse outcome - /// than a guessable PIN. - /// - /// [id] is derived from the server's `user_id` rather than minted, so a - /// second sign-in updates the same row instead of duplicating the person. - /// - /// A null [pin] leaves an existing PIN alone, and on a new row stores an - /// unusable hash: the person appears on the staff list and can be attributed - /// bills, but nothing typed at the keypad will ever match them. That is the - /// honest representation of "the back office did not give us their PIN". - Future upsertFromServer({ - required String id, - required String name, - required StaffRole role, - String? pin, - bool isActive = true, - }) async { - final trimmed = name.trim().isEmpty ? 'Staff' : name.trim(); - final now = DateTime.now().millisecondsSinceEpoch; - final existing = await findById(id); - - // Minted once and reused for both columns. PinHasher.newSalt() is random, - // so calling it twice in one statement would store a hash the stored salt - // cannot reproduce. - final salt = PinHasher.newSalt(); - - if (existing != null) { - await _db.update( - Tables.staff, - { - 'name': trimmed, - 'role': role.name, - 'is_active': isActive ? 1 : 0, - if (pin != null) ...{ - 'pin_hash': PinHasher.hash(pin, salt), - 'pin_salt': salt, - 'must_change_pin': 0, - }, - 'updated_at': now, - }, - where: 'id = ?', - whereArgs: [id], - ); - return StaffUser(id: id, name: trimmed, role: role, isActive: isActive); - } - - // No PIN from the server means no PIN that can ever be entered: hashing a - // random value is how that is stored, rather than a sentinel a future - // reader might treat as "any PIN accepted". - final secret = pin ?? _uuid.v4(); - - await _db.insert( - Tables.staff, - { - 'id': id, - 'name': trimmed, - 'role': role.name, - 'pin_hash': PinHasher.hash(secret, salt), - 'pin_salt': salt, - 'must_change_pin': 0, - 'is_active': isActive ? 1 : 0, - 'created_at': now, - 'updated_at': now, - }, - conflictAlgorithm: ConflictAlgorithm.replace, - ); - - return StaffUser(id: id, name: trimmed, role: role, isActive: isActive); - } - - /// A stable local id for a back-office user, so a second sign-in updates the - /// same row instead of duplicating the person. - static String serverId(int userId) => 'srv-$userId'; - Future updateDetails({ required String id, String? name, diff --git a/lib/data/remote/auth_api.dart b/lib/data/remote/auth_api.dart deleted file mode 100644 index 8d06f63..0000000 --- a/lib/data/remote/auth_api.dart +++ /dev/null @@ -1,378 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart' as http; - -import '../../core/config/api_config.dart'; -import '../../domain/entities/store_account.dart'; - -/// A back office that answered, but not with what was asked for. -/// -/// [isNetworkFault] separates "the server said no" from "there was no server". -/// The first is final — a wrong password does not become right on a retry, and -/// must never fall through to a local account. The second is the shop's line -/// being down, which is the only case an offline path may consider. -class ApiException implements Exception { - const ApiException( - this.message, { - this.statusCode, - this.isNetworkFault = false, - }); - - final String message; - final int? statusCode; - final bool isNetworkFault; - - @override - String toString() => message; -} - -/// One person on the store's staff list, as the back office describes them. -/// -/// ```json -/// { "user_id": 1148, "full_name": "Ragul Kannan", -/// "role": "Super admin", "pin": "1111", "status": "Active" } -/// ``` -class ApiStaffMember { - const ApiStaffMember({ - required this.userId, - required this.fullName, - required this.role, - this.pin, - this.status = 'Active', - }); - - final int userId; - final String fullName; - - /// Free text from the back office: `Super admin`, `Admin`, `Cashier`, - /// `Manager`. Compared case- and space-insensitively, never matched exactly. - final String role; - - /// The till PIN. May be absent, in which case this person exists on the - /// staff list but cannot be switched to at the counter. - final String? pin; - - final String status; - - bool get isActive => status.trim().toLowerCase() == 'active'; - - StaffRole get staffRole => ApiRoles.toStaffRole(role); - - bool get isAdmin => ApiRoles.isAdminRole(role); - - static ApiStaffMember fromJson(Map json) => ApiStaffMember( - userId: _asInt(json['user_id']) ?? 0, - fullName: (json['full_name'] as String? ?? '').trim(), - role: (json['role'] as String? ?? '').trim(), - pin: _nonEmpty(json['pin'] as String?), - status: (json['status'] as String? ?? 'Active').trim(), - ); -} - -/// An outlet this account can sign into. -class ApiLocation { - const ApiLocation({ - required this.locationId, - required this.locationName, - this.address = '', - this.city = '', - this.status = 'Active', - }); - - final int locationId; - final String locationName; - final String address; - final String city; - final String status; - - bool get isActive => status.trim().toLowerCase() == 'active'; - - static ApiLocation fromJson(Map json) => ApiLocation( - locationId: _asInt(json['location_id']) ?? 0, - locationName: (json['location_name'] as String? ?? '').trim(), - address: (json['address'] as String? ?? '').trim(), - city: (json['city'] as String? ?? '').trim(), - status: (json['status'] as String? ?? 'Active').trim(), - ); -} - -/// Everything the back office hands back on a successful sign-in. -/// -/// This is the terminal's whole identity for the rest of the session: the -/// bearer token every other call carries, the location id the catalogue and -/// order endpoints are scoped to, and the tenant name shown beside the logo. -/// Nothing below it is guessed or held as a constant. -class LoginSession { - const LoginSession({ - required this.token, - required this.userId, - required this.email, - required this.roleId, - required this.tenantId, - required this.tenantName, - required this.storeId, - required this.locationId, - required this.locationName, - this.fullName = '', - this.address = '', - this.phone = '', - this.expiresAt, - this.locations = const [], - this.staff = const [], - }); - - /// Bearer token. Sent as `authorization: Bearer …` on the catalogue pull and - /// on every order push — see `SyncConfig.apiKey`, which this fills. - final String token; - - final DateTime? expiresAt; - - final int userId; - final String fullName; - final String email; - - /// The back office's numeric role for this account. Used only when [staff] - /// does not name this person — see [isAdmin]. - final int roleId; - - final int tenantId; - final String tenantName; - - /// The back office sends this as a string; kept as one because it is used as - /// a path and query value, never arithmetically. - final String storeId; - - final int locationId; - final String locationName; - final String address; - final String phone; - - final List locations; - final List staff; - - /// What goes beside the logo. The tenant is the business, the location is - /// the branch — a single-outlet shop usually sets both to the same thing. - String get displayStoreName => - tenantName.isNotEmpty ? tenantName : locationName; - - /// Who signed in, for the profile row and for stamping bills. - String get displayUserName { - if (fullName.isNotEmpty) return fullName; - final me = whoAmI; - if (me != null && me.fullName.isNotEmpty) return me.fullName; - final at = email.indexOf('@'); - return at > 0 ? email.substring(0, at) : email; - } - - /// This account's own row in [staff], when the back office included one. - ApiStaffMember? get whoAmI { - for (final s in staff) { - if (s.userId == userId) return s; - } - return null; - } - - /// Which screen the terminal opens on. - /// - /// The staff list is the authority when it names this person, because it - /// carries the role as text — `Super admin`, `Cashier` — which is what the - /// back office actually manages. [roleId] is the fallback for the case in - /// the sample response, where `staff` comes back empty. - bool get isAdmin { - final me = whoAmI; - if (me != null && me.role.isNotEmpty) return me.isAdmin; - return ApiRoles.adminRoleIds.contains(roleId); - } - - /// The role to file this person under locally. - StaffRole get staffRole { - final me = whoAmI; - if (me != null && me.role.isNotEmpty) return me.staffRole; - return isAdmin ? StaffRole.admin : StaffRole.cashier; - } - - bool get isExpired => - expiresAt != null && DateTime.now().isAfter(expiresAt!.toLocal()); - - /// Reads `details`, not the envelope. [AuthApi.login] has already checked - /// `status` and `code` by the time this runs. - static LoginSession fromDetails(Map d) { - final locationId = _asInt(d['location_id']) ?? 0; - - return LoginSession( - token: (d['token'] as String? ?? '').trim(), - expiresAt: DateTime.tryParse(d['expires_at'] as String? ?? ''), - userId: _asInt(d['user_id']) ?? 0, - fullName: (d['full_name'] as String? ?? '').trim(), - email: (d['email'] as String? ?? '').trim(), - roleId: _asInt(d['role_id']) ?? 0, - tenantId: _asInt(d['tenant_id']) ?? 0, - tenantName: (d['tenant_name'] as String? ?? '').trim(), - // Falls back to the location id: they are the same number in every - // response seen so far, and a blank store id would scope the catalogue - // pull to nothing. - storeId: _nonEmpty(d['store_id']?.toString()) ?? '$locationId', - locationId: locationId, - locationName: (d['location_name'] as String? ?? '').trim(), - address: (d['address'] as String? ?? '').trim(), - phone: (d['phone'] as String? ?? '').trim(), - locations: _list(d['locations'], ApiLocation.fromJson), - staff: _list(d['staff'], ApiStaffMember.fromJson), - ); - } -} - -/// Maps the back office's role names onto what the terminal may open. -class ApiRoles { - const ApiRoles._(); - - /// Numeric roles that open the full shell, for responses whose `staff` array - /// is empty. `1` is Super admin. - /// - /// Add to this rather than to the string rules if the back office introduces - /// another privileged role id. - static const Set adminRoleIds = {1, 2}; - - /// Case, spacing and punctuation are all ignored: `Super admin`, - /// `super_admin` and `SUPERADMIN` are one role, and the back office is free - /// to change how it writes them. - static String _normalise(String role) => - role.toLowerCase().replaceAll(RegExp(r'[^a-z]'), ''); - - /// True for anything that should land on the admin shell rather than the - /// billing screen alone. - static bool isAdminRole(String role) { - final r = _normalise(role); - if (r.isEmpty) return false; - // `superadmin` contains `admin`, so one check covers both. - return r.contains('admin') || r == 'owner'; - } - - static StaffRole toStaffRole(String role) { - final r = _normalise(role); - if (r.contains('admin') || r == 'owner') return StaffRole.admin; - if (r.contains('manager') || r.contains('supervisor')) { - return StaffRole.manager; - } - return StaffRole.cashier; - } -} - -/// Signs a terminal in against the back office. -/// -/// ``` -/// POST {base}/login -/// { "authname": "rmart@gmail.com", "password": "…", -/// "device_id": "a5f3c8b9-…", "configid": 1 } -/// ``` -/// -/// `device_id` is this till's own minted identity — not a fresh uuid per -/// attempt — so the back office can recognise a terminal across restarts and -/// refuse one it has not registered. -class AuthApi { - AuthApi({http.Client? client, this.baseUrl = ApiConfig.baseUrl}) - : _client = client ?? http.Client(); - - final http.Client _client; - final String baseUrl; - - Future login({ - required String authname, - required String password, - required String deviceId, - int configId = ApiConfig.configId, - }) async { - final uri = Uri.parse('$baseUrl${ApiConfig.loginPath}'); - - 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, - 'device_id': deviceId, - 'configid': configId, - }), - ) - .timeout(ApiConfig.timeout); - } on Object catch (e) { - throw ApiException( - 'Could not reach the back office. Check this terminal’s internet ' - 'connection and try again. ($e)', - isNetworkFault: true, - ); - } - - Map body; - try { - body = jsonDecode(response.body) as Map; - } on Object { - throw ApiException( - 'The back office answered with something this terminal could not ' - 'read (${response.statusCode}).', - statusCode: response.statusCode, - ); - } - - final ok = body['status'] == true && _asInt(body['code']) == 200; - final details = body['details']; - - if (!ok || details is! Map) { - // The server's own wording is better than anything invented here: it - // knows whether the account is wrong, locked, or on a device the shop - // has not registered. - throw ApiException( - (body['message'] as String?)?.trim().isNotEmpty ?? false - ? body['message']! as String - : 'Sign-in was refused (${response.statusCode}).', - statusCode: response.statusCode, - ); - } - - final session = LoginSession.fromDetails(details); - - if (session.token.isEmpty) { - throw const ApiException( - 'The back office signed this account in but returned no token, so ' - 'nothing else could be fetched. Contact support.', - ); - } - - if (session.locationId == 0) { - throw const ApiException( - 'The back office returned no location for this account. A terminal ' - 'has to belong to an outlet before it can pull products.', - ); - } - - return session; - } - - void dispose() => _client.close(); -} - -// ------------------------------------------------------------------ Helpers -/// The back office is inconsistent about numbers — `store_id` arrives quoted, -/// `location_id` bare — so both shapes are accepted everywhere. -int? _asInt(Object? value) => switch (value) { - int v => v, - num v => v.toInt(), - String v => int.tryParse(v.trim()), - _ => null, - }; - -String? _nonEmpty(String? value) { - final t = value?.trim(); - return (t == null || t.isEmpty) ? null : t; -} - -List _list(Object? raw, T Function(Map) decode) { - if (raw is! List) return const []; - return raw.whereType>().map(decode).toList(); -} diff --git a/lib/presentation/auth/providers/auth_controller.dart b/lib/presentation/auth/providers/auth_controller.dart index 0fb51db..acfa0df 100644 --- a/lib/presentation/auth/providers/auth_controller.dart +++ b/lib/presentation/auth/providers/auth_controller.dart @@ -1,10 +1,6 @@ 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. @@ -27,7 +23,6 @@ class Authenticated extends AuthState { required this.store, required this.user, required this.login, - this.session, }); final StoreAccount store; @@ -37,25 +32,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. 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 { @@ -64,19 +44,19 @@ class AuthFailure extends AuthState { final String message; } -/// What this terminal is allowed to open. +/// The two ways into this terminal. /// -/// 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: +/// 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. /// -/// * [admin] runs the full shell and is the only login that can pull the +/// 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 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]. +/// * [cashier] gets the billing screen and nothing else, and signing out +/// takes the catalogue with it. enum TerminalLogin { admin( label: 'Admin', @@ -121,7 +101,7 @@ enum TerminalLogin { } } -/// The built-in accounts, used only by the offline path. +/// Kept for the store record, which is keyed on the outlet's own address. class DemoCredentials { const DemoCredentials._(); @@ -132,7 +112,7 @@ class DemoCredentials { static const String cashierPassword = 'cashier123'; } -/// Signs the terminal in against the back office and holds the session. +/// Validates store credentials and holds the signed-in session. class AuthController extends StateNotifier { AuthController(this._ref) : super(const Unauthenticated()); @@ -147,152 +127,44 @@ class AuthController extends StateNotifier { 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'; + // Stand-in for the network round trip. + await Future.delayed(const Duration(milliseconds: 600)); - 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; + if (login == null) { + state = const AuthFailure('No account is registered against that email.'); + return false; + } - final opener = store.staff.firstWhere( + 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: () => store.staff.first, + orElse: () => staff.first, ); state = Authenticated(store: store, user: opener, login: login); @@ -318,7 +190,6 @@ class AuthController extends StateNotifier { store: current.store, user: user, login: current.login, - session: current.session, ); return true; } @@ -338,24 +209,19 @@ class AuthController extends StateNotifier { // 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. + /// 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. - /// - /// 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(); @@ -384,31 +250,6 @@ final currentUserProvider = Provider((ref) { 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); diff --git a/lib/presentation/auth/screens/login_screen.dart b/lib/presentation/auth/screens/login_screen.dart index ad325e7..c35c54a 100644 --- a/lib/presentation/auth/screens/login_screen.dart +++ b/lib/presentation/auth/screens/login_screen.dart @@ -1,4 +1,4 @@ -import 'dart:ui'; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; @@ -6,9 +6,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../app/providers.dart'; +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'; @@ -31,17 +33,27 @@ class LoginScreen extends ConsumerStatefulWidget { class _LoginScreenState extends ConsumerState { final _formKey = GlobalKey(); - // Blank on purpose. The role tabs that used to sit above these fields chose - // between two built-in accounts and decided, locally, whether the terminal - // opened the admin shell or the billing screen. The back office decides that - // now — `POST /login` answers with the role — so offering the choice here - // would only let someone pick a screen the server is about to override. - final _email = TextEditingController(); - final _password = TextEditingController(); + /// 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); 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(); @@ -54,9 +66,9 @@ class _LoginScreenState extends ConsumerState { if (!(_formKey.currentState?.validate() ?? false)) return; final ok = await ref.read(authControllerProvider.notifier).signIn( - email: _email.text, - password: _password.text, - ); + email: _email.text, + password: _password.text, + ); if (ok && mounted) context.go(AppRoutes.pos); } @@ -68,18 +80,11 @@ class _LoginScreenState extends ConsumerState { final busy = auth is Authenticating; return Scaffold( + backgroundColor: AppColors.background, body: Stack( fit: StackFit.expand, children: [ - // Background image - Image.asset( - 'assets/images/bg.webp', - fit: BoxFit.cover, - ), - - // Light blur over the image - - + const _Backdrop(), SafeArea( child: LayoutBuilder( builder: (context, box) { @@ -91,8 +96,8 @@ class _LoginScreenState extends ConsumerState { vertical: tight ? AppSpacing.xl : AppSpacing.xxxl, ), child: ConstrainedBox( - // Fills the viewport so the card is centred vertically, and - // scrolls the moment it cannot be. + // Fills the viewport so the card is centred vertically, + // and scrolls the moment it cannot be. constraints: BoxConstraints( minHeight: (box.maxHeight - (tight ? 40 : 64)) .clamp(0.0, double.infinity), @@ -104,17 +109,21 @@ class _LoginScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _Masthead(tight: tight), + // _Masthead(tight: tight), SizedBox( - height: tight ? AppSpacing.lg : AppSpacing.xl,), + height: tight ? AppSpacing.lg : AppSpacing.xl, + ), _Card( formKey: _formKey, email: _email, password: _password, obscure: _obscure, rememberTerminal: _rememberTerminal, + login: _login, busy: busy, - failure: auth is AuthFailure ? auth.message : null, + failure: + auth is AuthFailure ? auth.message : null, + onSelectLogin: _selectLogin, onToggleObscure: () => setState(() => _obscure = !_obscure), onToggleRemember: (v) => @@ -125,9 +134,9 @@ class _LoginScreenState extends ConsumerState { Center( child: Text( 'Terminal ${session.terminalId}', - style: const TextStyle( + style: TextStyle( fontSize: 11.5, - color: AppColors.textTertiary, + color: Colors.white.withValues(alpha: 0.72), ), ), ), @@ -146,6 +155,58 @@ 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. +/// +/// 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. +class _Backdrop extends StatelessWidget { + const _Backdrop(); + + @override + Widget build(BuildContext context) { + return Stack( + 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), + ), + ), + ), + ), + // 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. + const ColoredBox(color: Color(0x8A1A0B22)), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x66000000), Color(0x22000000), Color(0x77000000)], + ), + ), + ), + ], + ); + } +} + /// Mark and product name, directly above the card. class _Masthead extends StatelessWidget { const _Masthead({required this.tight}); @@ -156,25 +217,31 @@ class _Masthead extends StatelessWidget { Widget build(BuildContext context) { return Column( children: [ - // BrandMark(size: tight ? 48 : 60), + BrandMark(size: tight ? 48 : 60), const SizedBox(height: AppSpacing.md), const Text( 'Nearle POS', style: TextStyle( - fontSize: 20, + 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), - const Text( + Text( 'Scanner-first billing for Indian retail', style: TextStyle( fontSize: 13, - color: AppColors.textSecondary, + color: Colors.white.withValues(alpha: 0.82), + shadows: const [ + Shadow(color: Color(0x55000000), blurRadius: 10), + ], ), ), ], @@ -190,8 +257,10 @@ class _Card extends StatelessWidget { 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, @@ -202,8 +271,10 @@ class _Card extends StatelessWidget { 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; @@ -218,9 +289,9 @@ class _Card extends StatelessWidget { border: Border.all(color: AppColors.border), boxShadow: const [ BoxShadow( - color: Color(0x0F101828), - blurRadius: 24, - offset: Offset(0, 8), + color: Color(0x33101828), + blurRadius: 40, + offset: Offset(0, 16), ), ], ), @@ -251,17 +322,31 @@ class _Card extends StatelessWidget { ), const SizedBox(height: AppSpacing.xl), - const _Label('Email or username'), + _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'), TextFormField( controller: email, keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, enabled: !busy, - // Not validated as an email. The back office takes this as - // `authname` and accepts either form; rejecting a username here - // would block an account the server would have let in. - validator: (v) => - (v ?? '').trim().isEmpty ? 'This is required' : null, + validator: (v) => (v ?? '').trim().isEmpty + ? 'Store email is required' + : Validators.emailOptional(v), decoration: const InputDecoration( hintText: 'store@example.in', prefixIcon: Icon(Icons.storefront_outlined), @@ -276,8 +361,9 @@ class _Card extends StatelessWidget { enabled: !busy, textInputAction: TextInputAction.done, onFieldSubmitted: (_) => onSubmit(), - validator: (v) => - (v ?? '').isEmpty ? 'Password is required' : null, + validator: (v) => (v ?? '').isEmpty + ? 'Password is required' + : ((v ?? '').length < 6 ? 'Password looks too short' : null), decoration: InputDecoration( hintText: 'Enter your password', prefixIcon: const Icon(Icons.lock_outline_rounded), @@ -347,7 +433,7 @@ class _Card extends StatelessWidget { child: Row( children: [ const Icon(Icons.error_outline_rounded, - color: AppColors.danger, size: 18,), + color: AppColors.danger, size: 18,), const SizedBox(width: AppSpacing.sm), Expanded( child: Text( @@ -371,6 +457,12 @@ class _Card extends StatelessWidget { busy: busy, onPressed: onSubmit, ), + + const SizedBox(height: AppSpacing.lg), + _DemoHint( + login: login, + onFill: busy ? null : () => onSelectLogin(login), + ), ], ), ), @@ -399,3 +491,167 @@ 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)), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/customer/widgets/customer_capture_sheet.dart b/lib/presentation/customer/widgets/customer_capture_sheet.dart index dc15051..e18b481 100644 --- a/lib/presentation/customer/widgets/customer_capture_sheet.dart +++ b/lib/presentation/customer/widgets/customer_capture_sheet.dart @@ -11,17 +11,19 @@ import '../../../core/widgets/numeric_keypad.dart'; import '../../../core/widgets/primary_button.dart'; import '../../../domain/entities/customer.dart'; import '../../pos/providers/cart_controller.dart'; +import '../providers/customer_providers.dart'; -/// Attaches a customer to the current bill: a mobile number, a name, or -/// neither. +/// Attaches a customer to the current bill. /// -/// Deliberately not a lookup screen. There is no search against the customers -/// already on the terminal, no recent list, and no membership tier — a cashier -/// at a queue keys the number, keys the name if the shopper gives one, and -/// carries on. A number that turns out to be registered already is reused -/// silently rather than being turned into a decision the counter has to make. +/// A mobile number, optionally a name, or skip. Nothing else — no lookup +/// result to read, no tier, no points balance. Those were a screenful of +/// information nobody at a counter acts on, in front of a queue, for a step +/// that is optional in the first place. /// -/// Nothing here blocks the sale: Skip closes it with a walk-in bill. +/// The lookup still happens; it just does not show. On save the number is +/// matched against what the terminal already holds, so a returning shopper is +/// attached to their existing record rather than duplicated — the loyalty +/// figures stay correct, they simply are not read out at the till. Future showCustomerCaptureSheet(BuildContext context) { return showModalBottomSheet( context: context, @@ -81,64 +83,46 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> { Navigator.of(context).pop(); } - /// Saves the number under the name given, then puts it on the bill. + /// Saves with whatever was given. /// - /// The name is optional — a bare number is still worth keeping, because it - /// is what the WhatsApp bill is sent to. - /// - /// A number already on file is the ordinary case, not an error: the existing - /// record is picked up and attached, and a freshly typed name is written - /// over the stored one so a correction at the counter sticks. The cashier - /// sees the same thing either way, which is the point of not having a lookup - /// step. + /// The name is optional: a bare number is still worth keeping, because it is + /// what the WhatsApp bill is sent to. An existing record wins over creating a + /// second one — silently, because a cashier does not need to be told the + /// shopper has been here before to finish the sale. Future _save() async { - if (!_complete) { - setState(() => _error = 'Enter all ' - '${AppConstants.mobileNumberLength} digits of the mobile number.'); - return; - } - - final typed = _name.text.trim(); - final name = typed.isEmpty - ? 'Customer ${_digits.substring(_digits.length - 4)}' - : typed; + if (!_complete) return; setState(() { _saving = true; _error = null; }); - final repo = ref.read(customerRepositoryProvider); + final typed = _name.text.trim(); + final repository = ref.read(customerRepositoryProvider); try { - final created = await repo.create( - Customer(id: '', name: name, mobile: _digits), - ); - if (mounted) _attachAndClose(created); - } on StateError { - // Already registered. Reuse the row rather than making the counter - // reconcile it. - try { - final existing = await repo.findByMobile(_digits); - if (existing == null) throw StateError('lookup failed'); - - final updated = typed.isEmpty || typed == existing.name - ? existing - : await repo.update(existing.copyWith(name: typed)); - - if (mounted) _attachAndClose(updated); - } catch (_) { - if (!mounted) return; - setState(() { - _saving = false; - _error = 'Could not save customer. Try again.'; - }); + final existing = await repository.findByMobile(_digits); + if (existing != null) { + if (mounted) _attachAndClose(existing); + return; } - } catch (_) { + + final created = await repository.create( + Customer( + id: '', + name: typed.isEmpty + ? 'Customer ${_digits.substring(_digits.length - 4)}' + : typed, + mobile: _digits, + ), + ); + ref.invalidate(recentCustomersProvider); + if (mounted) _attachAndClose(created); + } catch (e) { if (!mounted) return; setState(() { _saving = false; - _error = 'Could not save customer. Try again.'; + _error = e is StateError ? e.message : 'Could not save customer.'; }); } } @@ -166,13 +150,13 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> { mainAxisSize: MainAxisSize.min, children: [ _grabber(), - // Everything inside is capped and centred. A modal sheet on a - // 27-inch till used to run the full width of the screen, which - // put the keypad and the fields at opposite ends of the desk. + // Capped and centred. A modal sheet on a 27-inch till used to run + // the full width of the screen, which put the keypad and the save + // button at opposite ends of the desk. Flexible( child: Center( child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 900), + constraints: const BoxConstraints(maxWidth: 640), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -186,32 +170,75 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> { AppSpacing.xxl, AppSpacing.xxl, ), - child: LayoutBuilder( - builder: (context, box) { - // Side by side once there is room for both. - final wide = box.maxWidth >= 660; - final entry = _entryColumn(); - final details = _detailsColumn(); - - if (!wide) { - return Column( - children: [ - entry, - const SizedBox(height: AppSpacing.xl), - details, - ], - ); - } - return Row( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - SizedBox(width: 300, child: entry), - const SizedBox(width: AppSpacing.xxl), - Expanded(child: details), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _display(), + const SizedBox(height: AppSpacing.sm), + Text( + _complete + ? 'Ready to save' + : '${AppConstants.mobileNumberLength - _digits.length}' + ' more digit(s)', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 11.5, + color: AppColors.textTertiary, + ), + ), + const SizedBox(height: AppSpacing.lg), + Center( + child: NumericKeypad( + maxWidth: 320, + onKey: _append, + onBackspace: _backspace, + onClear: _clear, + ), + ), + const SizedBox(height: AppSpacing.lg), + TextField( + controller: _name, + textCapitalization: + TextCapitalization.words, + enabled: !_saving, + onSubmitted: (_) => _save(), + inputFormatters: [ + LengthLimitingTextInputFormatter(60), ], - ); - }, + decoration: const InputDecoration( + labelText: 'Customer name', + hintText: 'Optional', + prefixIcon: + Icon(Icons.person_outline_rounded), + ), + ), + if (_error != null) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + _error!, + style: const TextStyle( + color: AppColors.danger, + fontSize: 12.5, + ), + ), + ], + const SizedBox(height: AppSpacing.lg), + PrimaryButton( + label: 'Save & use', + icon: Icons.check_rounded, + large: true, + busy: _saving, + onPressed: _complete ? _save : null, + ), + const SizedBox(height: AppSpacing.sm), + PrimaryButton( + label: 'Skip', + tone: ButtonTone.neutral, + onPressed: + _saving ? null : () => _attachAndClose(null), + ), + ], ), ), ), @@ -275,7 +302,7 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> { ), ), const Text( - 'Optional — number and name, or skip', + 'Optional — the bill can be sent to this number', overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5, @@ -286,16 +313,8 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> { ), ), const SizedBox(width: AppSpacing.sm), - TextButton( - onPressed: _saving ? null : () => _attachAndClose(null), - style: TextButton.styleFrom( - foregroundColor: AppColors.textSecondary, - minimumSize: const Size(0, 38), - ), - child: const Text('Skip'), - ), IconButton( - onPressed: _saving ? null : () => Navigator.of(context).pop(), + onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.close_rounded), color: AppColors.textTertiary, tooltip: 'Close', @@ -304,36 +323,6 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> { ), ); - // ----------------------------------------------------------------- Entry - Widget _entryColumn() => Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _display(), - const SizedBox(height: AppSpacing.sm), - Text( - _complete - ? 'Number complete' - : '${AppConstants.mobileNumberLength - _digits.length} more ' - 'digit(s)', - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 11.5, - color: AppColors.textTertiary, - ), - ), - const SizedBox(height: AppSpacing.lg), - Center( - child: NumericKeypad( - maxWidth: 300, - onKey: _append, - onBackspace: _backspace, - onClear: _clear, - ), - ), - ], - ); - /// The number as it is keyed, grouped 5 + 5 the way it is read aloud. Widget _display() { final filled = _digits.isNotEmpty; @@ -395,7 +384,7 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> { ), if (filled) IconButton( - onPressed: _saving ? null : _clear, + onPressed: _clear, icon: const Icon(Icons.backspace_outlined, size: 18), color: AppColors.textTertiary, tooltip: 'Clear', @@ -404,77 +393,4 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> { ), ); } - - // --------------------------------------------------------------- Details - Widget _detailsColumn() => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - _hint(), - const SizedBox(height: AppSpacing.lg), - TextField( - controller: _name, - textCapitalization: TextCapitalization.words, - enabled: !_saving, - onSubmitted: (_) => _save(), - inputFormatters: [LengthLimitingTextInputFormatter(60)], - decoration: const InputDecoration( - labelText: 'Customer name', - hintText: 'Optional', - prefixIcon: Icon(Icons.person_outline_rounded), - ), - ), - if (_error != null) ...[ - const SizedBox(height: AppSpacing.sm), - Text( - _error!, - style: const TextStyle(color: AppColors.danger, fontSize: 12.5), - ), - ], - const SizedBox(height: AppSpacing.lg), - PrimaryButton( - label: 'Save & use', - icon: Icons.person_add_alt_1_rounded, - large: true, - busy: _saving, - onPressed: _complete && !_saving ? _save : null, - ), - const SizedBox(height: AppSpacing.sm), - PrimaryButton( - label: 'Skip — no customer', - tone: ButtonTone.neutral, - onPressed: _saving ? null : () => _attachAndClose(null), - ), - ], - ); - - Widget _hint() => Container( - width: double.infinity, - padding: const EdgeInsets.all(AppSpacing.lg), - decoration: BoxDecoration( - color: AppColors.surfaceAlt, - borderRadius: AppRadius.brLg, - border: Border.all(color: AppColors.border), - ), - child: const Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.dialpad_rounded, - size: 19, color: AppColors.textTertiary,), - SizedBox(width: AppSpacing.md), - Expanded( - child: Text( - 'Key in the mobile number, add a name if the shopper gives ' - 'one, then save. The name is optional and the whole step can ' - 'be skipped — the sale is never held up by it.', - style: TextStyle( - fontSize: 13, - color: AppColors.textSecondary, - height: 1.5, - ), - ), - ), - ], - ), - ); } diff --git a/lib/presentation/modules/widgets/module_widgets.dart b/lib/presentation/modules/widgets/module_widgets.dart index 1907b37..f2b7813 100644 --- a/lib/presentation/modules/widgets/module_widgets.dart +++ b/lib/presentation/modules/widgets/module_widgets.dart @@ -23,6 +23,12 @@ class ModulePage extends StatelessWidget { return SingleChildScrollView( padding: EdgeInsets.all(padding), child: Column( + // Top-left is the resting position for every module. Both are spelled + // out rather than left to the defaults, because a Column's default + // cross-axis is centre — which is what had short pages drifting to the + // middle instead of starting at the left edge. + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.stretch, children: children, ), diff --git a/lib/presentation/payment/screens/payment_screen.dart b/lib/presentation/payment/screens/payment_screen.dart index 2bf908f..909b066 100644 --- a/lib/presentation/payment/screens/payment_screen.dart +++ b/lib/presentation/payment/screens/payment_screen.dart @@ -12,7 +12,6 @@ import '../../../core/utils/formatters.dart'; import '../../../core/widgets/glass_card.dart'; import '../../../core/widgets/numeric_keypad.dart'; import '../../../core/widgets/primary_button.dart'; -import '../../../core/widgets/status_pill.dart'; import '../../../domain/entities/promo.dart'; import '../../../domain/entities/transaction.dart'; import '../../customer/widgets/customer_capture_sheet.dart'; @@ -255,10 +254,6 @@ class _PaymentScreenState extends ConsumerState { ), ), ), - if (customer != null) ...[ - const SizedBox(width: AppSpacing.sm), - StatusPill.tier(customer.tier, dense: true), - ], ], ), const SizedBox(height: 1), diff --git a/lib/presentation/pos/screens/pos_dashboard_screen.dart b/lib/presentation/pos/screens/pos_dashboard_screen.dart index a1798f1..ac7f61b 100644 --- a/lib/presentation/pos/screens/pos_dashboard_screen.dart +++ b/lib/presentation/pos/screens/pos_dashboard_screen.dart @@ -183,6 +183,13 @@ class _PosDashboardScreenState extends ConsumerState { Expanded( child: AnimatedSwitcher( duration: AppMotion.fast, + // Top, not the default centre. The switcher stacks its + // children with loose constraints, so a module page + // shorter than the viewport — Promotions with two + // campaigns, Product Import before anything is pulled — + // sized itself to its content and then floated in the + // middle of the screen with dead space above it. + child: KeyedSubtree( key: ValueKey(module), child: _body(module, layout), diff --git a/lib/presentation/pos/widgets/app_sidebar.dart b/lib/presentation/pos/widgets/app_sidebar.dart index 8fe1ea4..2956926 100644 --- a/lib/presentation/pos/widgets/app_sidebar.dart +++ b/lib/presentation/pos/widgets/app_sidebar.dart @@ -78,36 +78,13 @@ class AppSidebar extends ConsumerWidget { } } -/// Store name beside the mark. -/// -/// Used to read "Nearle / POS", which told the person standing at the till -/// nothing they did not already know — they can see which app is open. What is -/// worth the space is which shop and which branch this terminal is signed into, -/// because one person works several and a bill rung against the wrong outlet is -/// found the next morning. Both come from the sign-in response. -class _Brand extends ConsumerWidget { +class _Brand extends StatelessWidget { const _Brand({required this.expanded}); final bool expanded; @override - Widget build(BuildContext context, WidgetRef ref) { - final store = ref.watch(storeDisplayNameProvider); - final location = ref.watch(locationDisplayNameProvider); - - // Before the back office has answered, fall back to whatever the store - // record on this terminal says rather than to the product's own name. - final title = store.isNotEmpty - ? store - : (ref.watch(currentStoreProvider)?.name ?? 'Store'); - - // A single-outlet shop usually names the tenant and the branch the same - // thing; repeating it twice would look like a bug. - final subtitle = - (location.isEmpty || location.toLowerCase() == title.toLowerCase()) - ? 'POS' - : location; - + Widget build(BuildContext context) { return Container( height: AppSizes.headerHeight, padding: EdgeInsets.symmetric( @@ -125,11 +102,9 @@ class _Brand extends ConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( + const Text( + 'Nearle', + style: TextStyle( fontSize: 17, fontWeight: FontWeight.w700, letterSpacing: -0.3, @@ -138,9 +113,7 @@ class _Brand extends ConsumerWidget { ), ), Text( - subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, + 'POS', style: AppTypography.sectionLabel() .copyWith(color: AppColors.primary), ), diff --git a/lib/presentation/pos/widgets/billing_panel.dart b/lib/presentation/pos/widgets/billing_panel.dart index b177387..e831fee 100644 --- a/lib/presentation/pos/widgets/billing_panel.dart +++ b/lib/presentation/pos/widgets/billing_panel.dart @@ -352,41 +352,47 @@ class _Row extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + // Start, not space-between. With space-between *and* a Spacer, the + // free width was shared out between every child — which pushed the + // slab hint away from the label it belongs to, so "GST" and "(5%, 12%)" + // read as two unrelated columns. The Spacer alone puts all the slack in + // one place, between the label group and the amount. children: [ - Flexible( - child: Text( - label, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 14, - color: AppColors.textSecondary, + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + ), ), ), - ), - if (hint != null) ...[ - const SizedBox(width: AppSpacing.xs), - Text('($hint)', + if (hint != null) ...[ + const SizedBox(width: AppSpacing.xs), + Text( + '($hint)', style: const TextStyle( fontSize: 11.5, color: AppColors.textTertiary, - ),), - ], - const SizedBox(width: AppSpacing.sm), - const Spacer(), - Text( - value, - style: AppTypography.money( - 14.5, - weight: FontWeight.w600, - color: valueColor ?? AppColors.textPrimary, + ), + ), + ], + const Spacer(), + Text( + value, + style: AppTypography.money( + 14.5, + weight: FontWeight.w600, + color: valueColor ?? AppColors.textPrimary, + ), ), - ), - if (trailingIcon != null) ...[ - - Icon(trailingIcon, size: 14, color: AppColors.textTertiary), + if (trailingIcon != null) ...[ + const SizedBox(width: AppSpacing.xs), + Icon(trailingIcon, size: 14, color: AppColors.textTertiary), + ], ], - ],), + ), ); } } diff --git a/lib/presentation/pos/widgets/page_header.dart b/lib/presentation/pos/widgets/page_header.dart index 46f3273..2d1403f 100644 --- a/lib/presentation/pos/widgets/page_header.dart +++ b/lib/presentation/pos/widgets/page_header.dart @@ -404,15 +404,8 @@ class _CashierBrand extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final store = ref.watch(currentStoreProvider); final user = ref.watch(currentUserProvider); - final store = ref.watch(storeDisplayNameProvider); - - // Which shop, not which product. See `_Brand` in the sidebar — a cashier - // knows what app is open; what they cannot see is which outlet this till - // is signed into. - final title = store.isNotEmpty - ? store - : (ref.watch(currentStoreProvider)?.name ?? 'Store'); return Row( mainAxisSize: MainAxisSize.min, @@ -426,8 +419,7 @@ class _CashierBrand extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - title, - maxLines: 1, + store?.name ?? 'Nearle POS', overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 14, diff --git a/lib/presentation/shift/screens/end_shift_screen.dart b/lib/presentation/shift/screens/end_shift_screen.dart index a996bc7..c83bd43 100644 --- a/lib/presentation/shift/screens/end_shift_screen.dart +++ b/lib/presentation/shift/screens/end_shift_screen.dart @@ -199,20 +199,49 @@ class _EndShiftScreenState extends ConsumerState { ); }, ), - bottomNavigationBar: _bottomBar(pending, variance), + bottomNavigationBar: _bottomBar(pending, expected, variance), ); } - Widget _bottomBar(int pending, double variance) { + /// Whether the drawer has been counted and agrees with what was rung. + /// + /// Signing out is gated on this. A shift that ends with the cash unaccounted + /// for is a shift nobody can settle afterwards — the person who worked it has + /// gone home, and the difference becomes an argument rather than a number. To + /// the rupee, because that is the smallest note anyone hands over. + bool _balances(double expected) => (_countedTotal - expected).abs() < 0.5; + + Widget _bottomBar(int pending, double expected, double variance) { final counted = _noteCount > 0; final short = variance < -0.5; final over = variance > 0.5; + final balanced = _balances(expected); - // The gate. A shift closes on a drawer that reconciles and on nothing - // else — an uncounted or mismatched till is settled at the counter, with - // the person who worked it still there, not discovered by the back office - // the next morning. - final balanced = counted && !short && !over; + final (icon, tone, message) = switch (0) { + _ when !counted => ( + Icons.info_outline_rounded, + AppColors.textTertiary, + 'Count the drawer to finish. Signing out needs the count to match ' + '${Formatters.money(expected)}.', + ), + _ when short => ( + Icons.error_outline_rounded, + AppColors.danger, + 'The drawer is ${Formatters.money(variance.abs())} short. Recount, ' + 'or find the difference before signing out.', + ), + _ when over => ( + Icons.error_outline_rounded, + AppColors.warning, + 'The drawer is ${Formatters.money(variance)} over. Recount, or find ' + 'the difference before signing out.', + ), + _ => ( + Icons.check_circle_outline_rounded, + AppColors.success, + 'The drawer matches what was rung. You can sign out.', + ), + }; return SafeArea( child: Container( @@ -231,40 +260,15 @@ class _EndShiftScreenState extends ConsumerState { children: [ Row( children: [ - Icon( - counted - ? (short || over - ? Icons.error_outline_rounded - : Icons.check_circle_outline_rounded) - : Icons.info_outline_rounded, - size: 16, - color: !counted - ? AppColors.textTertiary - : (short - ? AppColors.danger - : (over ? AppColors.warning : AppColors.success)), - ), + Icon(icon, size: 16, color: tone), const SizedBox(width: AppSpacing.sm), Expanded( child: Text( - !counted - ? 'Count the drawer. The shift cannot be ended until ' - 'it matches the expected amount.' - : short - ? 'The drawer is ' - '${Formatters.money(variance.abs())} short. ' - 'Recount, or settle the difference — the ' - 'shift cannot be ended while it is off.' - : over - ? 'The drawer is ' - '${Formatters.money(variance)} over. ' - 'Recount — the shift cannot be ended ' - 'while it is off.' - : 'The drawer matches what was rung. Ready to ' - 'end the shift.', + message, style: const TextStyle( fontSize: 12.5, color: AppColors.textSecondary, + height: 1.4, ), ), ), @@ -272,25 +276,23 @@ class _EndShiftScreenState extends ConsumerState { ), const SizedBox(height: AppSpacing.sm), PrimaryButton( - label: !balanced - ? 'Drawer must match to end shift' - : pending > 0 - ? 'Upload $pending bill(s) & end shift' - : 'End shift', - icon: balanced ? Icons.logout_rounded : Icons.lock_outline_rounded, + label: pending > 0 + ? 'Upload $pending bill(s) & end shift' + : 'End shift', + icon: Icons.logout_rounded, large: true, busy: _pushing, + // Disabled until the count agrees. There is deliberately no way + // past this on the screen: an override that a tired cashier can + // press at the end of a long day is not a control. onPressed: (_pushing || !balanced) ? null : () => _finish(sync: pending > 0), ), - if (pending > 0) ...[ + if (pending > 0 && balanced) ...[ const SizedBox(height: AppSpacing.xs), TextButton( - // Skipping the upload is still allowed; skipping the count is - // not, so this is gated on the same condition. - onPressed: - (_pushing || !balanced) ? null : () => _finish(sync: false), + onPressed: _pushing ? null : () => _finish(sync: false), style: TextButton.styleFrom( foregroundColor: AppColors.textSecondary, ), @@ -659,14 +661,10 @@ class _ReviewPanel extends StatelessWidget { 'when you end the shift.', ), _checkRow( - !short && !over && counted > 0, - counted == 0 - ? 'Drawer not counted yet — required before the shift can ' - 'be ended.' - : (short || over) - ? 'Drawer does not match the expected amount. The ' - 'shift stays open until it does.' - : 'Drawer matches the expected amount.', + counted > 0, + counted > 0 + ? 'Drawer counted.' + : 'Drawer not counted yet.', ), _checkRow( false, diff --git a/lib/presentation/shift/widgets/session_end_sheet.dart b/lib/presentation/shift/widgets/session_end_sheet.dart index fc4d6e0..794a77a 100644 --- a/lib/presentation/shift/widgets/session_end_sheet.dart +++ b/lib/presentation/shift/widgets/session_end_sheet.dart @@ -14,17 +14,16 @@ import '../../sync/widgets/sign_out_dialog.dart'; /// Asks what "signing out" means before doing it. /// -/// A cashier stepping off the counter for a few minutes and a cashier -/// finishing for the day want different things from the *shift*, not from the -/// terminal. A temporary logout leaves the shift open — bills stay queued and -/// today's totals keep accumulating — and simply locks the screen. Ending the -/// shift means counting the drawer, reconciling it, and handing the till back. +/// Both cashier paths clear the products — the till never keeps a catalogue +/// across a sign-out, whatever the reason. What they differ on is the drawer: +/// a temporary logout locks the screen and leaves the money alone, while +/// ending the shift counts it and reconciles it against what was rung. +/// Treating both as one button meant either the drawer was never settled, or a +/// cashier stepping away for ten minutes had to count it first. /// -/// What both do identically: the products come off this terminal. A cashier -/// session never leaves a catalogue sitting on an unattended screen, so an -/// admin re-imports it before the counter is worked again. -/// -/// Admins see the plain sign-out dialog: they have no drawer to settle. +/// Admins see the plain sign-out dialog: they have no drawer to settle, and +/// their sign-out deliberately leaves the catalogue in place for whoever picks +/// the terminal up. Future showSessionEndSheet(BuildContext context, WidgetRef ref) async { if (!ref.read(isCashierModeProvider)) { return showSignOutDialog(context, ref); @@ -40,20 +39,17 @@ Future showSessionEndSheet(BuildContext context, WidgetRef ref) async { class _SessionEndDialog extends ConsumerWidget { const _SessionEndDialog(); - /// Closes the session without closing the shift. + /// Locks the screen and returns to the login screen. /// - /// Explicitly *not* a shift end: unsynced bills stay queued and today's - /// totals keep accumulating against the same day, so the drawer is still - /// settled once, at the end. What it does not leave behind is the - /// catalogue — every cashier sign-out takes the products with it, this one - /// included, so an admin imports them again before billing resumes. + /// Explicitly *not* a shift end — the drawer is left alone, unsynced bills + /// stay queued, and today's totals keep accumulating against the same day. + /// The catalogue still goes: a terminal sitting unattended at a login screen + /// must not be holding a shop's prices and stock, and an admin re-imports in + /// seconds. Future _temporaryLogout(BuildContext context, WidgetRef ref) async { ref.read(cartControllerProvider.notifier).reset(); await ref.read(authControllerProvider.notifier).signOut(); - // Bump the version so catalogueReadyProvider re-reads hasCatalogue, and - // drop the cached lists so the next session's grid does not flash this - // session's products before it re-checks what is on disk. ref.read(catalogueVersionProvider.notifier).state++; ref.invalidate(allProductsProvider); ref.invalidate(visibleProductsProvider); @@ -65,22 +61,8 @@ class _SessionEndDialog extends ConsumerWidget { ref.read(selectedCategoryProvider.notifier).state = null; if (!context.mounted) return; - - // Resolved while this context is still mounted — the messenger lives above - // the router, so the bar survives the route change below. - final messenger = ScaffoldMessenger.of(context); - Navigator.of(context).pop(); context.go(AppRoutes.login); - - messenger - ..hideCurrentSnackBar() - ..showSnackBar(const SnackBar( - content: Text( - 'Logged out. The shift is still open, and the product catalogue has ' - 'been removed from this terminal.', - ), - ),); } @override @@ -119,8 +101,8 @@ class _SessionEndDialog extends ConsumerWidget { const SizedBox(width: AppSpacing.sm), Expanded( child: Text( - 'The current bill has ${cart.lineCount} item(s) and ' - 'will be cleared either way.', + 'The current bill has ${cart.lineCount} item(s), and ' + 'the imported products are cleared either way.', style: const TextStyle( fontSize: 12.5, color: AppColors.warning, @@ -132,44 +114,13 @@ class _SessionEndDialog extends ConsumerWidget { ), ), - // The one thing both choices do, said once here rather than - // repeated in each card and discovered at the next login. - Container( - margin: const EdgeInsets.only(bottom: AppSpacing.md), - padding: const EdgeInsets.all(AppSpacing.md), - decoration: const BoxDecoration( - color: AppColors.infoSurface, - borderRadius: AppRadius.brSm, - ), - child: const Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.delete_sweep_outlined, - size: 18, color: AppColors.info,), - SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - 'Either way, the product catalogue is removed from ' - 'this terminal. An admin imports it again before the ' - 'counter is worked.', - style: TextStyle( - fontSize: 12.5, - color: AppColors.info, - height: 1.45, - ), - ), - ), - ], - ), - ), - _Choice( - icon: Icons.lock_clock_outlined, + icon: Icons.lock_outline_rounded, tone: AppColors.info, title: 'Temporary logout', - body: 'Locks the terminal without closing the shift. Bills ' - 'stay queued and today’s totals keep running, so the ' - 'drawer is still counted once at the end.', + body: 'Locks the screen. The drawer is left as it is and the ' + 'day keeps running — an admin re-imports the products when ' + 'you come back.', onTap: () => _temporaryLogout(context, ref), ), const SizedBox(height: AppSpacing.md), @@ -178,10 +129,11 @@ class _SessionEndDialog extends ConsumerWidget { tone: AppColors.primary, title: 'End shift', body: pending == 0 - ? 'Count the drawer, check it against what was rung, then ' - 'hand the till over.' - : 'Count the drawer, check it against what was rung, then ' - 'upload the $pending bill(s) still held here.', + ? 'Count the drawer. It has to match what was rung before ' + 'the till can be handed over.' + : 'Count the drawer and upload the $pending bill(s) still ' + 'held here. The count has to match before you can sign ' + 'out.', onTap: () { Navigator.of(context).pop(); context.push(AppRoutes.endShift);