added login

This commit is contained in:
2026-08-07 17:07:49 +05:30
parent ad44402232
commit 4a2474ee6c
8 changed files with 908 additions and 383 deletions

View File

@@ -3,7 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/constants/app_constants.dart'; import '../core/constants/app_constants.dart';
import '../core/router/app_router.dart'; import '../core/router/app_router.dart';
import '../core/theme/app_colors.dart';
import '../core/theme/app_theme.dart'; import '../core/theme/app_theme.dart';
import '../presentation/auth/providers/auth_controller.dart';
import '../presentation/sync/providers/sync_controller.dart'; import '../presentation/sync/providers/sync_controller.dart';
class NearlePosApp extends ConsumerWidget { class NearlePosApp extends ConsumerWidget {
@@ -15,6 +17,18 @@ class NearlePosApp extends ConsumerWidget {
// behind it. Nothing on screen depends on this having finished. // behind it. Nothing on screen depends on this having finished.
ref.watch(syncBootstrapProvider); ref.watch(syncBootstrapProvider);
// This one *is* awaited. Re-opening a stored session is a keystore read —
// a few milliseconds — and building the router before it lands would show
// an already-signed-in terminal the login screen and then snatch it away.
final restored = ref.watch(sessionBootstrapProvider);
if (restored.isLoading) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: ColoredBox(color: AppColors.background),
);
}
return MaterialApp.router( return MaterialApp.router(
title: AppConstants.appName, title: AppConstants.appName,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,

View File

@@ -13,10 +13,12 @@ import '../data/remote/simulated_catalogue_source.dart';
import '../data/remote/http_order_transport.dart'; import '../data/remote/http_order_transport.dart';
import '../data/remote/mqtt_order_transport.dart'; import '../data/remote/mqtt_order_transport.dart';
import '../data/remote/order_transport.dart'; import '../data/remote/order_transport.dart';
import '../data/remote/pos_auth_api.dart';
import '../data/remote/simulated_order_transport.dart'; 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/sync/sync_engine.dart'; import '../data/sync/sync_engine.dart';
import '../domain/repositories/customer_repository.dart'; import '../domain/repositories/customer_repository.dart';
@@ -31,6 +33,34 @@ import '../presentation/auth/providers/auth_controller.dart';
/// Root data source. Overridden in tests with an in-memory double. /// Root data source. Overridden in tests with an in-memory double.
final localStoreProvider = Provider<LocalStore>((ref) => LocalStore.instance); final localStoreProvider = Provider<LocalStore>((ref) => LocalStore.instance);
// ------------------------------------------------------------------- Auth
/// Where the back office lives.
///
/// Its own provider, and deliberately free of any dependency on the session:
/// [posAuthApiProvider] needs it *before* anyone is signed in, so a base URL
/// derived from the session would be a cycle — sign-in needing the thing that
/// sign-in produces.
final backOfficeBaseUrlProvider = Provider<String>(
(ref) => 'https://fiesta.nearle.app/live/api/v1/pos',
);
/// Which back-office configuration this build's terminals belong to. Sent as
/// `configid` on every sign-in.
final posConfigIdProvider = Provider<int>((ref) => 1);
/// `POST /login`. One client, closed when the endpoint is re-pointed.
final posAuthApiProvider = Provider<PosAuthApi>((ref) {
final api = PosAuthApi(
baseUrl: ref.watch(backOfficeBaseUrlProvider),
configId: ref.watch(posConfigIdProvider),
);
ref.onDispose(api.dispose);
return api;
});
/// The signed-in session on disk, in the platform keystore.
final sessionStoreProvider = Provider<SessionStore>((ref) => SessionStore());
// ---------------------------------------------------------- Repositories // ---------------------------------------------------------- Repositories
final productRepositoryProvider = Provider<ProductRepository>( final productRepositoryProvider = Provider<ProductRepository>(
(ref) => ProductRepositoryImpl(ref.watch(localStoreProvider)), (ref) => ProductRepositoryImpl(ref.watch(localStoreProvider)),
@@ -91,7 +121,7 @@ final syncConfigProvider = StateProvider<SyncConfig>((ref) {
final terminal = ref.watch(terminalIdentityProvider); final terminal = ref.watch(terminalIdentityProvider);
return SyncConfig( return SyncConfig(
transport: TransportKind.http, transport: TransportKind.http,
httpBaseUrl: 'https://fiesta.nearle.app/live/api/v1/pos', httpBaseUrl: ref.watch(backOfficeBaseUrlProvider),
storeId: terminal.storeId, storeId: terminal.storeId,
terminalId: terminal.code, terminalId: terminal.code,
); );
@@ -164,7 +194,8 @@ final storeRepositoryProvider = Provider<StoreRepositoryImpl>(
/// The outlet, refreshed whenever staff or details change. /// The outlet, refreshed whenever staff or details change.
final storeAccountProvider = FutureProvider<StoreAccount>( final storeAccountProvider = FutureProvider<StoreAccount>(
(ref) => ref.watch(storeRepositoryProvider).load( (ref) => ref.watch(storeRepositoryProvider).load(
email: DemoCredentials.email, // The account the terminal is signed in as, or blank before sign-in.
email: ref.watch(sessionAuthnameProvider),
), ),
); );

View File

@@ -15,42 +15,91 @@ class AppRoutes {
static const String login = '/login'; static const String login = '/login';
/// The terminal itself. Signing in lands here directly — customer capture /// The admin shell: billing plus catalogue import, promos, staff, settings.
/// happens at checkout, not before the sale. static const String adminDashboard = '/admin';
/// The cashier shell: billing, and nothing else.
static const String cashierDashboard = '/cashier';
/// Alias for "wherever this session lives".
///
/// Kept because everything that returns to billing — finishing a receipt,
/// abandoning a payment — should land on the caller's own dashboard without
/// having to know which one that is. It never renders; [routerProvider]
/// resolves it to one of the two above.
static const String pos = '/'; static const String pos = '/';
static const String payment = '/payment'; static const String payment = '/payment';
static const String receipt = '/receipt';
/// Drawer count and hand-over. Reached from the session-end chooser, never /// Drawer count and hand-over. Reached from the session-end chooser, never
/// linked to directly, and guarded like every other signed-in route. /// linked to directly, and guarded like every other signed-in route.
static const String endShift = '/end-shift'; static const String endShift = '/end-shift';
static const String receipt = '/receipt';
/// Which dashboard a session owns.
///
/// The single place the role-to-screen decision is written down. `null` —
/// nobody signed in — resolves to the cashier till, which is the smaller of
/// the two; the guard sends an unauthenticated terminal to [login] before
/// this is ever reached.
static String homeFor(TerminalLogin? login) =>
login == TerminalLogin.admin ? adminDashboard : cashierDashboard;
/// True for the two dashboards, so the guard can spot a session sitting on
/// the wrong one.
static bool isDashboard(String location) =>
location == adminDashboard || location == cashierDashboard;
} }
/// Router with an authentication guard. /// Router with an authentication and role guard.
/// ///
/// Every route except [AppRoutes.login] requires a signed-in store, and an /// Three rules, in order:
/// already-signed-in terminal is bounced away from the login screen. ///
/// 1. Every route except [AppRoutes.login] requires a signed-in session.
/// 2. A signed-in terminal is bounced off the login screen onto its own
/// dashboard — admin for Admin/Supervisor/Manager/Owner accounts, cashier
/// for everything else. See [PosSession.isCashier].
/// 3. A session on the *other* role's dashboard is moved to its own. Typing
/// `/admin` on a cashier till must not open the back office, and the guard
/// is what makes that true regardless of how the route was reached.
final routerProvider = Provider<GoRouter>((ref) { final routerProvider = Provider<GoRouter>((ref) {
// GoRouter re-evaluates `redirect` whenever this notifier fires. // GoRouter re-evaluates `redirect` whenever this notifier fires. It carries
final authChanged = ValueNotifier<bool>( // the destination rather than a bare bool, so a role change — a cashier
ref.read(authControllerProvider).isAuthenticated, // signing out and a supervisor signing in — also moves the terminal, which
// watching `isAuthenticated` alone would miss.
String? home(AuthState state) => state is Authenticated
? AppRoutes.homeFor(state.login)
: null;
final destination = ValueNotifier<String?>(
home(ref.read(authControllerProvider)),
); );
ref.listen<AuthState>( ref.listen<AuthState>(
authControllerProvider, authControllerProvider,
(_, next) => authChanged.value = next.isAuthenticated, (_, next) => destination.value = home(next),
); );
ref.onDispose(authChanged.dispose); ref.onDispose(destination.dispose);
return GoRouter( return GoRouter(
initialLocation: AppRoutes.login, initialLocation: AppRoutes.login,
refreshListenable: authChanged, refreshListenable: destination,
debugLogDiagnostics: false, debugLogDiagnostics: false,
redirect: (context, state) { redirect: (context, state) {
final signedIn = ref.read(authControllerProvider).isAuthenticated; final auth = ref.read(authControllerProvider);
final atLogin = state.matchedLocation == AppRoutes.login; final location = state.matchedLocation;
final atLogin = location == AppRoutes.login;
if (auth is! Authenticated) return atLogin ? null : AppRoutes.login;
final myHome = AppRoutes.homeFor(auth.login);
// Signed in and still on the login screen, or on the `/` alias.
if (atLogin || location == AppRoutes.pos) return myHome;
// On the other role's dashboard.
if (AppRoutes.isDashboard(location) && location != myHome) return myHome;
if (!signedIn) return atLogin ? null : AppRoutes.login;
if (atLogin) return AppRoutes.pos;
return null; return null;
}, },
routes: [ routes: [
@@ -59,12 +108,35 @@ final routerProvider = Provider<GoRouter>((ref) {
name: 'login', name: 'login',
pageBuilder: (context, state) => _fade(state, const LoginScreen()), pageBuilder: (context, state) => _fade(state, const LoginScreen()),
), ),
// Redirect-only. `/` is an alias, never a screen — the guard above has
// already resolved it, and this exists so the path matches a route at
// all rather than falling through to [errorBuilder].
GoRoute( GoRoute(
path: AppRoutes.pos, path: AppRoutes.pos,
name: 'pos', name: 'home',
redirect: (context, state) => AppRoutes.homeFor(
ref.read(terminalLoginProvider) ?? TerminalLogin.cashier,
),
),
// Both dashboards are the same shell. It reads `isCashierModeProvider`
// and hides the sidebar, the catalogue and the back-office modules in
// cashier mode — so the two routes are the *addresses* of two shapes of
// one screen, not two screens to keep in step with each other.
GoRoute(
path: AppRoutes.adminDashboard,
name: 'adminDashboard',
pageBuilder: (context, state) => pageBuilder: (context, state) =>
_fade(state, const PosDashboardScreen()), _fade(state, const PosDashboardScreen()),
), ),
GoRoute(
path: AppRoutes.cashierDashboard,
name: 'cashierDashboard',
pageBuilder: (context, state) =>
_fade(state, const PosDashboardScreen()),
),
GoRoute( GoRoute(
path: AppRoutes.endShift, path: AppRoutes.endShift,
name: 'endShift', name: 'endShift',

View File

@@ -0,0 +1,83 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../domain/entities/pos_session.dart';
/// Where the signed-in session lives between launches.
///
/// The platform keystore, not SQLite — Keychain on macOS, Credential Manager
/// on Windows, the Android Keystore on a tablet. The response carries a bearer
/// token and every staff PIN in the clear, and the SQLite file sits on a
/// machine behind a shop counter readable by anything that can open it.
///
/// Stored as one blob rather than field by field so [clear] is a single
/// delete. A sign-out that leaves half a session behind is worse than one that
/// leaves none.
class SessionStore {
SessionStore({FlutterSecureStorage? secureStorage})
: _secure = secureStorage ?? const FlutterSecureStorage();
final FlutterSecureStorage _secure;
static const String _key = 'pos.session';
/// The stored session, or null if there is none, it cannot be read, or it
/// has expired.
///
/// An expired token is treated as absent and swept: carrying it forward only
/// moves the failure to the first call that uses it, which is a cashier
/// discovering it mid-sale rather than at the login screen.
Future<PosSession?> read() async {
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. The terminal still runs, it just asks for credentials.
debugPrint('SessionStore: keystore unavailable ($e)');
return null;
}
if (raw == null || raw.isEmpty) return null;
PosSession session;
try {
session = PosSession.fromJson(jsonDecode(raw) as Map<String, Object?>);
} on Object catch (e) {
// A blob this build cannot parse — an upgrade that changed the shape.
// Drop it rather than failing every launch from here on.
debugPrint('SessionStore: unreadable session dropped ($e)');
await clear();
return null;
}
if (session.token.isEmpty || session.isExpired) {
await clear();
return null;
}
return session;
}
Future<void> save(PosSession session) async {
try {
await _secure.write(key: _key, value: jsonEncode(session.toJson()));
} on Object catch (e) {
// Not fatal: the session is live in memory and this shift carries on.
// The next launch just asks for credentials again.
debugPrint('SessionStore: could not persist session ($e)');
}
}
/// Removes the session. Called on every sign-out, and on an expired or
/// rejected token.
Future<void> clear() async {
try {
await _secure.delete(key: _key);
} on Object catch (e) {
debugPrint('SessionStore: could not clear session ($e)');
}
}
}

View File

@@ -0,0 +1,163 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../domain/entities/pos_session.dart';
/// A sign-in that did not produce a session.
///
/// Carries a message written for the person at the counter, not a status code.
/// [isCredentialFailure] separates "you typed the wrong password" from "the
/// shop's internet is down", because the first is the operator's problem to
/// fix and the second is not.
class AuthApiException implements Exception {
const AuthApiException(
this.message, {
this.isCredentialFailure = false,
this.statusCode,
});
final String message;
final bool isCredentialFailure;
final int? statusCode;
@override
String toString() => message;
}
/// Signs the terminal in against the back office.
///
/// ```
/// POST {base}/login
/// Content-Type: application/json
///
/// { "authname": …, "password": …, "device_id": …, "configid": 1 }
/// ```
///
/// answering
///
/// ```json
/// { "code": 200, "status": true, "message": "Login successful",
/// "details": { "token": …, "role": "Supervisor", … } }
/// ```
///
/// The envelope is checked rather than the HTTP status alone: this API answers
/// `200` with `status: false` for a rejected credential, so trusting the
/// status code would sign a terminal in on a failed login.
class PosAuthApi {
PosAuthApi({
required this.baseUrl,
this.configId = 1,
http.Client? client,
}) : _client = client ?? http.Client();
/// Same base as the catalogue and order endpoints, e.g.
/// `https://fiesta.nearle.app/live/api/v1/pos`.
final String baseUrl;
/// Which back-office configuration this terminal belongs to.
final int configId;
final http.Client _client;
static const Duration _timeout = Duration(seconds: 20);
Future<PosSession> login({
required String authname,
required String password,
required String deviceId,
}) async {
if (baseUrl.isEmpty) {
throw const AuthApiException(
'No back-office URL is configured for this terminal. Set one in '
'Settings → Connectivity & sync → Configure.',
);
}
final uri = Uri.parse('${baseUrl.replaceAll(RegExp(r'/+$'), '')}/login');
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,
// This device's own identity, minted on first run. Two terminals
// must never sign in as the same device — the back office keys
// sessions on it.
'device_id': deviceId,
'configid': configId,
}),
)
.timeout(_timeout);
} on TimeoutException {
throw const AuthApiException(
'The back office did not answer in time. Check the connection and '
'try again.',
);
} on http.ClientException {
// DNS failure, refused connection, dropped socket — the shop's line
// rather than the operator's credentials.
throw const AuthApiException(
'Could not reach the back office. Check this terminal\'s internet '
'connection.',
);
} on Exception catch (e) {
throw AuthApiException('Could not reach the back office: $e');
}
Map<String, Object?> body;
try {
body = jsonDecode(response.body) as Map<String, Object?>;
} on Object {
throw AuthApiException(
'The back office answered with something this terminal could not '
'read (${response.statusCode}).',
statusCode: response.statusCode,
);
}
final ok = body['status'] == true && response.statusCode < 300;
if (!ok) {
final raw = body['message'];
final message = raw is String ? raw.trim() : '';
const rejectedCodes = {400, 401, 403, 422};
throw AuthApiException(
// The server's own wording, when it gave one. It knows whether the
// account is disabled, the device is unregistered or the password is
// simply wrong, and a generic message would throw that away.
message.isEmpty ? 'Sign-in failed (${response.statusCode}).' : message,
isCredentialFailure: rejectedCodes.contains(response.statusCode),
statusCode: response.statusCode,
);
}
final details = body['details'];
if (details is! Map<String, Object?>) {
throw const AuthApiException(
'The back office accepted the sign-in but sent no session back.',
);
}
final session = PosSession.fromDetails(details, authname: authname.trim());
if (session.token.isEmpty) {
throw const AuthApiException(
'The back office accepted the sign-in but issued no token.',
);
}
return session;
}
void dispose() => _client.close();
}

View File

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

View File

@@ -1,6 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart'; import '../../../app/providers.dart';
import '../../../data/local/app_database.dart';
import '../../../data/remote/pos_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.
@@ -23,6 +27,7 @@ class Authenticated extends AuthState {
required this.store, required this.store,
required this.user, required this.user,
required this.login, required this.login,
required this.session,
}); });
final StoreAccount store; final StoreAccount store;
@@ -32,6 +37,10 @@ class Authenticated extends AuthState {
/// is allowed to show — not [user], which can be swapped at the till. /// 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. Holds the bearer token every later
/// call needs, and the outlet this terminal is trading as.
final PosSession session;
StaffRole get role => login.role; StaffRole get role => login.role;
bool get isAdmin => login == TerminalLogin.admin; bool get isAdmin => login == TerminalLogin.admin;
@@ -44,46 +53,37 @@ class AuthFailure extends AuthState {
final String message; final String message;
} }
/// The two ways into this terminal. /// The two shapes this terminal can take.
/// ///
/// Store-level credentials, not a person's: they are replaced wholesale when /// No longer a credential — the back office owns those now. This is the mode
/// the terminal is registered against a real back office. Staff PINs — the /// the shell runs in, decided from the role the login response came back with
/// credential that actually opens a till drawer — are not here. They live /// (see [PosSession.isCashier]).
/// hashed in the database.
/// ///
/// The split is what the two roles are *for*, not decoration: /// The split is what the two are *for*, not decoration:
/// ///
/// * [admin] runs the whole shell and is the only login that can pull the /// * [admin] runs the whole shell and is the only mode 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 signing out
/// takes the catalogue with it. /// takes the catalogue with it.
enum TerminalLogin { enum TerminalLogin {
admin( admin(
label: 'Admin', label: 'Admin',
email: 'admin@nearle.in',
password: 'nearle123',
role: StaffRole.admin, role: StaffRole.admin,
blurb: 'Full shell — import products, promos, 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 admin 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;
@@ -92,27 +92,12 @@ enum TerminalLogin {
/// 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;
static TerminalLogin? byEmail(String email) { /// Which shell the back office's role name lands in.
final normalised = email.trim().toLowerCase(); static TerminalLogin forSession(PosSession session) =>
for (final login in TerminalLogin.values) { session.isCashier ? TerminalLogin.cashier : TerminalLogin.admin;
if (login.email == normalised) return login;
}
return null;
}
} }
/// Kept for the store record, which is keyed on the outlet's own address. /// Validates store credentials against the back office and holds the session.
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';
}
/// Validates store credentials and holds the signed-in session.
class AuthController extends StateNotifier<AuthState> { class AuthController extends StateNotifier<AuthState> {
AuthController(this._ref) : super(const Unauthenticated()); AuthController(this._ref) : super(const Unauthenticated());
@@ -127,48 +112,143 @@ class AuthController extends StateNotifier<AuthState> {
return current is Authenticated && current.login.clearsCatalogueOnSignOut; return current is Authenticated && current.login.clearsCatalogueOnSignOut;
} }
/// The live bearer token, or null when nobody is signed in.
String? get token {
final current = state;
return current is Authenticated ? current.session.token : null;
}
/// Signs in against `POST /pos/login`.
///
/// [authname] is the account the back office issued for this till, e.g.
/// `supervisor.1135@pos.nearle.in`. The role that comes back — not anything
/// chosen on this screen — decides whether the terminal opens the admin
/// shell or the cashier till.
Future<bool> signIn({ Future<bool> signIn({
required String email, required String authname,
required String password, required String password,
}) async { }) async {
state = const Authenticating(); state = const Authenticating();
// Stand-in for the network round trip. try {
await Future<void>.delayed(const Duration(milliseconds: 600)); final session = await _ref.read(posAuthApiProvider).login(
authname: authname,
password: password,
deviceId: _ref.read(terminalIdentityProvider).deviceId,
);
final login = TerminalLogin.byEmail(email); await _open(session, persist: true);
return true;
if (login == null) { } on AuthApiException catch (e) {
state = const AuthFailure('No account is registered against that email.'); state = AuthFailure(e.message);
return false; return false;
} } on Object catch (e, stack) {
debugPrint('Sign-in failed: $e\n$stack');
if (password != login.password) {
state = const AuthFailure('Incorrect password. Please try again.');
return false;
}
final store = await _ref.read(storeAccountProvider.future);
final staff = store.staff;
if (staff.isEmpty) {
state = const AuthFailure( state = const AuthFailure(
'This terminal has no staff accounts. Reinstall to seed them.', 'Sign-in failed unexpectedly. Please try again.',
); );
return false; return false;
} }
}
// Whoever on this terminal matches the role that just signed in. Falls /// Re-opens the session stored on this terminal, if there is a live one.
// back rather than failing: the session's permissions come from [login], ///
// so a shop with no cashier row still gets a usable till — the bills are /// Called once at startup, before the first frame, so a till that was signed
// just stamped with the account that is there. /// in when it lost power comes back up on the same shell rather than at a
final opener = staff.firstWhere( /// login screen someone has to find the credentials for.
(s) => s.role == login.role, ///
orElse: () => staff.first, /// Returns false — and leaves the terminal signed out — when there is no
/// session, or the token has expired.
Future<bool> restore() async {
final session = await _ref.read(sessionStoreProvider).read();
if (session == null) return false;
try {
// Already on disk, so nothing to persist. Details are re-applied because
// a shop that changed its GSTIN in the back office should not print the
// old one just because this terminal never signed out.
await _open(session, persist: false);
return true;
} on Object catch (e, stack) {
debugPrint('Session restore failed: $e\n$stack');
await _ref.read(sessionStoreProvider).clear();
state = const Unauthenticated();
return false;
}
}
/// Turns a session into a live shell.
Future<void> _open(PosSession session, {required bool persist}) async {
if (persist) await _ref.read(sessionStoreProvider).save(session);
await _applyStoreDetails(session);
// Read directly rather than through `storeAccountProvider`: that provider
// watches this controller, so going through it here would rebuild it in
// the middle of the sign-in that is about to populate it. Setting the
// state below is what refreshes it, once.
final store = await _ref.read(storeRepositoryProvider).load(
email: session.authname,
);
state = Authenticated(
store: store,
// The operator is whoever the back office says signed in, not a local
// seeded account that happens to share a role.
user: session.user,
login: TerminalLogin.forSession(session),
session: session,
); );
}
state = Authenticated(store: store, user: opener, login: login); /// Copies the outlet's details out of the login response into this
return true; /// terminal's own record.
///
/// The name, address, GSTIN and phone are printed on every invoice, where
/// they are a legal requirement rather than decoration — so the back office
/// is the source of truth for them, and a correction made there reaches the
/// till on the next sign-in. Blank fields are skipped, so a partial response
/// never erases details that are already right.
///
/// Delete this method if the terminal should keep whatever was typed into
/// Settings instead; nothing else depends on it.
Future<void> _applyStoreDetails(PosSession session) async {
final catalogue = _ref.read(localStoreProvider).catalogue;
Future<void> put(String key, String value) async {
if (value.trim().isEmpty) return;
await catalogue.setMeta(key, value.trim());
}
await put(MetaKeys.storeName, session.locationName);
await put(MetaKeys.storeAddress, session.address);
await put(MetaKeys.storeGstin, session.gstin.toUpperCase());
await put(MetaKeys.storePhone, session.phone);
await _followOutlet(session);
}
/// Re-points the terminal at the outlet the session belongs to.
///
/// The outlet id namespaces every sync topic, so a till moved between shops
/// would otherwise keep publishing its bills into the previous shop's books.
///
/// Goes through the identity store rather than writing the meta row alone:
/// the in-memory [TerminalIdentity] is what `syncConfigProvider` reads, and a
/// row on disk that nothing has re-read is a change that appears to have
/// worked and has not.
Future<void> _followOutlet(PosSession session) async {
if (session.storeId.trim().isEmpty) return;
final local = _ref.read(localStoreProvider);
if (local.terminal.storeId == session.storeId) return;
await local.identityStore.rename(storeId: session.storeId);
local.terminal = await local.identityStore.load();
// Rebuilds the sync configuration, and with it the catalogue source and
// the order transport, onto the new outlet's topics.
_ref.invalidate(terminalIdentityProvider);
} }
/// Switches the active operator, checking their PIN. /// Switches the active operator, checking their PIN.
@@ -190,6 +270,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;
} }
@@ -199,20 +280,27 @@ class AuthController extends StateNotifier<AuthState> {
final current = state; final current = state;
if (current is! Authenticated) return; if (current is! Authenticated) return;
final store = await _ref.read(storeRepositoryProvider).load(
email: current.session.authname,
);
_ref.invalidate(storeAccountProvider); _ref.invalidate(storeAccountProvider);
final store = await _ref.read(storeAccountProvider.future);
// The signed-in account comes from the back office and is not in this
// terminal's staff table, so a miss here means "not a local operator",
// not "deactivated". Only a local operator who has actually disappeared
// hands the session back to the account that opened it.
final me = store.staff.where((s) => s.id == current.user.id); final me = store.staff.where((s) => s.id == current.user.id);
state = Authenticated( state = Authenticated(
store: store, store: store,
// Signed out if the active operator was just deactivated — carrying on user: me.isNotEmpty ? me.first : current.session.user,
// would keep stamping bills with an account the shop has revoked.
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, drops the stored copy of it, and — for a cashier only —
/// takes the catalogue with it.
/// ///
/// Every cashier sign-out drops the products, whatever the reason for it. /// Every cashier sign-out drops the products, whatever the reason for it.
/// The next shift should bill against what the back office answers with, /// The next shift should bill against what the back office answers with,
@@ -226,6 +314,12 @@ class AuthController extends StateNotifier<AuthState> {
if (clearsCatalogueOnSignOut) { if (clearsCatalogueOnSignOut) {
await _ref.read(localStoreProvider).clearCatalogue(); await _ref.read(localStoreProvider).clearCatalogue();
} }
// Unconditional, and before the state change: the token and the staff PINs
// in that blob must not survive a sign-out, and nothing below may be able
// to leave them on disk.
await _ref.read(sessionStoreProvider).clear();
state = const Unauthenticated(); state = const Unauthenticated();
} }
@@ -250,7 +344,20 @@ final currentUserProvider = Provider<StaffUser?>((ref) {
return s is Authenticated ? s.user : null; return s is Authenticated ? s.user : null;
}); });
/// Which credential is holding this session open, or null before sign-in. /// What the back office answered with, or null before sign-in.
///
/// Read this for the bearer token, the tenant, or the outlet list.
final posSessionProvider = Provider<PosSession?>((ref) {
final s = ref.watch(authControllerProvider);
return s is Authenticated ? s.session : null;
});
/// The account this terminal is signed in as.
final sessionAuthnameProvider = Provider<String>(
(ref) => ref.watch(posSessionProvider)?.authname ?? '',
);
/// Which mode 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);
return s is Authenticated ? s.login : null; return s is Authenticated ? s.login : null;
@@ -273,3 +380,11 @@ final mustChangePinProvider = Provider<bool>((ref) {
final user = ref.watch(currentUserProvider); final user = ref.watch(currentUserProvider);
return user?.mustChangePin ?? false; return user?.mustChangePin ?? false;
}); });
/// Re-opens a stored session before the first frame.
///
/// Awaited by the app shell, so the router never briefly shows a login screen
/// to a terminal that was already signed in.
final sessionBootstrapProvider = FutureProvider<void>(
(ref) => ref.read(authControllerProvider.notifier).restore(),
);

View File

@@ -1,5 +1,3 @@
import 'dart:ui' as 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';
@@ -10,12 +8,11 @@ import '../../../core/constants/asset_paths.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';
/// Store sign-in. The terminal shows this until a valid account is entered. /// Store sign-in. The terminal shows this until the back office issues a
/// session.
/// ///
/// One centred card on a plain background, at every width. The split-screen /// One centred card on a plain background, at every width. The split-screen
/// version put a marketing panel beside the form, which meant the thing the /// version put a marketing panel beside the form, which meant the thing the
@@ -23,6 +20,11 @@ import '../providers/auth_controller.dart';
/// different width on every monitor, and collapsed into a different layout /// different width on every monitor, and collapsed into a different layout
/// below 1000px. A till is signed into at the start of a shift by someone who /// below 1000px. A till is signed into at the start of a shift by someone who
/// already bought the product; the pitch was costing the form its position. /// already bought the product; the pitch was costing the form its position.
///
/// There is no role picker. The role comes back in the login response and is
/// the server's to decide — a tab on this screen would only ever have been a
/// hint, and a hint that disagreed with the response would be a bug someone
/// spends an afternoon on.
class LoginScreen extends ConsumerStatefulWidget { class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key}); const LoginScreen({super.key});
@@ -33,30 +35,15 @@ class LoginScreen extends ConsumerStatefulWidget {
class _LoginScreenState extends ConsumerState<LoginScreen> { class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
/// Which of the two default accounts the tabs are pointing at. Only a final _authname = TextEditingController();
/// convenience for filling the fields — [AuthController.signIn] decides the final _password = TextEditingController();
/// role from the email that is actually submitted, so typing a different
/// address over the top still signs in as that account.
TerminalLogin _login = TerminalLogin.admin;
late final _email = TextEditingController(text: _login.email);
late final _password = TextEditingController(text: _login.password);
bool _obscure = true; bool _obscure = true;
bool _rememberTerminal = true; bool _rememberTerminal = true;
void _selectLogin(TerminalLogin login) {
setState(() {
_login = login;
_email.text = login.email;
_password.text = login.password;
});
ref.read(authControllerProvider.notifier).clearError();
}
@override @override
void dispose() { void dispose() {
_email.dispose(); _authname.dispose();
_password.dispose(); _password.dispose();
super.dispose(); super.dispose();
} }
@@ -66,11 +53,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
if (!(_formKey.currentState?.validate() ?? false)) return; if (!(_formKey.currentState?.validate() ?? false)) return;
final ok = await ref.read(authControllerProvider.notifier).signIn( final ok = await ref.read(authControllerProvider.notifier).signIn(
email: _email.text, authname: _authname.text,
password: _password.text, password: _password.text,
); );
if (ok && mounted) context.go(AppRoutes.pos); if (!ok || !mounted) return;
// Admin accounts land on the full shell, cashiers on the till. Read from
// the session that was just opened rather than from anything typed here.
context.go(AppRoutes.homeFor(ref.read(terminalLoginProvider)));
} }
@override @override
@@ -109,21 +100,18 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// _Masthead(tight: tight),
SizedBox( SizedBox(
height: tight ? AppSpacing.lg : AppSpacing.xl, height: tight ? AppSpacing.lg : AppSpacing.xl,
), ),
_Card( _Card(
formKey: _formKey, formKey: _formKey,
email: _email, authname: _authname,
password: _password, password: _password,
obscure: _obscure, obscure: _obscure,
rememberTerminal: _rememberTerminal, rememberTerminal: _rememberTerminal,
login: _login,
busy: busy, busy: busy,
failure: failure:
auth is AuthFailure ? auth.message : null, auth is AuthFailure ? auth.message : null,
onSelectLogin: _selectLogin,
onToggleObscure: () => onToggleObscure: () =>
setState(() => _obscure = !_obscure), setState(() => _obscure = !_obscure),
onToggleRemember: (v) => onToggleRemember: (v) =>
@@ -157,11 +145,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
/// The shopfront behind the form. /// The shopfront behind the form.
/// ///
/// Blurred hard and darkened, because it is atmosphere rather than something /// Darkened, because it is atmosphere rather than something to read: a sharp
/// to read: a sharp photograph under a sign-in card competes with the two /// photograph under a sign-in card competes with the two fields the person came
/// fields the person came here to fill in. Scaled up slightly before the blur /// here to fill in. Scaled up slightly so the edges are pushed off-screen
/// so the softened edges are pushed off-screen instead of showing as a pale /// instead of showing as a pale border.
/// border.
/// ///
/// Falls back to the plain background colour if the asset is missing, so an /// Falls back to the plain background colour if the asset is missing, so an
/// undeclared file costs the login screen its atmosphere and not its function. /// undeclared file costs the login screen its atmosphere and not its function.
@@ -174,24 +161,21 @@ class _Backdrop extends StatelessWidget {
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
ClipRect( ClipRect(
child: ImageFiltered( child: Transform.scale(
imageFilter: ui.ImageFilter.blur(sigmaX: 0, sigmaY: 0), scale: 1.12,
child: Transform.scale( child: Image.asset(
scale: 1.12, AssetPaths.loginBackground,
child: Image.asset( fit: BoxFit.cover,
AssetPaths.loginBackground, filterQuality: FilterQuality.medium,
fit: BoxFit.cover, errorBuilder: (context, _, __) =>
filterQuality: FilterQuality.medium, const ColoredBox(color: AppColors.background),
errorBuilder: (context, _, __) =>
const ColoredBox(color: AppColors.background),
),
), ),
), ),
), ),
// Two layers, not one: the flat wash guarantees contrast for the white // Two layers, not one: the flat wash guarantees contrast wherever the
// masthead wherever the photograph happens to be pale, and the gradient // photograph happens to be pale, and the gradient puts the darkest part
// puts the darkest part behind the text rather than spreading it evenly // behind the card rather than spreading it evenly and flattening the
// and flattening the image out. // image out.
const ColoredBox(color: Color(0x8A1A0B22)), const ColoredBox(color: Color(0x8A1A0B22)),
const DecoratedBox( const DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -207,74 +191,27 @@ class _Backdrop extends StatelessWidget {
} }
} }
/// Mark and product name, directly above the card.
class _Masthead extends StatelessWidget {
const _Masthead({required this.tight});
final bool tight;
@override
Widget build(BuildContext context) {
return Column(
children: [
BrandMark(size: tight ? 48 : 60),
const SizedBox(height: AppSpacing.md),
const Text(
'Nearle POS',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
color: Colors.white,
height: 1.2,
shadows: [
Shadow(color: Color(0x66000000), blurRadius: 12),
],
),
),
if (!tight) ...[
const SizedBox(height: 2),
Text(
'Scanner-first billing for Indian retail',
style: TextStyle(
fontSize: 13,
color: Colors.white.withValues(alpha: 0.82),
shadows: const [
Shadow(color: Color(0x55000000), blurRadius: 10),
],
),
),
],
],
).animate().fadeIn(duration: 260.ms);
}
}
class _Card extends StatelessWidget { class _Card extends StatelessWidget {
const _Card({ const _Card({
required this.formKey, required this.formKey,
required this.email, required this.authname,
required this.password, required this.password,
required this.obscure, required this.obscure,
required this.rememberTerminal, required this.rememberTerminal,
required this.login,
required this.busy, required this.busy,
required this.failure, required this.failure,
required this.onSelectLogin,
required this.onToggleObscure, required this.onToggleObscure,
required this.onToggleRemember, required this.onToggleRemember,
required this.onSubmit, required this.onSubmit,
}); });
final GlobalKey<FormState> formKey; final GlobalKey<FormState> formKey;
final TextEditingController email; final TextEditingController authname;
final TextEditingController password; final TextEditingController password;
final bool obscure; final bool obscure;
final bool rememberTerminal; final bool rememberTerminal;
final TerminalLogin login;
final bool busy; final bool busy;
final String? failure; final String? failure;
final ValueChanged<TerminalLogin> onSelectLogin;
final VoidCallback onToggleObscure; final VoidCallback onToggleObscure;
final ValueChanged<bool?> onToggleRemember; final ValueChanged<bool?> onToggleRemember;
final VoidCallback onSubmit; final VoidCallback onSubmit;
@@ -313,7 +250,8 @@ class _Card extends StatelessWidget {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
const Text( const Text(
'Use the credentials issued when your outlet was registered.', 'Use the terminal account issued when your outlet was '
'registered.',
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
color: AppColors.textSecondary, color: AppColors.textSecondary,
@@ -322,33 +260,21 @@ class _Card extends StatelessWidget {
), ),
const SizedBox(height: AppSpacing.xl), const SizedBox(height: AppSpacing.xl),
_RoleSwitch( const _Label('Terminal account'),
selected: login,
enabled: !busy,
onSelect: onSelectLogin,
),
const SizedBox(height: AppSpacing.sm),
Text(
login.blurb,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textTertiary,
height: 1.45,
),
),
const SizedBox(height: AppSpacing.xl),
const _Label('Store email'),
TextFormField( TextFormField(
controller: email, controller: authname,
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
enabled: !busy, enabled: !busy,
autocorrect: false,
// Deliberately not validated as an email address. It looks like
// one, but it is an account name the back office issues and its
// shape is theirs to change.
validator: (v) => (v ?? '').trim().isEmpty validator: (v) => (v ?? '').trim().isEmpty
? 'Store email is required' ? 'The terminal account is required'
: Validators.emailOptional(v), : null,
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: 'store@example.in', hintText: 'supervisor.1135@pos.nearle.in',
prefixIcon: Icon(Icons.storefront_outlined), prefixIcon: Icon(Icons.storefront_outlined),
), ),
), ),
@@ -361,9 +287,11 @@ class _Card extends StatelessWidget {
enabled: !busy, enabled: !busy,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => onSubmit(), onFieldSubmitted: (_) => onSubmit(),
validator: (v) => (v ?? '').isEmpty // Length is the server's rule to enforce. Refusing to *send* a
? 'Password is required' // short password only produces a second, different error message
: ((v ?? '').length < 6 ? 'Password looks too short' : null), // for the same wrong credential.
validator: (v) =>
(v ?? '').isEmpty ? 'Password is required' : 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),
@@ -432,8 +360,11 @@ class _Card extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.error_outline_rounded, const Icon(
color: AppColors.danger, size: 18,), Icons.error_outline_rounded,
color: AppColors.danger,
size: 18,
),
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
Expanded( Expanded(
child: Text( child: Text(
@@ -457,12 +388,6 @@ class _Card extends StatelessWidget {
busy: busy, busy: busy,
onPressed: onSubmit, onPressed: onSubmit,
), ),
const SizedBox(height: AppSpacing.lg),
_DemoHint(
login: login,
onFill: busy ? null : () => onSelectLogin(login),
),
], ],
), ),
), ),
@@ -490,168 +415,3 @@ class _Label extends StatelessWidget {
); );
} }
} }
/// Which of the two default accounts is being signed into.
///
/// The roles are not cosmetic — they decide whether the terminal opens the
/// full shell or the billing screen alone, and whether signing out leaves the
/// products behind — so the choice is made before the credentials rather than
/// inferred from them afterwards.
class _RoleSwitch extends StatelessWidget {
const _RoleSwitch({
required this.selected,
required this.enabled,
required this.onSelect,
});
final TerminalLogin selected;
final bool enabled;
final ValueChanged<TerminalLogin> onSelect;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: AppRadius.brSm,
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
for (final login in TerminalLogin.values)
Expanded(
child: _RoleTab(
login: login,
selected: login == selected,
enabled: enabled,
onTap: () => onSelect(login),
),
),
],
),
);
}
}
class _RoleTab extends StatelessWidget {
const _RoleTab({
required this.login,
required this.selected,
required this.enabled,
required this.onTap,
});
final TerminalLogin login;
final bool selected;
final bool enabled;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final icon = login == TerminalLogin.admin
? Icons.admin_panel_settings_outlined
: Icons.point_of_sale_rounded;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: enabled ? onTap : null,
borderRadius: AppRadius.brXs,
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.surface : Colors.transparent,
borderRadius: AppRadius.brXs,
border: Border.all(
color: selected ? AppColors.primaryBorder : Colors.transparent,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 17,
color: selected ? AppColors.primary : AppColors.textSecondary,
),
const SizedBox(width: AppSpacing.sm),
Flexible(
child: Text(
login.label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color:
selected ? AppColors.primary : AppColors.textSecondary,
),
),
),
],
),
),
),
);
}
}
class _DemoHint extends StatelessWidget {
const _DemoHint({required this.login, this.onFill});
final TerminalLogin login;
final VoidCallback? onFill;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brSm,
border: Border.all(color: AppColors.primaryBorder),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.info_outline_rounded,
size: 17, color: AppColors.primary,),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Default ${login.label.toLowerCase()} account',
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
const SizedBox(height: 2),
SelectableText(
'${login.email} \u00b7 ${login.password}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
TextButton(
onPressed: onFill,
style: TextButton.styleFrom(
minimumSize: const Size(0, 32),
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
),
child: const Text('Fill', style: TextStyle(fontSize: 12.5)),
),
],
),
);
}
}