check
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../core/config/api_config.dart';
|
||||||
import '../core/config/sync_config.dart';
|
import '../core/config/sync_config.dart';
|
||||||
import '../core/services/connectivity_service.dart';
|
import '../core/services/connectivity_service.dart';
|
||||||
import '../core/services/receipt_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/datasources/local_store.dart';
|
||||||
import '../data/repositories/customer_repository_impl.dart';
|
import '../data/repositories/customer_repository_impl.dart';
|
||||||
import '../data/repositories/product_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/catalogue_source.dart';
|
||||||
import '../data/remote/http_catalogue_source.dart';
|
import '../data/remote/http_catalogue_source.dart';
|
||||||
import '../data/remote/simulated_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/store_repository_impl.dart';
|
||||||
import '../data/repositories/sync_repository_impl.dart';
|
import '../data/repositories/sync_repository_impl.dart';
|
||||||
import '../data/repositories/transaction_repository_impl.dart';
|
import '../data/repositories/transaction_repository_impl.dart';
|
||||||
import '../data/local/session_store.dart';
|
|
||||||
import '../data/local/terminal_identity.dart';
|
import '../data/local/terminal_identity.dart';
|
||||||
import '../data/remote/pos_auth_api.dart';
|
|
||||||
import '../data/sync/sync_engine.dart';
|
import '../data/sync/sync_engine.dart';
|
||||||
import '../domain/repositories/customer_repository.dart';
|
import '../domain/repositories/customer_repository.dart';
|
||||||
import '../domain/repositories/product_repository.dart';
|
import '../domain/repositories/product_repository.dart';
|
||||||
@@ -91,14 +91,30 @@ final catalogueSourceProvider = Provider<CatalogueSource>((ref) {
|
|||||||
/// exists to prevent.
|
/// exists to prevent.
|
||||||
final syncConfigProvider = StateProvider<SyncConfig>((ref) {
|
final syncConfigProvider = StateProvider<SyncConfig>((ref) {
|
||||||
final terminal = ref.watch(terminalIdentityProvider);
|
final terminal = ref.watch(terminalIdentityProvider);
|
||||||
|
final session = ref.watch(apiSessionProvider);
|
||||||
|
|
||||||
return SyncConfig(
|
return SyncConfig(
|
||||||
transport: TransportKind.http,
|
transport: TransportKind.http,
|
||||||
httpBaseUrl: 'https://fiesta.nearle.app/live/api/v1/pos',
|
httpBaseUrl: ApiConfig.baseUrl,
|
||||||
storeId: terminal.storeId,
|
// 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,
|
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<AuthApi>((ref) {
|
||||||
|
final api = AuthApi();
|
||||||
|
ref.onDispose(api.dispose);
|
||||||
|
return api;
|
||||||
|
});
|
||||||
|
|
||||||
/// Real network state, folded with the Settings offline switch.
|
/// Real network state, folded with the Settings offline switch.
|
||||||
final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
|
final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
|
||||||
final service = ConnectivityService(
|
final service = ConnectivityService(
|
||||||
@@ -163,30 +179,16 @@ final storeRepositoryProvider = Provider<StoreRepositoryImpl>(
|
|||||||
(ref) => StoreRepositoryImpl(ref.watch(localStoreProvider)),
|
(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<PosAuthApi>((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<SessionStore>((ref) => SessionStore());
|
|
||||||
|
|
||||||
/// The outlet, refreshed whenever staff or details change.
|
/// The outlet, refreshed whenever staff or details change.
|
||||||
///
|
final storeAccountProvider = FutureProvider<StoreAccount>((ref) {
|
||||||
/// The email is the signed-in account's, not a constant. It used to be a
|
// The address the back office signed in, so Settings and the invoice header
|
||||||
/// `DemoCredentials.email` compiled into the build — the same address on every
|
// show the account that actually owns this till rather than a build
|
||||||
/// install, which is what made the store login decorative.
|
// constant. Falls back only before anyone has signed in.
|
||||||
final storeAccountProvider = FutureProvider<StoreAccount>(
|
final email = ref.watch(apiSessionProvider)?.email;
|
||||||
(ref) => ref.watch(storeRepositoryProvider).load(
|
return ref.watch(storeRepositoryProvider).load(
|
||||||
email: ref.watch(authControllerProvider.notifier).session?.email ?? '',
|
email: (email == null || email.isEmpty) ? DemoCredentials.email : email,
|
||||||
),
|
);
|
||||||
);
|
});
|
||||||
|
|
||||||
/// Campaigns stored on this terminal.
|
/// Campaigns stored on this terminal.
|
||||||
final promosProvider = FutureProvider<List<Promo>>(
|
final promosProvider = FutureProvider<List<Promo>>(
|
||||||
@@ -246,9 +248,11 @@ final terminalIdentityProvider = Provider<TerminalIdentity>((ref) {
|
|||||||
|
|
||||||
final cashierSessionProvider = StateProvider<CashierSession>((ref) {
|
final cashierSessionProvider = StateProvider<CashierSession>((ref) {
|
||||||
final terminal = ref.watch(terminalIdentityProvider);
|
final terminal = ref.watch(terminalIdentityProvider);
|
||||||
|
final session = ref.watch(apiSessionProvider);
|
||||||
|
|
||||||
return CashierSession(
|
return CashierSession(
|
||||||
name: 'Suriya',
|
name: session?.displayUserName ?? 'Operator',
|
||||||
role: 'ADMIN',
|
role: (session?.isAdmin ?? false) ? 'ADMIN' : 'CASHIER',
|
||||||
terminalId: terminal.code,
|
terminalId: terminal.code,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
41
lib/core/config/api_config.dart
Normal file
41
lib/core/config/api_config.dart
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -35,7 +35,6 @@ class SyncConfig {
|
|||||||
this.password,
|
this.password,
|
||||||
this.httpBaseUrl = '',
|
this.httpBaseUrl = '',
|
||||||
this.apiKey,
|
this.apiKey,
|
||||||
this.sessionToken,
|
|
||||||
this.ackTimeout = const Duration(seconds: 20),
|
this.ackTimeout = const Duration(seconds: 20),
|
||||||
this.batchSize = 50,
|
this.batchSize = 50,
|
||||||
});
|
});
|
||||||
@@ -53,38 +52,8 @@ class SyncConfig {
|
|||||||
final String? password;
|
final String? password;
|
||||||
|
|
||||||
final String httpBaseUrl;
|
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;
|
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
|
/// How long to wait for the back office to confirm a batch before treating
|
||||||
/// the outcome as unknown and leaving every row pending.
|
/// the outcome as unknown and leaving every row pending.
|
||||||
///
|
///
|
||||||
@@ -179,7 +148,6 @@ class SyncConfig {
|
|||||||
String? password,
|
String? password,
|
||||||
String? httpBaseUrl,
|
String? httpBaseUrl,
|
||||||
String? apiKey,
|
String? apiKey,
|
||||||
String? sessionToken,
|
|
||||||
Duration? ackTimeout,
|
Duration? ackTimeout,
|
||||||
int? batchSize,
|
int? batchSize,
|
||||||
}) =>
|
}) =>
|
||||||
@@ -194,7 +162,6 @@ class SyncConfig {
|
|||||||
password: password ?? this.password,
|
password: password ?? this.password,
|
||||||
httpBaseUrl: httpBaseUrl ?? this.httpBaseUrl,
|
httpBaseUrl: httpBaseUrl ?? this.httpBaseUrl,
|
||||||
apiKey: apiKey ?? this.apiKey,
|
apiKey: apiKey ?? this.apiKey,
|
||||||
sessionToken: sessionToken ?? this.sessionToken,
|
|
||||||
ackTimeout: ackTimeout ?? this.ackTimeout,
|
ackTimeout: ackTimeout ?? this.ackTimeout,
|
||||||
batchSize: batchSize ?? this.batchSize,
|
batchSize: batchSize ?? this.batchSize,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -76,6 +76,17 @@ class LocalStore {
|
|||||||
_ready = true;
|
_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<TerminalIdentity> reloadTerminal() async {
|
||||||
|
terminal = await identityStore.load();
|
||||||
|
return terminal;
|
||||||
|
}
|
||||||
|
|
||||||
/// Re-reads cached state from disk. Called on start and after an import.
|
/// Re-reads cached state from disk. Called on start and after an import.
|
||||||
Future<void> hydrate() async {
|
Future<void> hydrate() async {
|
||||||
_products
|
_products
|
||||||
|
|||||||
@@ -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<PosSession?> 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<String, Object?>);
|
|
||||||
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<void> 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<void> clear() async {
|
|
||||||
try {
|
|
||||||
await _secure.delete(key: _key);
|
|
||||||
} on Object catch (e) {
|
|
||||||
debugPrint('Could not clear the session: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,10 +25,6 @@ class StaffDao {
|
|||||||
|
|
||||||
final Database _db;
|
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();
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
/// The accounts a shop starts with.
|
/// 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<StaffUser> 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<void> updateDetails({
|
Future<void> updateDetails({
|
||||||
required String id,
|
required String id,
|
||||||
String? name,
|
String? name,
|
||||||
@@ -281,93 +358,3 @@ class StaffDao {
|
|||||||
isActive: (row['is_active'] as int? ?? 1) == 1,
|
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<int> replaceFromBackOffice(List<StaffImportRecord> members) async {
|
|
||||||
if (members.isEmpty) return 0;
|
|
||||||
|
|
||||||
final now = DateTime.now().millisecondsSinceEpoch;
|
|
||||||
final imported = <String>{};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
378
lib/data/remote/auth_api.dart
Normal file
378
lib/data/remote/auth_api.dart
Normal file
@@ -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<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();
|
||||||
|
}
|
||||||
@@ -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<PosSession> 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 = <String, Object?>{
|
|
||||||
'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<String, Object?> decoded;
|
|
||||||
try {
|
|
||||||
decoded = jsonDecode(response.body) as Map<String, Object?>;
|
|
||||||
} 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<String, Object?>) {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
@@ -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<PosOutlet> 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<PosStaffMember> 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<String, Object?> json) {
|
|
||||||
final outlets = (json['locations'] as List<Object?>? ?? const [])
|
|
||||||
.whereType<Map<String, Object?>>()
|
|
||||||
.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<Object?>? ?? const [])
|
|
||||||
.whereType<Map<String, Object?>>()
|
|
||||||
.map(PosStaffMember.fromJson)
|
|
||||||
.where((m) => m.pin.isNotEmpty)
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object?> 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<String, Object?> json) => PosOutlet(
|
|
||||||
locationId: _int(json['location_id']),
|
|
||||||
locationName: (json['location_name'] as String?) ?? '',
|
|
||||||
address: (json['address'] as String?) ?? '',
|
|
||||||
city: (json['city'] as String?) ?? '',
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, Object?> 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<String, Object?> 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<String, Object?> toJson() => {
|
|
||||||
'user_id': userId,
|
|
||||||
'full_name': fullName,
|
|
||||||
'pin': pin,
|
|
||||||
'role': role,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../app/providers.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/local/staff_dao.dart';
|
||||||
import '../../../data/remote/pos_auth_api.dart';
|
import '../../../data/remote/auth_api.dart';
|
||||||
import '../../../domain/entities/pos_session.dart';
|
|
||||||
import '../../../domain/entities/store_account.dart';
|
import '../../../domain/entities/store_account.dart';
|
||||||
|
|
||||||
/// Sign-in state for the terminal.
|
/// Sign-in state for the terminal.
|
||||||
@@ -26,19 +27,35 @@ class Authenticated extends AuthState {
|
|||||||
required this.store,
|
required this.store,
|
||||||
required this.user,
|
required this.user,
|
||||||
required this.login,
|
required this.login,
|
||||||
|
this.session,
|
||||||
});
|
});
|
||||||
|
|
||||||
final StoreAccount store;
|
final StoreAccount store;
|
||||||
final StaffUser user;
|
final StaffUser user;
|
||||||
|
|
||||||
/// What this session may open. The authority on what the terminal shows —
|
/// Which credential opened this session. The authority on what the terminal
|
||||||
/// not [user], which can be swapped at the till without re-authenticating.
|
/// is allowed to show — not [user], which can be swapped at the till.
|
||||||
final TerminalLogin login;
|
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;
|
StaffRole get role => login.role;
|
||||||
|
|
||||||
bool get isAdmin => login == TerminalLogin.admin;
|
bool get isAdmin => login == TerminalLogin.admin;
|
||||||
bool get isCashier => login == TerminalLogin.cashier;
|
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 {
|
class AuthFailure extends AuthState {
|
||||||
@@ -47,92 +64,80 @@ class AuthFailure extends AuthState {
|
|||||||
final String message;
|
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
|
/// No longer a credential — the back office decides the role now, and
|
||||||
/// split is what the roles are *for* rather than decoration:
|
/// [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.
|
/// catalogue. Signing out leaves the products on the terminal.
|
||||||
/// * [cashier] gets the billing screen and nothing else, and signing out
|
/// * [cashier] gets the billing screen and nothing else, and every way out of
|
||||||
/// takes the catalogue with it.
|
/// the session takes the catalogue with it.
|
||||||
///
|
///
|
||||||
/// This used to carry an email and a password per entry, and the terminal
|
/// The email and password fields are the built-in demo accounts, used only by
|
||||||
/// decided which role you were by comparing what you typed against those
|
/// the offline path — see [ApiConfig.allowOfflineDemoLogin].
|
||||||
/// 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.
|
|
||||||
enum TerminalLogin {
|
enum TerminalLogin {
|
||||||
admin(
|
admin(
|
||||||
label: 'Supervisor',
|
label: 'Admin',
|
||||||
|
email: 'admin@nearle.in',
|
||||||
|
password: 'nearle123',
|
||||||
role: StaffRole.admin,
|
role: StaffRole.admin,
|
||||||
blurb: 'Full shell — import products, promos, staff, settings.',
|
blurb: 'Full shell — import products, promos, settings.',
|
||||||
),
|
),
|
||||||
cashier(
|
cashier(
|
||||||
label: 'Cashier',
|
label: 'Cashier',
|
||||||
|
email: 'cashier@nearle.in',
|
||||||
|
password: 'cashier123',
|
||||||
role: StaffRole.cashier,
|
role: StaffRole.cashier,
|
||||||
blurb: 'Billing only, on the products the supervisor imported.',
|
blurb: 'Billing only, on the products the admin imported.',
|
||||||
);
|
);
|
||||||
|
|
||||||
const TerminalLogin({
|
const TerminalLogin({
|
||||||
required this.label,
|
required this.label,
|
||||||
|
required this.email,
|
||||||
|
required this.password,
|
||||||
required this.role,
|
required this.role,
|
||||||
required this.blurb,
|
required this.blurb,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String label;
|
final String label;
|
||||||
|
final String email;
|
||||||
|
final String password;
|
||||||
final StaffRole role;
|
final StaffRole role;
|
||||||
final String blurb;
|
final String blurb;
|
||||||
|
|
||||||
/// The catalogue is pulled once by a supervisor and billed against by
|
/// The catalogue is pulled once by an admin and billed against by whoever is
|
||||||
/// whoever is on the counter, so only the cashier's sign-out drops it. A
|
/// on the counter, so only the cashier's sign-out drops it. An admin closing
|
||||||
/// supervisor closing the shell is a handover, not the end of the day.
|
/// the shell is a handover, not the end of the day.
|
||||||
bool get clearsCatalogueOnSignOut => this == TerminalLogin.cashier;
|
bool get clearsCatalogueOnSignOut => this == TerminalLogin.cashier;
|
||||||
|
|
||||||
/// Which shell the back office says this account gets.
|
static TerminalLogin? byEmail(String email) {
|
||||||
///
|
final normalised = email.trim().toLowerCase();
|
||||||
/// Reads `can_manage_staff` rather than the role name or id. The name is
|
for (final login in TerminalLogin.values) {
|
||||||
/// free text and often blank, and `app_roles` holds six rows for four
|
if (login.email == normalised) return login;
|
||||||
/// 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
|
return null;
|
||||||
/// 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;
|
/// 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.
|
/// 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<AuthState> {
|
class AuthController extends StateNotifier<AuthState> {
|
||||||
AuthController(this._ref) : super(const Unauthenticated());
|
AuthController(this._ref) : super(const Unauthenticated());
|
||||||
|
|
||||||
final Ref _ref;
|
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.
|
/// Whether signing out right now would wipe the products off this terminal.
|
||||||
///
|
///
|
||||||
/// Read *before* [signOut] by anything that needs to warn the operator, since
|
/// Read *before* [signOut] by anything that needs to warn the operator, since
|
||||||
@@ -142,195 +147,157 @@ class AuthController extends StateNotifier<AuthState> {
|
|||||||
return current is Authenticated && current.login.clearsCatalogueOnSignOut;
|
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
|
/// The back office decides everything the terminal then does: the role that
|
||||||
/// trading rather than showing a login screen to a queue of customers.
|
/// picks admin shell or billing screen, the location the catalogue is pulled
|
||||||
/// Returns false when there is nothing usable, which includes an expired
|
/// for, and the token every later call carries. Nothing here is chosen at
|
||||||
/// session — [SessionStore] treats those as absent.
|
/// the login screen any more.
|
||||||
Future<bool> restore() async {
|
|
||||||
final saved = await _ref.read(sessionStoreProvider).read();
|
|
||||||
if (saved == null) return false;
|
|
||||||
|
|
||||||
await _adopt(saved);
|
|
||||||
return state is Authenticated;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> signIn({
|
Future<bool> signIn({
|
||||||
required String email,
|
required String email,
|
||||||
required String password,
|
required String password,
|
||||||
int? locationId,
|
|
||||||
}) async {
|
}) async {
|
||||||
state = const Authenticating();
|
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 {
|
try {
|
||||||
session = await _ref.read(posAuthApiProvider).login(
|
session = await _ref.read(authApiProvider).login(
|
||||||
authname: email,
|
authname: email,
|
||||||
password: password,
|
password: password,
|
||||||
terminalId: terminal.code,
|
deviceId: deviceId,
|
||||||
deviceId: terminal.deviceId,
|
|
||||||
locationId: locationId,
|
|
||||||
);
|
);
|
||||||
} 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);
|
state = AuthFailure(e.message);
|
||||||
return false;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _ref.read(sessionStoreProvider).write(session);
|
return true;
|
||||||
await _adopt(session);
|
|
||||||
|
|
||||||
return state is Authenticated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
/// Order matters. The store id and terminal identity are written first,
|
||||||
/// the signed token: the back office has to issue a new one, and re-checking
|
/// because `syncConfigProvider` is derived from them and from the session —
|
||||||
/// entitlement at that moment is the point. Requires the password again,
|
/// setting the session last means the catalogue and order transports are
|
||||||
/// which is correct — moving a till between shops changes whose books it
|
/// rebuilt once, already pointing at the right location with the right
|
||||||
/// writes to.
|
/// token.
|
||||||
Future<bool> switchOutlet({
|
Future<void> _applySession(LoginSession session) async {
|
||||||
required String password,
|
final store = _ref.read(localStoreProvider);
|
||||||
required int locationId,
|
final catalogue = store.catalogue;
|
||||||
}) async {
|
|
||||||
final current = _session;
|
|
||||||
if (current == null) return false;
|
|
||||||
|
|
||||||
return signIn(
|
// 1. The outlet, as the back office describes it. These are printed on
|
||||||
email: current.email.isNotEmpty ? current.email : current.fullName,
|
// every GST invoice, so they come from the server rather than from the
|
||||||
password: password,
|
// build's constants.
|
||||||
locationId: locationId,
|
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.
|
// 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.
|
||||||
/// Order matters. The store id and token are written *before* the catalogue
|
await store.identityStore.rename(storeId: session.storeId);
|
||||||
/// or any uplink can run, so a terminal can never spend even one request
|
await store.reloadTerminal();
|
||||||
/// pointed at the outlet it had yesterday while claiming to be signed in as
|
|
||||||
/// today's.
|
|
||||||
Future<void> _adopt(PosSession session) async {
|
|
||||||
_session = session;
|
|
||||||
|
|
||||||
await _ref.read(localStoreProvider).identityStore.rename(
|
|
||||||
storeId: session.storeId,
|
|
||||||
);
|
|
||||||
_ref.invalidate(terminalIdentityProvider);
|
_ref.invalidate(terminalIdentityProvider);
|
||||||
|
|
||||||
_ref.read(syncConfigProvider.notifier).state =
|
// 3. The staff list, so PIN switching at the counter works against the
|
||||||
_ref.read(syncConfigProvider).copyWith(
|
// people head office actually employs.
|
||||||
storeId: session.storeId,
|
final me = await _syncStaff(session);
|
||||||
sessionToken: session.token,
|
|
||||||
|
// 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,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
_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.',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state = Authenticated(
|
state = Authenticated(
|
||||||
store: store,
|
store: account,
|
||||||
user: _opener(staff, session),
|
user: me,
|
||||||
login: TerminalLogin.forSession(session),
|
login: session.isAdmin ? TerminalLogin.admin : TerminalLogin.cashier,
|
||||||
|
session: session,
|
||||||
|
);
|
||||||
|
|
||||||
|
_ref.invalidate(storeAccountProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors the back office's staff list onto this terminal, and returns the
|
||||||
|
/// row for whoever just signed in.
|
||||||
|
///
|
||||||
|
/// Additive on purpose. Deactivating everyone the server did not mention
|
||||||
|
/// would lock a shop out of its own till the first time the endpoint answers
|
||||||
|
/// with an empty array — which is exactly what the sample response does.
|
||||||
|
Future<StaffUser> _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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Who the terminal attributes bills to the moment it opens.
|
// 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
|
||||||
/// The person who just signed in, if the import wrote them — matched on the
|
// every bill is stamped with a staff id.
|
||||||
/// back office id rather than the name, which is neither unique nor stable.
|
return dao.upsertFromServer(
|
||||||
/// Falls back to anyone rather than failing: the session's permissions come
|
id: StaffDao.serverId(session.userId),
|
||||||
/// from the token, so a shop whose staff list is empty or unsynced still gets
|
name: session.displayUserName,
|
||||||
/// a usable till, and the bills are simply stamped with the account that is
|
role: session.staffRole,
|
||||||
/// there until someone switches with their PIN.
|
pin: session.whoAmI?.pin,
|
||||||
StaffUser _opener(List<StaffUser> staff, PosSession session) {
|
);
|
||||||
final mine = 'boffice-${session.userId}';
|
|
||||||
for (final s in staff) {
|
|
||||||
if (s.id == mine) return s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final wanted = TerminalLogin.forSession(session).role;
|
/// The built-in accounts, for a terminal with no line to the back office.
|
||||||
return staff.firstWhere((s) => s.role == wanted, orElse: () => staff.first);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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
|
/// Development only — see [ApiConfig.allowOfflineDemoLogin]. It reaches no
|
||||||
/// staff import fails — the seeded or previously-synced accounts are still
|
/// server, so it sets no token: the catalogue cannot be pulled and bills
|
||||||
/// there, and refusing the sign-in would trade a working counter for a
|
/// cannot be pushed until a real sign-in happens.
|
||||||
/// tidier database.
|
Future<bool> _signInOffline(String email, String password) async {
|
||||||
Future<void> _importStaff(PosSession session) async {
|
final login = TerminalLogin.byEmail(email);
|
||||||
if (session.staff.isEmpty) return;
|
if (login == null || password != login.password) return false;
|
||||||
|
|
||||||
try {
|
final store = await _ref.read(storeRepositoryProvider).load(email: email);
|
||||||
await _ref.read(localStoreProvider).staff.replaceFromBackOffice([
|
if (store.staff.isEmpty) return false;
|
||||||
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.
|
final opener = store.staff.firstWhere(
|
||||||
///
|
(s) => s.role == login.role,
|
||||||
/// `app_roles` holds six rows for four distinct roles — Admin and Manager are
|
orElse: () => store.staff.first,
|
||||||
/// 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.
|
state = Authenticated(store: store, user: opener, login: login);
|
||||||
///
|
return true;
|
||||||
/// 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.
|
/// Switches the active operator, checking their PIN.
|
||||||
///
|
///
|
||||||
@@ -339,11 +306,6 @@ class AuthController extends StateNotifier<AuthState> {
|
|||||||
/// list. It changes who the bill names, never what the session may open:
|
/// list. It changes who the bill names, never what the session may open:
|
||||||
/// [Authenticated.login] is untouched, so a cashier terminal stays a cashier
|
/// [Authenticated.login] is untouched, so a cashier terminal stays a cashier
|
||||||
/// terminal.
|
/// 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<bool> switchUser(String pin) async {
|
Future<bool> switchUser(String pin) async {
|
||||||
final current = state;
|
final current = state;
|
||||||
if (current is! Authenticated) return false;
|
if (current is! Authenticated) return false;
|
||||||
@@ -356,6 +318,7 @@ class AuthController extends StateNotifier<AuthState> {
|
|||||||
store: current.store,
|
store: current.store,
|
||||||
user: user,
|
user: user,
|
||||||
login: current.login,
|
login: current.login,
|
||||||
|
session: current.session,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -375,34 +338,28 @@ class AuthController extends StateNotifier<AuthState> {
|
|||||||
// would keep stamping bills with an account the shop has revoked.
|
// would keep stamping bills with an account the shop has revoked.
|
||||||
user: me.isEmpty ? store.staff.first : me.first,
|
user: me.isEmpty ? store.staff.first : me.first,
|
||||||
login: current.login,
|
login: current.login,
|
||||||
|
session: current.session,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ends the session, and — for a cashier only — the catalogue with it.
|
/// Ends the session, and — for a cashier only — the catalogue with it.
|
||||||
///
|
///
|
||||||
/// The token always goes, from the keystore and from the live configuration
|
/// Every way out of a cashier session clears the products: ending a shift,
|
||||||
/// both. Leaving it in place would let a signed-out terminal keep uploading
|
/// and a temporary logout alike. There is no exception for stepping away for
|
||||||
/// as the shop that signed in this morning, so that half is a security
|
/// ten minutes, because the terminal is left unattended either way and the
|
||||||
/// 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
|
|
||||||
/// next session should bill against what the back office answers with rather
|
/// next session should bill against what the back office answers with rather
|
||||||
/// than a catalogue carried over. A supervisor signing out is the opposite
|
/// than a catalogue carried over.
|
||||||
/// 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.
|
/// 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<void> signOut() async {
|
Future<void> signOut() async {
|
||||||
if (clearsCatalogueOnSignOut) {
|
if (clearsCatalogueOnSignOut) {
|
||||||
await _ref.read(localStoreProvider).clearCatalogue();
|
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();
|
state = const Unauthenticated();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,6 +384,31 @@ final currentUserProvider = Provider<StaffUser?>((ref) {
|
|||||||
return s is Authenticated ? s.user : null;
|
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<LoginSession?>((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<String>((ref) {
|
||||||
|
final s = ref.watch(authControllerProvider);
|
||||||
|
return s is Authenticated ? s.storeName : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The outlet, for the line under the store name.
|
||||||
|
final locationDisplayNameProvider = Provider<String>((ref) {
|
||||||
|
return ref.watch(apiSessionProvider)?.locationName.trim() ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
/// Which credential is holding this session open, or null before sign-in.
|
/// Which credential is holding this session open, or null before sign-in.
|
||||||
final terminalLoginProvider = Provider<TerminalLogin?>((ref) {
|
final terminalLoginProvider = Provider<TerminalLogin?>((ref) {
|
||||||
final s = ref.watch(authControllerProvider);
|
final s = ref.watch(authControllerProvider);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -7,7 +9,7 @@ import '../../../app/providers.dart';
|
|||||||
import '../../../core/router/app_router.dart';
|
import '../../../core/router/app_router.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.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 '../../../core/widgets/primary_button.dart';
|
||||||
import '../providers/auth_controller.dart';
|
import '../providers/auth_controller.dart';
|
||||||
|
|
||||||
@@ -29,9 +31,11 @@ class LoginScreen extends ConsumerStatefulWidget {
|
|||||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
// Both fields start empty. There is nothing to prefill: a person signs in
|
// Blank on purpose. The role tabs that used to sit above these fields chose
|
||||||
// with their own back-office account, and which shell they get is a property
|
// between two built-in accounts and decided, locally, whether the terminal
|
||||||
// of that account rather than a tab they picked before typing.
|
// 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 _email = TextEditingController();
|
||||||
final _password = TextEditingController();
|
final _password = TextEditingController();
|
||||||
|
|
||||||
@@ -247,15 +251,17 @@ class _Card extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.xl),
|
const SizedBox(height: AppSpacing.xl),
|
||||||
|
|
||||||
const _Label('Store email'),
|
const _Label('Email or username'),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: email,
|
controller: email,
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
enabled: !busy,
|
enabled: !busy,
|
||||||
validator: (v) => (v ?? '').trim().isEmpty
|
// Not validated as an email. The back office takes this as
|
||||||
? 'Store email is required'
|
// `authname` and accepts either form; rejecting a username here
|
||||||
: Validators.emailOptional(v),
|
// would block an account the server would have let in.
|
||||||
|
validator: (v) =>
|
||||||
|
(v ?? '').trim().isEmpty ? 'This is required' : null,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: 'store@example.in',
|
hintText: 'store@example.in',
|
||||||
prefixIcon: Icon(Icons.storefront_outlined),
|
prefixIcon: Icon(Icons.storefront_outlined),
|
||||||
@@ -270,9 +276,8 @@ class _Card extends StatelessWidget {
|
|||||||
enabled: !busy,
|
enabled: !busy,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
onFieldSubmitted: (_) => onSubmit(),
|
onFieldSubmitted: (_) => onSubmit(),
|
||||||
validator: (v) => (v ?? '').isEmpty
|
validator: (v) =>
|
||||||
? 'Password is required'
|
(v ?? '').isEmpty ? 'Password is required' : null,
|
||||||
: ((v ?? '').length < 6 ? 'Password looks too short' : null),
|
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Enter your password',
|
hintText: 'Enter your password',
|
||||||
prefixIcon: const Icon(Icons.lock_outline_rounded),
|
prefixIcon: const Icon(Icons.lock_outline_rounded),
|
||||||
@@ -393,3 +398,4 @@ class _Label extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,10 +93,8 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
|
|||||||
/// step.
|
/// step.
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
if (!_complete) {
|
if (!_complete) {
|
||||||
setState(
|
setState(() => _error = 'Enter all '
|
||||||
() => _error = 'Enter all '
|
'${AppConstants.mobileNumberLength} digits of the mobile number.');
|
||||||
'${AppConstants.mobileNumberLength} digits of the mobile number.',
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../../auth/providers/auth_controller.dart';
|
|||||||
import '../../sync/providers/sync_controller.dart';
|
import '../../sync/providers/sync_controller.dart';
|
||||||
import '../providers/navigation_provider.dart';
|
import '../providers/navigation_provider.dart';
|
||||||
import '../widgets/category_chips.dart';
|
import '../widgets/category_chips.dart';
|
||||||
|
import '../widgets/customer_bar.dart';
|
||||||
import '../widgets/product_grid.dart';
|
import '../widgets/product_grid.dart';
|
||||||
import '../widgets/scan_toast.dart';
|
import '../widgets/scan_toast.dart';
|
||||||
import '../widgets/search_field.dart';
|
import '../widgets/search_field.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});
|
const _Brand({required this.expanded});
|
||||||
|
|
||||||
final bool expanded;
|
final bool expanded;
|
||||||
|
|
||||||
@override
|
@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(
|
return Container(
|
||||||
height: AppSizes.headerHeight,
|
height: AppSizes.headerHeight,
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
@@ -102,9 +125,11 @@ class _Brand extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'Nearle',
|
title,
|
||||||
style: TextStyle(
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: -0.3,
|
letterSpacing: -0.3,
|
||||||
@@ -113,7 +138,9 @@ class _Brand extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'POS',
|
subtitle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: AppTypography.sectionLabel()
|
style: AppTypography.sectionLabel()
|
||||||
.copyWith(color: AppColors.primary),
|
.copyWith(color: AppColors.primary),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -404,8 +404,15 @@ class _CashierBrand extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final store = ref.watch(currentStoreProvider);
|
|
||||||
final user = ref.watch(currentUserProvider);
|
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(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -419,7 +426,8 @@ class _CashierBrand extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
store?.name ?? 'Nearle POS',
|
title,
|
||||||
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../../../core/utils/formatters.dart';
|
|||||||
import '../../../core/widgets/primary_button.dart';
|
import '../../../core/widgets/primary_button.dart';
|
||||||
import '../../../domain/entities/shift_report.dart';
|
import '../../../domain/entities/shift_report.dart';
|
||||||
import '../../../domain/entities/transaction.dart';
|
import '../../../domain/entities/transaction.dart';
|
||||||
|
import '../../../domain/repositories/sync_repository.dart';
|
||||||
import '../../auth/providers/auth_controller.dart';
|
import '../../auth/providers/auth_controller.dart';
|
||||||
import '../../payment/screens/payment_screen.dart' show methodIcon;
|
import '../../payment/screens/payment_screen.dart' show methodIcon;
|
||||||
import '../../pos/providers/cart_controller.dart';
|
import '../../pos/providers/cart_controller.dart';
|
||||||
|
|||||||
@@ -222,18 +222,6 @@ final syncBootstrapProvider = FutureProvider<void>((ref) async {
|
|||||||
await store.syncConfig.load(ref.read(syncConfigProvider));
|
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();
|
await ref.read(connectivityServiceProvider).start();
|
||||||
|
|
||||||
final engine = ref.read(syncEngineProvider);
|
final engine = ref.read(syncEngineProvider);
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
|
|||||||
|
|
||||||
if (outcome.isSuccess) {
|
if (outcome.isSuccess) {
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 700));
|
await Future<void>.delayed(const Duration(milliseconds: 700));
|
||||||
if (mounted) await _finish();
|
if (mounted) _finish();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
30
test/widget_test.dart
Normal file
30
test/widget_test.dart
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user