379 lines
12 KiB
Dart
379 lines
12 KiB
Dart
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<String, Object?> 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<String, Object?> 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<ApiLocation> locations;
|
||
final List<ApiStaffMember> 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<String, Object?> 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<int> 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<LoginSession> 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<String, Object?> body;
|
||
try {
|
||
body = jsonDecode(response.body) as Map<String, Object?>;
|
||
} 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<String, Object?>) {
|
||
// 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<T> _list<T>(Object? raw, T Function(Map<String, Object?>) decode) {
|
||
if (raw is! List) return const [];
|
||
return raw.whereType<Map<String, Object?>>().map(decode).toList();
|
||
}
|