From 0988d39d8bab231e1eff6a2f526ea613bc67bef8 Mon Sep 17 00:00:00 2001 From: Anbarasu22004 Date: Fri, 7 Aug 2026 14:34:25 +0530 Subject: [PATCH] check --- lib/app/providers.dart | 62 +-- lib/core/config/api_config.dart | 41 ++ lib/core/config/sync_config.dart | 33 -- lib/data/datasources/local_store.dart | 11 + lib/data/local/session_store.dart | 81 ---- lib/data/local/staff_dao.dart | 175 ++++--- lib/data/remote/auth_api.dart | 378 +++++++++++++++ lib/data/remote/pos_auth_api.dart | 149 ------ lib/domain/entities/pos_session.dart | 281 ----------- .../auth/providers/auth_controller.dart | 440 +++++++++--------- .../auth/screens/login_screen.dart | 28 +- .../widgets/customer_capture_sheet.dart | 6 +- lib/presentation/pos/screens/pos_view.dart | 1 + lib/presentation/pos/widgets/app_sidebar.dart | 39 +- lib/presentation/pos/widgets/page_header.dart | 12 +- .../shift/screens/end_shift_screen.dart | 1 + .../sync/providers/sync_controller.dart | 12 - .../sync/widgets/sign_out_dialog.dart | 2 +- test/widget_test.dart | 30 ++ 19 files changed, 850 insertions(+), 932 deletions(-) create mode 100644 lib/core/config/api_config.dart delete mode 100644 lib/data/local/session_store.dart create mode 100644 lib/data/remote/auth_api.dart delete mode 100644 lib/data/remote/pos_auth_api.dart delete mode 100644 lib/domain/entities/pos_session.dart create mode 100644 test/widget_test.dart diff --git a/lib/app/providers.dart b/lib/app/providers.dart index d28b81b..b5b50a3 100644 --- a/lib/app/providers.dart +++ b/lib/app/providers.dart @@ -1,5 +1,6 @@ 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'; @@ -7,6 +8,7 @@ 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'; @@ -17,9 +19,7 @@ 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/remote/pos_auth_api.dart'; import '../data/sync/sync_engine.dart'; import '../domain/repositories/customer_repository.dart'; import '../domain/repositories/product_repository.dart'; @@ -91,14 +91,30 @@ 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: 'https://fiesta.nearle.app/live/api/v1/pos', - storeId: terminal.storeId, + 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, 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( @@ -163,30 +179,16 @@ final storeRepositoryProvider = Provider( (ref) => StoreRepositoryImpl(ref.watch(localStoreProvider)), ); -/// Signs a terminal in against the back office. -/// -/// Points at the same base URL the uplinks use, so re-pointing a terminal in -/// Settings moves its sign-in with it rather than leaving it authenticating -/// against the endpoint it used to belong to. -final posAuthApiProvider = Provider((ref) { - final api = PosAuthApi(baseUrl: ref.watch(syncConfigProvider).httpBaseUrl); - ref.onDispose(api.dispose); - return api; -}); - -/// Where the signed session survives a restart. -final sessionStoreProvider = Provider((ref) => SessionStore()); - /// The outlet, refreshed whenever staff or details change. -/// -/// The email is the signed-in account's, not a constant. It used to be a -/// `DemoCredentials.email` compiled into the build — the same address on every -/// install, which is what made the store login decorative. -final storeAccountProvider = FutureProvider( - (ref) => ref.watch(storeRepositoryProvider).load( - email: ref.watch(authControllerProvider.notifier).session?.email ?? '', - ), -); +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, + ); +}); /// Campaigns stored on this terminal. final promosProvider = FutureProvider>( @@ -246,9 +248,11 @@ final terminalIdentityProvider = Provider((ref) { final cashierSessionProvider = StateProvider((ref) { final terminal = ref.watch(terminalIdentityProvider); + final session = ref.watch(apiSessionProvider); + return CashierSession( - name: 'Suriya', - role: 'ADMIN', + name: session?.displayUserName ?? 'Operator', + role: (session?.isAdmin ?? false) ? 'ADMIN' : 'CASHIER', terminalId: terminal.code, ); }); diff --git a/lib/core/config/api_config.dart b/lib/core/config/api_config.dart new file mode 100644 index 0000000..61b3041 --- /dev/null +++ b/lib/core/config/api_config.dart @@ -0,0 +1,41 @@ +/// 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/config/sync_config.dart b/lib/core/config/sync_config.dart index 500a736..fb13417 100644 --- a/lib/core/config/sync_config.dart +++ b/lib/core/config/sync_config.dart @@ -35,7 +35,6 @@ class SyncConfig { this.password, this.httpBaseUrl = '', this.apiKey, - this.sessionToken, this.ackTimeout = const Duration(seconds: 20), this.batchSize = 50, }); @@ -53,38 +52,8 @@ class SyncConfig { final String? password; final String httpBaseUrl; - - /// A static key shared by every terminal at a deployment, if one is set. - /// - /// Predates sign-in and says nothing about *who* is at the till, so it cannot - /// scope a request to an outlet. Kept for deployments that put one in front - /// of the endpoint. final String? apiKey; - /// The signed session from `POST /login`, held for the trading day. - /// - /// Distinct from [apiKey] because the two answer different questions. The key - /// says "this request came from our fleet"; the session says "this request - /// came from Selvapuram, signed in as Ragul, and may touch that outlet and no - /// other". Only the second can stop a till reaching another tenant's books, - /// which is why it takes precedence when both are present. - final String? sessionToken; - - /// What goes in the Authorization header. - /// - /// One accessor rather than the same `??` repeated at each call site, because - /// the request that forgot it would be the one silently sending no - /// credentials at all. - String? get bearerToken { - final session = sessionToken?.trim(); - if (session != null && session.isNotEmpty) return session; - - final key = apiKey?.trim(); - if (key != null && key.isNotEmpty) return key; - - return null; - } - /// How long to wait for the back office to confirm a batch before treating /// the outcome as unknown and leaving every row pending. /// @@ -179,7 +148,6 @@ class SyncConfig { String? password, String? httpBaseUrl, String? apiKey, - String? sessionToken, Duration? ackTimeout, int? batchSize, }) => @@ -194,7 +162,6 @@ class SyncConfig { password: password ?? this.password, httpBaseUrl: httpBaseUrl ?? this.httpBaseUrl, apiKey: apiKey ?? this.apiKey, - sessionToken: sessionToken ?? this.sessionToken, ackTimeout: ackTimeout ?? this.ackTimeout, batchSize: batchSize ?? this.batchSize, ); diff --git a/lib/data/datasources/local_store.dart b/lib/data/datasources/local_store.dart index d98798f..212f199 100644 --- a/lib/data/datasources/local_store.dart +++ b/lib/data/datasources/local_store.dart @@ -76,6 +76,17 @@ 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/session_store.dart b/lib/data/local/session_store.dart deleted file mode 100644 index 9013ea2..0000000 --- a/lib/data/local/session_store.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -import '../../domain/entities/pos_session.dart'; - -/// Keeps a terminal signed in across restarts. -/// -/// A till is not a browser. It signs in when a shop opens and bills for the -/// whole trading day, often on a connection that comes and goes, and it must -/// survive being rebooted mid-shift without a queue of customers waiting while -/// somebody finds the manager's password. -/// -/// The whole session goes to the platform keystore rather than to SQLite. The -/// token is a bearer credential — anything holding it can bill as this shop — -/// and SQLite here is a file on a machine behind a shop counter, readable by -/// anything that can open it. The rest of the session travels with the token -/// because splitting them invites the two halves to disagree about which outlet -/// this terminal is. -class SessionStore { - SessionStore({FlutterSecureStorage? secureStorage}) - : _secure = secureStorage ?? const FlutterSecureStorage(); - - final FlutterSecureStorage _secure; - - static const _key = 'pos.session'; - - /// Reads the saved session, or null when there is none worth using. - /// - /// An expired session is treated as absent rather than returned for the - /// caller to check. Every caller would have to make the same check, and the - /// one that forgot would send a dead token all day and read the resulting - /// 401s as a server fault. - Future read({DateTime? now}) async { - final 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. Signing in again is the safe way to fail. - debugPrint('Secure storage unavailable, session not loaded: $e'); - return null; - } - - if (raw == null || raw.isEmpty) return null; - - try { - final session = - PosSession.fromJson(jsonDecode(raw) as Map); - if (!session.isValidAt(now ?? DateTime.now())) return null; - if (session.token.isEmpty || session.locationId <= 0) return null; - return session; - } on Object catch (e) { - // A stored session this build cannot parse — most likely written by an - // older one. Dropped rather than repaired: a half-understood session is - // worse than none, and re-authenticating costs one screen. - debugPrint('Stored session could not be read, discarding: $e'); - return null; - } - } - - Future write(PosSession session) async { - try { - await _secure.write(key: _key, value: jsonEncode(session.toJson())); - } on Object catch (e) { - // The terminal keeps working on the session it holds in memory; it just - // will not survive a restart. Failing the sign-in over this would close a - // shop for a keystore problem. - debugPrint('Could not persist the session: $e'); - } - } - - Future clear() async { - try { - await _secure.delete(key: _key); - } on Object catch (e) { - debugPrint('Could not clear the session: $e'); - } - } -} diff --git a/lib/data/local/staff_dao.dart b/lib/data/local/staff_dao.dart index b5cc873..cc6619c 100644 --- a/lib/data/local/staff_dao.dart +++ b/lib/data/local/staff_dao.dart @@ -25,10 +25,6 @@ class StaffDao { final Database _db; - /// Exposed for [StaffImport], which lives in this file and is part of this - /// type in everything but syntax — an extension cannot see a private field. - Database get db => _db; - static const _uuid = Uuid(); /// The accounts a shop starts with. @@ -150,6 +146,87 @@ 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, @@ -281,93 +358,3 @@ class StaffDao { isActive: (row['is_active'] as int? ?? 1) == 1, ); } - -/// Replaces the terminal's staff with what the back office says. -/// -/// The back office is the source of truth for who works at a shop, and this is -/// where that becomes true rather than aspirational. It exists because the -/// alternative — three names and three PINs compiled into the app — meant every -/// install of a build shared the same three logins, readable by anyone with the -/// APK. -/// -/// Three things happen, and the second is the one that matters: -/// -/// 1. every person the back office named is written, keyed on their user id so -/// a re-sync updates rather than duplicates; -/// 2. **the seeded accounts are deactivated**, so the moment a shop has real -/// staff the built-in PINs stop working — without this the hardcoded -/// logins would survive alongside the real ones for ever; and -/// 3. anyone previously imported who is no longer named is deactivated too, -/// because a leaver removed in the back office must lose the till. -/// -/// Deactivated, never deleted. Bills carry the cashier's name and shifts are -/// settled against it, so a hard delete would orphan a day's takings. -/// -/// Does nothing at all when [members] is empty. That is the common case today — -/// most outlets have no staff recorded — and wiping a working till's logins -/// because the back office has not been filled in yet would close a shop. -extension StaffImport on StaffDao { - Future replaceFromBackOffice(List members) async { - if (members.isEmpty) return 0; - - final now = DateTime.now().millisecondsSinceEpoch; - final imported = {}; - - for (final member in members) { - final pin = member.pin.trim(); - // A blank or malformed PIN cannot be signed in with. Skipped rather than - // written, so the till does not show a name nobody can use. - if (pin.length < 4 || int.tryParse(pin) == null) continue; - - final salt = PinHasher.newSalt(); - imported.add(member.localId); - - await db.insert( - Tables.staff, - { - 'id': member.localId, - 'name': member.name.isEmpty ? 'Staff ${member.localId}' : member.name, - 'role': member.role.name, - 'pin_hash': PinHasher.hash(pin, salt), - 'pin_salt': salt, - // Not flagged for change: this PIN was set by the shop in the back - // office, so it is already theirs. The flag is for the seeds. - 'must_change_pin': 0, - 'is_active': 1, - 'created_at': now, - 'updated_at': now, - }, - conflictAlgorithm: ConflictAlgorithm.replace, - ); - } - - // Nothing usable came back — leave the till exactly as it was rather than - // stranding it with no way to sign in. - if (imported.isEmpty) return 0; - - final placeholders = List.filled(imported.length, '?').join(','); - await db.update( - Tables.staff, - {'is_active': 0, 'updated_at': now}, - where: 'id NOT IN ($placeholders)', - whereArgs: imported.toList(), - ); - - return imported.length; - } -} - -/// One person to import, already mapped onto the till's own role vocabulary. -class StaffImportRecord { - const StaffImportRecord({ - required this.localId, - required this.name, - required this.role, - required this.pin, - }); - - final String localId; - final String name; - final StaffRole role; - final String pin; -} diff --git a/lib/data/remote/auth_api.dart b/lib/data/remote/auth_api.dart new file mode 100644 index 0000000..8d06f63 --- /dev/null +++ b/lib/data/remote/auth_api.dart @@ -0,0 +1,378 @@ +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/data/remote/pos_auth_api.dart b/lib/data/remote/pos_auth_api.dart deleted file mode 100644 index fc40769..0000000 --- a/lib/data/remote/pos_auth_api.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:http/http.dart' as http; - -import '../../domain/entities/pos_session.dart'; - -/// Raised when the back office refuses or cannot answer a sign-in. -/// -/// Carries a message meant to be shown to whoever is standing at the till, so -/// it is written for them rather than for a log: what happened, and what they -/// can do about it. -class PosAuthException implements Exception { - const PosAuthException(this.message, {this.isCredentialFailure = false}); - - final String message; - - /// Whether the details were wrong, as opposed to the back office being - /// unreachable. The till reacts differently: a bad password is worth - /// re-typing, an unreachable server is worth waiting for. - final bool isCredentialFailure; - - @override - String toString() => message; -} - -/// Signs a terminal in against the back office. -/// -/// Talks to the same `app_users` accounts as the web console, so a manager who -/// can open the back office can open the till with the same details — one -/// account store means deactivating a leaver closes both doors at once. -/// -/// ``` -/// POST {base}/login -/// { "authname": "…", "password": "…", "terminal_id": "T5EDD" } -/// ``` -/// -/// answered with `{ code, status, details: { token, store_id, locations, … } }`. -class PosAuthApi { - PosAuthApi({required this.baseUrl, http.Client? client}) - : _client = client ?? http.Client(); - - final String baseUrl; - final http.Client _client; - - /// Generous, because this runs on a shop's connection while somebody watches. - /// Short enough that a dead endpoint is reported rather than hung on. - static const _timeout = Duration(seconds: 20); - - /// Exchanges credentials for a session. - /// - /// [locationId] is only meaningful for an account entitled to several - /// outlets: it says which one this terminal is standing in. It is a request, - /// not an assertion — the back office checks it against what the account may - /// actually reach, and that check is the whole point of the endpoint. - Future login({ - required String authname, - required String password, - String? terminalId, - String? deviceId, - int? locationId, - int? configId, - }) async { - if (baseUrl.isEmpty) { - throw const PosAuthException( - 'This terminal has no back office configured. Set the endpoint in ' - 'Settings → Connectivity & sync.', - ); - } - - final body = { - 'authname': authname.trim(), - 'password': password, - if (terminalId != null && terminalId.isNotEmpty) 'terminal_id': terminalId, - if (deviceId != null && deviceId.isNotEmpty) 'device_id': deviceId, - if (locationId != null && locationId > 0) 'location_id': locationId, - // Sent only when known. The backend infers it when absent, and a shop - // has no way to find out what its configid is. - if (configId != null && configId > 0) 'configid': configId, - }; - - final http.Response response; - try { - response = await _client - .post( - Uri.parse('$baseUrl/login'), - headers: const {'Content-Type': 'application/json'}, - body: jsonEncode(body), - ) - .timeout(_timeout); - } on TimeoutException { - throw const PosAuthException( - 'The back office did not answer in time. Check the connection and try ' - 'again.', - ); - } on Object { - throw const PosAuthException( - 'Could not reach the back office. Check the connection and try again.', - ); - } - - Map decoded; - try { - decoded = jsonDecode(response.body) as Map; - } on Object { - throw PosAuthException( - 'The back office answered with something this terminal could not read ' - '(HTTP ${response.statusCode}).', - ); - } - - if (response.statusCode != 200) { - throw PosAuthException( - (decoded['message'] as String?) ?? - 'Sign-in was refused (HTTP ${response.statusCode}).', - // 401 is a wrong email or password; 403 is a real account that may not - // open this till. Only the first is worth re-typing. - isCredentialFailure: response.statusCode == 401, - ); - } - - final details = decoded['details']; - if (details is! Map) { - throw const PosAuthException( - 'The back office accepted the sign-in but returned no session.', - ); - } - - final session = PosSession.fromJson(details); - - // A session with no token cannot authenticate anything, and one with no - // outlet cannot bill. Refused here rather than being saved and failing - // later against every request, which would be much harder to diagnose. - if (session.token.isEmpty) { - throw const PosAuthException( - 'The back office returned a session with no token.', - ); - } - if (session.locationId <= 0) { - throw const PosAuthException( - 'This account is not attached to an outlet, so it cannot open a till.', - ); - } - - return session; - } - - void dispose() => _client.close(); -} diff --git a/lib/domain/entities/pos_session.dart b/lib/domain/entities/pos_session.dart deleted file mode 100644 index 21b1896..0000000 --- a/lib/domain/entities/pos_session.dart +++ /dev/null @@ -1,281 +0,0 @@ -/// What the back office answers a sign-in with. -/// -/// Replaces the arrangement where a till held a store id typed into Settings -/// and a password compiled into the app. That made the store id a *claim*: any -/// terminal could name any outlet and be believed, so one leaked build reached -/// every tenant on the platform. -/// -/// Now the outlet arrives *from* the back office as a consequence of who signed -/// in, sealed inside a signed token the terminal cannot edit. The till stops -/// deciding which shop it belongs to and starts being told. -class PosSession { - const PosSession({ - required this.token, - required this.expiresAt, - required this.userId, - required this.fullName, - required this.roleId, - required this.tenantId, - required this.tenantName, - required this.storeId, - required this.locationId, - required this.locationName, - this.email = '', - this.role = '', - this.canManageStaff = false, - this.gstin = '', - this.address = '', - this.phone = '', - this.outlets = const [], - this.staff = const [], - }); - - /// The bearer token, sent on every request from here on. - /// - /// Opaque on purpose. The terminal must not parse it, reason about it, or - /// trust anything it appears to say — its only correct use is to hand it back - /// and let the server decide what it means. - final String token; - - final DateTime expiresAt; - - final int userId; - final String fullName; - final String email; - final int roleId; - - /// The back office's name for [roleId] — "Supervisor", "Cashier", "Admin". - /// - /// For display. Never branch on it: `app_roles` holds six rows for four - /// distinct roles, and a great many accounts carry a `roleid` that is not in - /// the table at all and come back blank. [canManageStaff] is the flag to - /// read. - final String role; - - /// Whether this account runs the terminal or only bills on it. - /// - /// The one permission the till acts on, and it is answered by the back office - /// rather than worked out here. A supervisor gets the full shell; a cashier - /// gets the billing screen. Deciding it locally would mean shipping a copy of - /// the role table in the app and keeping the two in step for ever. - /// - /// Defaults to false, which is the least-privileged answer. That matters on - /// the restore path: a session saved by a build that predates this field - /// comes back as a cashier rather than silently as an admin. - final bool canManageStaff; - - final int tenantId; - final String tenantName; - - /// The outlet this terminal bills for, as a string because that is the shape - /// the sync configuration and every uplink already use. - final String storeId; - final int locationId; - final String locationName; - - /// Printed on the invoice, so a legal requirement rather than decoration. - /// Arriving with the session means a shop that corrects its GSTIN in the back - /// office sees it on the next receipt instead of at the next rebuild. - final String gstin; - final String address; - final String phone; - - /// Every outlet this account may open a till at. - /// - /// A single-shop user gets a list of one, so the sign-in flow has no special - /// case: it offers a choice when there is one and skips it when there is not. - final List outlets; - - bool get hasChoiceOfOutlet => outlets.length > 1; - - /// The people the back office says may ring a bill here. - /// - /// Very often empty. Only 116 of 596 accounts on the platform have a PIN set, - /// and most outlets — including the one this terminal ships pointed at — have - /// none at all. A till must treat that as an unfinished setup rather than as - /// a failure, which is why the seeded accounts still exist as a last resort. - final List staff; - - /// Whether the session is still worth sending. - /// - /// Checked against the terminal's own clock, which is the only one available - /// offline. A till whose clock is wrong will re-authenticate unnecessarily — - /// annoying, and much better than billing a whole day against a session the - /// server has already stopped accepting. - bool isValidAt(DateTime now) => now.isBefore(expiresAt); - - PosSession copyWith({ - String? storeId, - int? locationId, - String? locationName, - String? address, - }) => - PosSession( - token: token, - expiresAt: expiresAt, - userId: userId, - fullName: fullName, - email: email, - roleId: roleId, - role: role, - canManageStaff: canManageStaff, - tenantId: tenantId, - tenantName: tenantName, - storeId: storeId ?? this.storeId, - locationId: locationId ?? this.locationId, - locationName: locationName ?? this.locationName, - gstin: gstin, - address: address ?? this.address, - phone: phone, - outlets: outlets, - staff: staff, - ); - - factory PosSession.fromJson(Map json) { - final outlets = (json['locations'] as List? ?? const []) - .whereType>() - .map(PosOutlet.fromJson) - .toList(); - - return PosSession( - token: (json['token'] as String?) ?? '', - // A session with no readable expiry is treated as already finished rather - // than as never finishing. Guessing "valid" here would keep a till - // sending a token the server stopped honouring hours ago. - expiresAt: DateTime.tryParse((json['expires_at'] as String?) ?? '') - ?.toLocal() ?? - DateTime.fromMillisecondsSinceEpoch(0), - userId: _int(json['user_id']), - fullName: (json['full_name'] as String?)?.trim() ?? '', - email: (json['email'] as String?) ?? '', - roleId: _int(json['role_id']), - role: ((json['role'] as String?) ?? '').trim(), - canManageStaff: json['can_manage_staff'] == true, - tenantId: _int(json['tenant_id']), - tenantName: (json['tenant_name'] as String?) ?? '', - storeId: (json['store_id'] as String?) ?? '${_int(json['location_id'])}', - locationId: _int(json['location_id']), - locationName: (json['location_name'] as String?) ?? '', - gstin: (json['gstin'] as String?) ?? '', - address: (json['address'] as String?) ?? '', - phone: (json['phone'] as String?) ?? '', - outlets: outlets, - staff: (json['staff'] as List? ?? const []) - .whereType>() - .map(PosStaffMember.fromJson) - .where((m) => m.pin.isNotEmpty) - .toList(), - ); - } - - Map toJson() => { - 'token': token, - 'expires_at': expiresAt.toIso8601String(), - 'user_id': userId, - 'full_name': fullName, - 'email': email, - 'role_id': roleId, - 'role': role, - 'can_manage_staff': canManageStaff, - 'tenant_id': tenantId, - 'tenant_name': tenantName, - 'store_id': storeId, - 'location_id': locationId, - 'location_name': locationName, - 'gstin': gstin, - 'address': address, - 'phone': phone, - 'locations': outlets.map((o) => o.toJson()).toList(), - 'staff': staff.map((m) => m.toJson()).toList(), - }; -} - -/// One outlet a signed-in account may bill for. -class PosOutlet { - const PosOutlet({ - required this.locationId, - required this.locationName, - this.address = '', - this.city = '', - }); - - final int locationId; - final String locationName; - final String address; - final String city; - - String get storeId => '$locationId'; - - factory PosOutlet.fromJson(Map json) => PosOutlet( - locationId: _int(json['location_id']), - locationName: (json['location_name'] as String?) ?? '', - address: (json['address'] as String?) ?? '', - city: (json['city'] as String?) ?? '', - ); - - Map toJson() => { - 'location_id': locationId, - 'location_name': locationName, - 'address': address, - 'city': city, - }; -} - -/// Reads an id that may arrive as a number or as a string. -/// -/// The backend sends `location_id` as an int and `store_id` as a string for the -/// same value, and a till that accepted only one shape would silently read zero -/// for the other — which looks like "no outlet" rather than like a bug. -int _int(Object? value) => switch (value) { - final int v => v, - final num v => v.toInt(), - final String v => int.tryParse(v.trim()) ?? 0, - _ => 0, - }; - -/// One person the back office says may ring a bill at this outlet. -/// -/// The PIN arrives in the clear over TLS and is hashed before it touches disk — -/// see [PosSession] and the backend's `PosStaffMember` for why hashing it -/// server-side would have bought the appearance of strength and not the -/// substance. A four-digit PIN is brute-forceable in microseconds regardless; -/// what it is, is *shift attribution* — which of the people already inside a -/// shop gets credited with a sale. The security boundary is the session token. -class PosStaffMember { - const PosStaffMember({ - required this.userId, - required this.fullName, - required this.pin, - this.role = '', - }); - - final int userId; - final String fullName; - final String pin; - - /// The back office's own role name — "Admin", "Manager", "Operations". Blank - /// for the many accounts whose `roleid` is not in `app_roles` at all. - final String role; - - /// A stable local id for a row that came from the back office. - /// - /// Prefixed so an imported account can be told apart from one seeded on this - /// device. That distinction is what lets a sync retire the seeded logins - /// without touching anything a shop created itself. - String get localId => 'boffice-$userId'; - - factory PosStaffMember.fromJson(Map json) => - PosStaffMember( - userId: _int(json['user_id']), - fullName: ((json['full_name'] as String?) ?? '').trim(), - pin: ((json['pin'] as String?) ?? '').trim(), - role: ((json['role'] as String?) ?? '').trim(), - ); - - Map toJson() => { - 'user_id': userId, - 'full_name': fullName, - 'pin': pin, - 'role': role, - }; -} diff --git a/lib/presentation/auth/providers/auth_controller.dart b/lib/presentation/auth/providers/auth_controller.dart index 11a1f6d..0fb51db 100644 --- a/lib/presentation/auth/providers/auth_controller.dart +++ b/lib/presentation/auth/providers/auth_controller.dart @@ -1,9 +1,10 @@ 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/pos_auth_api.dart'; -import '../../../domain/entities/pos_session.dart'; +import '../../../data/remote/auth_api.dart'; import '../../../domain/entities/store_account.dart'; /// Sign-in state for the terminal. @@ -26,19 +27,35 @@ class Authenticated extends AuthState { required this.store, required this.user, required this.login, + this.session, }); final StoreAccount store; final StaffUser user; - /// What this session may open. The authority on what the terminal shows — - /// not [user], which can be swapped at the till without re-authenticating. + /// Which credential opened this session. The authority on what the terminal + /// is allowed to show — not [user], which can be swapped at the till. final TerminalLogin login; + /// What the back office answered with. Null only on the offline demo path, + /// where there was no back office to answer. + /// + /// Everything downstream reads from here rather than from a constant: the + /// bearer token for the catalogue pull and the order push, the location id + /// they are scoped to, and the tenant name shown beside the logo. + final LoginSession? session; + StaffRole get role => login.role; bool get isAdmin => login == TerminalLogin.admin; bool get isCashier => login == TerminalLogin.cashier; + + /// The name beside the logo: the tenant, then the outlet, then whatever the + /// store record on this terminal says. + String get storeName { + final fromApi = session?.displayStoreName ?? ''; + return fromApi.isNotEmpty ? fromApi : store.name; + } } class AuthFailure extends AuthState { @@ -47,92 +64,80 @@ class AuthFailure extends AuthState { final String message; } -/// What a signed-in account may do with this terminal. +/// What this terminal is allowed to open. /// -/// Two shapes, because the terminal only ever behaves in two ways, and the -/// split is what the roles are *for* rather than decoration: +/// No longer a credential — the back office decides the role now, and +/// [AuthController.signIn] maps its answer onto one of these. The two values +/// remain because the whole shell keys off them: /// -/// * [admin] runs the whole shell and is the only login that can pull the +/// * [admin] runs the full shell and is the only login that can pull the /// catalogue. Signing out leaves the products on the terminal. -/// * [cashier] gets the billing screen and nothing else, and signing out -/// takes the catalogue with it. +/// * [cashier] gets the billing screen and nothing else, and every way out of +/// the session takes the catalogue with it. /// -/// This used to carry an email and a password per entry, and the terminal -/// decided which role you were by comparing what you typed against those -/// constants. That made the role a property of the *build* — every install -/// shared two logins, and a shop could not add a third person or revoke the -/// two it had without shipping a new APK. -/// -/// The role now arrives from the back office as a property of the *account*. -/// [forSession] is the only way to construct one, so there is no path left -/// where the terminal grants itself a permission the server did not send. +/// The email and password fields are the built-in demo accounts, used only by +/// the offline path — see [ApiConfig.allowOfflineDemoLogin]. enum TerminalLogin { admin( - label: 'Supervisor', + label: 'Admin', + email: 'admin@nearle.in', + password: 'nearle123', role: StaffRole.admin, - blurb: 'Full shell — import products, promos, staff, settings.', + 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 supervisor imported.', + blurb: 'Billing only, on the products the admin imported.', ); const TerminalLogin({ required this.label, + required this.email, + required this.password, required this.role, required this.blurb, }); final String label; + final String email; + final String password; final StaffRole role; final String blurb; - /// The catalogue is pulled once by a supervisor and billed against by - /// whoever is on the counter, so only the cashier's sign-out drops it. A - /// supervisor closing the shell is a handover, not the end of the day. + /// The catalogue is pulled once by an admin and billed against by whoever is + /// on the counter, so only the cashier's sign-out drops it. An admin closing + /// the shell is a handover, not the end of the day. bool get clearsCatalogueOnSignOut => this == TerminalLogin.cashier; - /// Which shell the back office says this account gets. - /// - /// Reads `can_manage_staff` rather than the role name or id. The name is - /// free text and often blank, and `app_roles` holds six rows for four - /// distinct roles with a great many accounts carrying a `roleid` that is not - /// in the table at all — so matching on either here would mean keeping a - /// copy of the role table in the app and keeping the two in step for ever. - /// One boolean, decided by the server, cannot drift. - static TerminalLogin forSession(PosSession session) => - session.canManageStaff ? TerminalLogin.admin : TerminalLogin.cashier; + static TerminalLogin? byEmail(String email) { + final normalised = email.trim().toLowerCase(); + for (final login in TerminalLogin.values) { + if (login.email == normalised) return login; + } + return null; + } +} + +/// The built-in accounts, used only by the offline path. +class DemoCredentials { + const DemoCredentials._(); + + static const String email = 'admin@nearle.in'; + static const String password = 'nearle123'; + + static const String cashierEmail = 'cashier@nearle.in'; + static const String cashierPassword = 'cashier123'; } /// Signs the terminal in against the back office and holds the session. -/// -/// This used to compare against constants compiled into the app, with a 600ms -/// delay standing in for a network call that was never made. Two things were -/// wrong with that, and the second was the serious one: -/// -/// 1. every install of a build shared one password, and changing it meant a -/// rebuild; and -/// 2. because nothing was checked with the back office, the *outlet* could not -/// come from the sign-in. It came from a store id typed into Settings — so -/// a till named its own shop and was believed, and one number changed on -/// one screen moved a terminal into another tenant's books. -/// -/// Now a person signs in with their own back-office account, and both the -/// outlet and the role arrive as a consequence: sealed in a signed token, -/// checked server-side on every request, and not editable from this device. class AuthController extends StateNotifier { AuthController(this._ref) : super(const Unauthenticated()); final Ref _ref; - /// The back office's answer to the last sign-in, if there is one. - /// - /// Held so the outlet picker can offer a proprietor their other shops without - /// asking for the password a second time. - PosSession? _session; - PosSession? get session => _session; - /// Whether signing out right now would wipe the products off this terminal. /// /// Read *before* [signOut] by anything that needs to warn the operator, since @@ -142,196 +147,158 @@ class AuthController extends StateNotifier { return current is Authenticated && current.login.clearsCatalogueOnSignOut; } - /// Restores a session saved on a previous run. + /// Signs in against `POST /login`. /// - /// Called at start-up so a till that was rebooted mid-shift comes back - /// trading rather than showing a login screen to a queue of customers. - /// Returns false when there is nothing usable, which includes an expired - /// session — [SessionStore] treats those as absent. - Future restore() async { - final saved = await _ref.read(sessionStoreProvider).read(); - if (saved == null) return false; - - await _adopt(saved); - return state is Authenticated; - } - + /// 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, - int? locationId, }) async { state = const Authenticating(); - final terminal = _ref.read(terminalIdentityProvider); + 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'; - final PosSession session; + LoginSession session; try { - session = await _ref.read(posAuthApiProvider).login( + session = await _ref.read(authApiProvider).login( authname: email, password: password, - terminalId: terminal.code, - deviceId: terminal.deviceId, - locationId: locationId, + deviceId: deviceId, ); - } on PosAuthException catch (e) { + } 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; - } on Object { - state = const AuthFailure( - 'Sign-in failed for an unexpected reason. Please try again.', + } + + try { + await _applySession(session); + } on Object catch (e) { + state = AuthFailure( + 'Signed in, but this terminal could not store the store details: $e', ); return false; } - await _ref.read(sessionStoreProvider).write(session); - await _adopt(session); - - return state is Authenticated; + return true; } - /// Moves this terminal to another of the signed-in account's outlets. + /// Writes everything the session decided into the terminal, then opens it. /// - /// A fresh sign-in rather than a local switch, because the outlet is inside - /// the signed token: the back office has to issue a new one, and re-checking - /// entitlement at that moment is the point. Requires the password again, - /// which is correct — moving a till between shops changes whose books it - /// writes to. - Future switchOutlet({ - required String password, - required int locationId, - }) async { - final current = _session; - if (current == null) return false; + /// 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; - return signIn( - email: current.email.isNotEmpty ? current.email : current.fullName, - password: password, - locationId: locationId, - ); - } + // 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); + } - /// Adopts a session: points the terminal at its outlet, then opens it. - /// - /// Order matters. The store id and token are written *before* the catalogue - /// or any uplink can run, so a terminal can never spend even one request - /// pointed at the outlet it had yesterday while claiming to be signed in as - /// today's. - Future _adopt(PosSession session) async { - _session = session; - - await _ref.read(localStoreProvider).identityStore.rename( - storeId: session.storeId, - ); + // 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); - _ref.read(syncConfigProvider.notifier).state = - _ref.read(syncConfigProvider).copyWith( - storeId: session.storeId, - sessionToken: session.token, - ); + // 3. The staff list, so PIN switching at the counter works against the + // people head office actually employs. + final me = await _syncStaff(session); - // Store details for the receipt come from the back office now, not from - // constants compiled into the build. A GSTIN is a legal requirement on a - // tax invoice; it should not need a rebuild to correct. - await _ref.read(storeRepositoryProvider).save( - name: session.locationName.isNotEmpty - ? session.locationName - : session.tenantName, - address: session.address, - gstin: session.gstin, - phone: session.phone, + // 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, ); - // Who may ring a bill here, per the back office. - // - // This is what retires the seeded logins. The till ships with three names - // and three PINs compiled into it — the same three on every install — and - // they exist only so a shop whose back office has no staff recorded can - // still trade on day one. The moment real staff arrive they are - // deactivated, which is the whole point of importing rather than merging. - // - // Empty is the common case rather than an error: most outlets have nobody - // recorded, including the one this build ships pointed at. The import - // no-ops, the seeds survive, and the shop keeps selling. - await _importStaff(session); + state = Authenticated( + store: account, + user: me, + login: session.isAdmin ? TerminalLogin.admin : TerminalLogin.cashier, + session: session, + ); _ref.invalidate(storeAccountProvider); - 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.', + /// 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, ); - return; } - state = Authenticated( - store: store, - user: _opener(staff, session), - login: TerminalLogin.forSession(session), + // 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, ); } - /// Who the terminal attributes bills to the moment it opens. + /// The built-in accounts, for a terminal with no line to the back office. /// - /// The person who just signed in, if the import wrote them — matched on the - /// back office id rather than the name, which is neither unique nor stable. - /// Falls back to anyone rather than failing: the session's permissions come - /// from the token, so a shop whose staff list is empty or unsynced still gets - /// a usable till, and the bills are simply stamped with the account that is - /// there until someone switches with their PIN. - StaffUser _opener(List staff, PosSession session) { - final mine = 'boffice-${session.userId}'; - for (final s in staff) { - if (s.id == mine) return s; - } + /// 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 wanted = TerminalLogin.forSession(session).role; - return staff.firstWhere((s) => s.role == wanted, orElse: () => staff.first); + final store = await _ref.read(storeRepositoryProvider).load(email: email); + if (store.staff.isEmpty) return false; + + final opener = store.staff.firstWhere( + (s) => s.role == login.role, + orElse: () => store.staff.first, + ); + + state = Authenticated(store: store, user: opener, login: login); + return true; } - /// Writes the back office's staff over this terminal's. - /// - /// Failures are swallowed. A shop must be able to open its till even when the - /// staff import fails — the seeded or previously-synced accounts are still - /// there, and refusing the sign-in would trade a working counter for a - /// tidier database. - Future _importStaff(PosSession session) async { - if (session.staff.isEmpty) return; - - try { - await _ref.read(localStoreProvider).staff.replaceFromBackOffice([ - for (final member in session.staff) - StaffImportRecord( - localId: member.localId, - name: member.fullName, - role: _roleFor(member.role), - pin: member.pin, - ), - ]); - } on Object { - // Deliberately silent — see above. - } - } - - /// Maps the back office's role names onto the till's three. - /// - /// `app_roles` holds six rows for four distinct roles — Admin and Manager are - /// each in there twice — and most accounts carry a `roleid` that is not in - /// the table at all. So this matches on the name and falls back to the least - /// privileged answer: an unrecognised role must not silently become an admin. - /// - /// Supervisor is the role a shop actually hands out; it sits with Admin - /// because a supervisor *is* the till's administrator. - StaffRole _roleFor(String backOfficeRole) => - switch (backOfficeRole.trim().toLowerCase()) { - 'super admin' || 'admin' || 'supervisor' => StaffRole.admin, - 'manager' || 'operations' => StaffRole.manager, - _ => StaffRole.cashier, - }; - /// Switches the active operator, checking their PIN. /// /// Every bill is stamped with whoever is active, so this is the boundary that @@ -339,11 +306,6 @@ class AuthController extends StateNotifier { /// list. It changes who the bill names, never what the session may open: /// [Authenticated.login] is untouched, so a cashier terminal stays a cashier /// terminal. - /// - /// That is deliberate. A PIN is four digits typed at an unattended counter; - /// it is shift attribution, not a privilege boundary. Escalating to the full - /// shell takes a real sign-in, because that is the only thing the back office - /// sees and signs. Future switchUser(String pin) async { final current = state; if (current is! Authenticated) return false; @@ -356,6 +318,7 @@ class AuthController extends StateNotifier { store: current.store, user: user, login: current.login, + session: current.session, ); return true; } @@ -375,34 +338,28 @@ 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. /// - /// The token always goes, from the keystore and from the live configuration - /// both. Leaving it in place would let a signed-out terminal keep uploading - /// as the shop that signed in this morning, so that half is a security - /// matter and takes no exception. - /// - /// The catalogue is a workflow matter and does take one. Every way out of a - /// cashier session clears the products — ending a shift and a temporary - /// logout alike — because the terminal is left unattended either way and the + /// 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. A supervisor 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. + /// than a catalogue carried over. + /// + /// An admin signing out is the opposite case. They have just pulled the + /// products *so that* a cashier can pick the terminal up, so dropping the + /// table here would make the import pointless. + /// + /// The bearer token needs no clearing: it lives on the session, so it goes + /// when the state does, and `syncConfigProvider` is derived from it. Future signOut() async { if (clearsCatalogueOnSignOut) { await _ref.read(localStoreProvider).clearCatalogue(); } - - await _ref.read(sessionStoreProvider).clear(); - - _ref.read(syncConfigProvider.notifier).state = - _ref.read(syncConfigProvider).copyWith(sessionToken: ''); - - _session = null; state = const Unauthenticated(); } @@ -427,6 +384,31 @@ 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 de5816c..ad325e7 100644 --- a/lib/presentation/auth/screens/login_screen.dart +++ b/lib/presentation/auth/screens/login_screen.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -7,7 +9,7 @@ import '../../../app/providers.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'; @@ -29,9 +31,11 @@ class LoginScreen extends ConsumerStatefulWidget { class _LoginScreenState extends ConsumerState { final _formKey = GlobalKey(); - // Both fields start empty. There is nothing to prefill: a person signs in - // with their own back-office account, and which shell they get is a property - // of that account rather than a tab they picked before typing. + // 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(); @@ -247,15 +251,17 @@ class _Card extends StatelessWidget { ), const SizedBox(height: AppSpacing.xl), - const _Label('Store email'), + const _Label('Email or username'), TextFormField( controller: email, keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, enabled: !busy, - validator: (v) => (v ?? '').trim().isEmpty - ? 'Store email is required' - : Validators.emailOptional(v), + // 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, decoration: const InputDecoration( hintText: 'store@example.in', prefixIcon: Icon(Icons.storefront_outlined), @@ -270,9 +276,8 @@ 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), + validator: (v) => + (v ?? '').isEmpty ? 'Password is required' : null, decoration: InputDecoration( hintText: 'Enter your password', prefixIcon: const Icon(Icons.lock_outline_rounded), @@ -393,3 +398,4 @@ class _Label extends StatelessWidget { ); } } + diff --git a/lib/presentation/customer/widgets/customer_capture_sheet.dart b/lib/presentation/customer/widgets/customer_capture_sheet.dart index a0eb1aa..dc15051 100644 --- a/lib/presentation/customer/widgets/customer_capture_sheet.dart +++ b/lib/presentation/customer/widgets/customer_capture_sheet.dart @@ -93,10 +93,8 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> { /// step. Future _save() async { if (!_complete) { - setState( - () => _error = 'Enter all ' - '${AppConstants.mobileNumberLength} digits of the mobile number.', - ); + setState(() => _error = 'Enter all ' + '${AppConstants.mobileNumberLength} digits of the mobile number.'); return; } diff --git a/lib/presentation/pos/screens/pos_view.dart b/lib/presentation/pos/screens/pos_view.dart index 2a82f38..a3b132b 100644 --- a/lib/presentation/pos/screens/pos_view.dart +++ b/lib/presentation/pos/screens/pos_view.dart @@ -9,6 +9,7 @@ import '../../auth/providers/auth_controller.dart'; import '../../sync/providers/sync_controller.dart'; import '../providers/navigation_provider.dart'; import '../widgets/category_chips.dart'; +import '../widgets/customer_bar.dart'; import '../widgets/product_grid.dart'; import '../widgets/scan_toast.dart'; import '../widgets/search_field.dart'; diff --git a/lib/presentation/pos/widgets/app_sidebar.dart b/lib/presentation/pos/widgets/app_sidebar.dart index 2956926..8fe1ea4 100644 --- a/lib/presentation/pos/widgets/app_sidebar.dart +++ b/lib/presentation/pos/widgets/app_sidebar.dart @@ -78,13 +78,36 @@ class AppSidebar extends ConsumerWidget { } } -class _Brand extends StatelessWidget { +/// 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 { const _Brand({required this.expanded}); final bool expanded; @override - Widget build(BuildContext context) { + 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; + return Container( height: AppSizes.headerHeight, padding: EdgeInsets.symmetric( @@ -102,9 +125,11 @@ class _Brand extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Nearle', - style: TextStyle( + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w700, letterSpacing: -0.3, @@ -113,7 +138,9 @@ class _Brand extends StatelessWidget { ), ), Text( - 'POS', + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: AppTypography.sectionLabel() .copyWith(color: AppColors.primary), ), diff --git a/lib/presentation/pos/widgets/page_header.dart b/lib/presentation/pos/widgets/page_header.dart index 2d1403f..46f3273 100644 --- a/lib/presentation/pos/widgets/page_header.dart +++ b/lib/presentation/pos/widgets/page_header.dart @@ -404,8 +404,15 @@ 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, @@ -419,7 +426,8 @@ class _CashierBrand extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - store?.name ?? 'Nearle POS', + title, + maxLines: 1, 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 1428b27..a996bc7 100644 --- a/lib/presentation/shift/screens/end_shift_screen.dart +++ b/lib/presentation/shift/screens/end_shift_screen.dart @@ -11,6 +11,7 @@ import '../../../core/utils/formatters.dart'; import '../../../core/widgets/primary_button.dart'; import '../../../domain/entities/shift_report.dart'; import '../../../domain/entities/transaction.dart'; +import '../../../domain/repositories/sync_repository.dart'; import '../../auth/providers/auth_controller.dart'; import '../../payment/screens/payment_screen.dart' show methodIcon; import '../../pos/providers/cart_controller.dart'; diff --git a/lib/presentation/sync/providers/sync_controller.dart b/lib/presentation/sync/providers/sync_controller.dart index 4a6de6b..adcd90f 100644 --- a/lib/presentation/sync/providers/sync_controller.dart +++ b/lib/presentation/sync/providers/sync_controller.dart @@ -222,18 +222,6 @@ final syncBootstrapProvider = FutureProvider((ref) async { await store.syncConfig.load(ref.read(syncConfigProvider)); } - // Bring back the session this terminal was signed in under. - // - // Runs before the engine starts, and that ordering is load-bearing: the - // session carries both the bearer token and the outlet, so a drain that began - // first would upload the day's bills unauthenticated — and, once the backend - // is enforcing, have them refused. - // - // A till signs in when a shop opens and trades all day. Without this a reboot - // mid-shift would put a login screen in front of a queue of customers, which - // is a worse outage than the one it protects against. - await ref.read(authControllerProvider.notifier).restore(); - await ref.read(connectivityServiceProvider).start(); final engine = ref.read(syncEngineProvider); diff --git a/lib/presentation/sync/widgets/sign_out_dialog.dart b/lib/presentation/sync/widgets/sign_out_dialog.dart index db89535..92b6ba3 100644 --- a/lib/presentation/sync/widgets/sign_out_dialog.dart +++ b/lib/presentation/sync/widgets/sign_out_dialog.dart @@ -95,7 +95,7 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> { if (outcome.isSuccess) { await Future.delayed(const Duration(milliseconds: 700)); - if (mounted) await _finish(); + if (mounted) _finish(); } } diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..d76d2a7 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:nearle_pos/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +}