import 'package:equatable/equatable.dart'; import 'store_account.dart'; /// One outlet the signed-in account is allowed to work. /// /// A supervisor at a single-shop tenant gets one entry; a multi-outlet account /// gets the list, which is what an outlet picker would be built from. class SessionLocation extends Equatable { const SessionLocation({ required this.locationId, required this.locationName, required this.address, required this.city, required this.status, }); final int locationId; final String locationName; final String address; final String city; final String status; bool get isActive => status.toLowerCase() == 'active'; factory SessionLocation.fromJson(Map json) => SessionLocation( locationId: _asInt(json['location_id']), locationName: _asString(json['location_name']), address: _asString(json['address']), city: _asString(json['city']), status: _asString(json['status']), ); Map toJson() => { 'location_id': locationId, 'location_name': locationName, 'address': address, 'city': city, 'status': status, }; @override List get props => [locationId, locationName, address, city, status]; } /// A person the back office says may work this terminal. /// /// The `pin` the server returns is in the clear. It is kept because switching /// operators at the till is a PIN entry and nothing else, but it is the reason /// [PosSession] is written to the platform keystore rather than to SQLite — /// and it is worth pushing the back office to return a hash instead. class SessionStaff extends Equatable { const SessionStaff({ required this.userId, required this.fullName, required this.role, required this.pin, required this.status, }); final int userId; final String fullName; final String role; final String pin; final String status; bool get isActive => status.toLowerCase() == 'active'; factory SessionStaff.fromJson(Map json) => SessionStaff( userId: _asInt(json['user_id']), fullName: _asString(json['full_name']), role: _asString(json['role']), pin: _asString(json['pin']), status: _asString(json['status']), ); Map toJson() => { 'user_id': userId, 'full_name': fullName, 'role': role, 'pin': pin, 'status': status, }; @override List get props => [userId, fullName, role, pin, status]; } /// Everything `POST /pos/login` answered with, plus the account it was issued /// to. /// /// This is the whole session: the bearer token every later call needs, who is /// signed in, and which outlet the terminal is now trading as. It is persisted /// verbatim so a restart does not force a fresh sign-in, and dropped entirely /// on sign-out. class PosSession extends Equatable { const PosSession({ required this.token, required this.authname, required this.userId, required this.fullName, required this.roleId, required this.role, required this.tenantId, required this.tenantName, required this.storeId, required this.locationId, required this.locationName, required this.address, required this.gstin, required this.phone, this.expiresAt, this.canManageStaff = false, this.locations = const [], this.staff = const [], }); /// Bearer token for every subsequent call. Never logged, never printed. final String token; /// The credential this session was opened with. Kept only so the login /// screen can pre-fill it on the next shift. final String authname; final int userId; final String fullName; /// Numeric role from the back office (7 = Supervisor on this tenant). /// /// Recorded, but never the thing that decides what the terminal opens — see /// [isCashier]. Ids are tenant configuration and can be renumbered; the role /// name is the stable contract. final int roleId; /// Role name as the server spells it — `Supervisor`, `Cashier`, `Admin`. final String role; final bool canManageStaff; final int tenantId; final String tenantName; /// The outlet, as a string, matching what the sync topics are namespaced on. final String storeId; final int locationId; final String locationName; /// Printed on every invoice, so these come from the back office rather than /// from anything typed into this terminal. final String address; final String gstin; final String phone; final DateTime? expiresAt; final List locations; final List staff; /// Roles that get the full shell: catalogue import, promos, settings, staff. static const Set adminRoles = { 'admin', 'administrator', 'owner', 'supervisor', 'manager', 'store manager', }; /// Whether this session is locked down to the billing screen. /// /// Anything not in [adminRoles] lands here, including a role this build has /// never seen. A new back-office role must not silently inherit catalogue /// and settings access because nobody remembered to list it — the failure /// should be "the supervisor sees a till", which someone reports in a /// minute, not "the cashier can edit prices", which nobody notices. bool get isCashier => !adminRoles.contains(role.trim().toLowerCase()); /// How this account maps onto the terminal's own permission model. /// /// Two values, not four: the shell has exactly two shapes, and every /// non-cashier role the back office issues is expected to be able to import /// products and edit the store's details — which is what `StaffRole.admin` /// unlocks locally. StaffRole get staffRole => isCashier ? StaffRole.cashier : StaffRole.admin; /// The operator, in the shape the rest of the app already speaks. StaffUser get user => StaffUser( id: '$userId', name: fullName, role: staffRole, // The back office owns this credential now, so the terminal never // forces a PIN change on an account it did not seed. mustChangePin: false, ); bool get isExpired => expiresAt != null && !DateTime.now().toUtc().isBefore(expiresAt!.toUtc()); /// Reads the `details` object of a successful login response. factory PosSession.fromDetails( Map details, { required String authname, }) => PosSession( token: _asString(details['token']), authname: authname, userId: _asInt(details['user_id']), fullName: _asString(details['full_name']), roleId: _asInt(details['role_id']), role: _asString(details['role']), canManageStaff: _asBool(details['can_manage_staff']), tenantId: _asInt(details['tenant_id']), tenantName: _asString(details['tenant_name']), storeId: _asString(details['store_id']), locationId: _asInt(details['location_id']), locationName: _asString(details['location_name']), address: _asString(details['address']), gstin: _asString(details['gstin']), phone: _asString(details['phone']), expiresAt: DateTime.tryParse(_asString(details['expires_at'])), locations: _asList(details['locations'], SessionLocation.fromJson), staff: _asList(details['staff'], SessionStaff.fromJson), ); /// Round-trips through [toJson], for reading back out of the keystore. factory PosSession.fromJson(Map json) => PosSession.fromDetails(json, authname: _asString(json['authname'])); Map toJson() => { 'token': token, 'authname': authname, 'user_id': userId, 'full_name': fullName, 'role_id': roleId, 'role': role, 'can_manage_staff': canManageStaff, 'tenant_id': tenantId, 'tenant_name': tenantName, 'store_id': storeId, 'location_id': locationId, 'location_name': locationName, 'address': address, 'gstin': gstin, 'phone': phone, 'expires_at': expiresAt?.toUtc().toIso8601String(), 'locations': locations.map((l) => l.toJson()).toList(), 'staff': staff.map((s) => s.toJson()).toList(), }; @override List get props => [token, userId, roleId, role, locationId]; /// Never let a token reach a log line or a crash report. @override String toString() => 'PosSession($fullName, $role, $locationName, expires $expiresAt)'; } // --------------------------------------------------------------- Decoding // // Tolerant on purpose. `store_id` arrives as a string and `location_id` as a // number for the same outlet, and a field the back office adds later must not // crash a till mid-shift. String _asString(Object? value) => value == null ? '' : '$value'; int _asInt(Object? value) => switch (value) { final int v => v, final num v => v.toInt(), final String v => int.tryParse(v) ?? 0, _ => 0, }; bool _asBool(Object? value) => switch (value) { final bool v => v, final num v => v != 0, final String v => v.toLowerCase() == 'true' || v == '1', _ => false, }; List _asList(Object? raw, T Function(Map) decode) { if (raw is! List) return const []; return raw .whereType>() .map(decode) .toList(growable: false); }