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(); }