This commit is contained in:
2026-08-07 15:31:20 +05:30
parent 09e5e29df2
commit ad44402232
17 changed files with 598 additions and 1187 deletions

View File

@@ -1,6 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/config/api_config.dart';
import '../core/config/sync_config.dart';
import '../core/services/connectivity_service.dart';
import '../core/services/receipt_service.dart';
@@ -8,7 +7,6 @@ import '../core/services/sound_service.dart';
import '../data/datasources/local_store.dart';
import '../data/repositories/customer_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/http_catalogue_source.dart';
import '../data/remote/simulated_catalogue_source.dart';
@@ -91,30 +89,14 @@ final catalogueSourceProvider = Provider<CatalogueSource>((ref) {
/// exists to prevent.
final syncConfigProvider = StateProvider<SyncConfig>((ref) {
final terminal = ref.watch(terminalIdentityProvider);
final session = ref.watch(apiSessionProvider);
return SyncConfig(
transport: TransportKind.http,
httpBaseUrl: ApiConfig.baseUrl,
// 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,
httpBaseUrl: 'https://fiesta.nearle.app/live/api/v1/pos',
storeId: terminal.storeId,
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.
final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
final service = ConnectivityService(
@@ -180,15 +162,11 @@ final storeRepositoryProvider = Provider<StoreRepositoryImpl>(
);
/// The outlet, refreshed whenever staff or details change.
final storeAccountProvider = FutureProvider<StoreAccount>((ref) {
// The address the back office signed in, so Settings and the invoice header
// show the account that actually owns this till rather than a build
// constant. Falls back only before anyone has signed in.
final email = ref.watch(apiSessionProvider)?.email;
return ref.watch(storeRepositoryProvider).load(
email: (email == null || email.isEmpty) ? DemoCredentials.email : email,
final storeAccountProvider = FutureProvider<StoreAccount>(
(ref) => ref.watch(storeRepositoryProvider).load(
email: DemoCredentials.email,
),
);
});
/// Campaigns stored on this terminal.
final promosProvider = FutureProvider<List<Promo>>(
@@ -248,11 +226,9 @@ final terminalIdentityProvider = Provider<TerminalIdentity>((ref) {
final cashierSessionProvider = StateProvider<CashierSession>((ref) {
final terminal = ref.watch(terminalIdentityProvider);
final session = ref.watch(apiSessionProvider);
return CashierSession(
name: session?.displayUserName ?? 'Operator',
role: (session?.isAdmin ?? false) ? 'ADMIN' : 'CASHIER',
name: 'Suriya',
role: 'ADMIN',
terminalId: terminal.code,
);
});

View File

@@ -1,41 +0,0 @@
/// 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;
}

View File

@@ -13,6 +13,10 @@ class AssetPaths {
/// the login screen, the sidebar and the cashier header.
static const String logo = '$_img/logo.png';
/// Shopfront photograph behind the sign-in screen. Blurred and darkened in
/// place, so it reads as atmosphere rather than as something to look at.
static const String loginBackground = '$_img/bg.webp';
static const String beepSuccess = '$_snd/beep_success.wav';
static const String beepError = '$_snd/beep_error.wav';
static const String chargeComplete = '$_snd/charge_complete.wav';

View File

@@ -76,17 +76,6 @@ class LocalStore {
_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.
Future<void> hydrate() async {
_products

View File

@@ -146,87 +146,6 @@ 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({
required String id,
String? name,

View File

@@ -1,378 +0,0 @@
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 terminals 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();
}

View File

@@ -1,10 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.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/remote/auth_api.dart';
import '../../../domain/entities/store_account.dart';
/// Sign-in state for the terminal.
@@ -27,7 +23,6 @@ class Authenticated extends AuthState {
required this.store,
required this.user,
required this.login,
this.session,
});
final StoreAccount store;
@@ -37,25 +32,10 @@ class Authenticated extends AuthState {
/// is allowed to show — not [user], which can be swapped at the till.
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;
bool get isAdmin => login == TerminalLogin.admin;
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 {
@@ -64,19 +44,19 @@ class AuthFailure extends AuthState {
final String message;
}
/// What this terminal is allowed to open.
/// The two ways into this terminal.
///
/// No longer a credential — the back office decides the role now, and
/// [AuthController.signIn] maps its answer onto one of these. The two values
/// remain because the whole shell keys off them:
/// Store-level credentials, not a person's: they are replaced wholesale when
/// the terminal is registered against a real back office. Staff PINs — the
/// credential that actually opens a till drawer — are not here. They live
/// hashed in the database.
///
/// * [admin] runs the full shell and is the only login that can pull the
/// The split is what the two roles are *for*, not decoration:
///
/// * [admin] runs the whole shell and is the only login that can pull the
/// catalogue. Signing out leaves the products on the terminal.
/// * [cashier] gets the billing screen and nothing else, and every way out of
/// the session takes the catalogue with it.
///
/// The email and password fields are the built-in demo accounts, used only by
/// the offline path — see [ApiConfig.allowOfflineDemoLogin].
/// * [cashier] gets the billing screen and nothing else, and signing out
/// takes the catalogue with it.
enum TerminalLogin {
admin(
label: 'Admin',
@@ -121,7 +101,7 @@ enum TerminalLogin {
}
}
/// The built-in accounts, used only by the offline path.
/// Kept for the store record, which is keyed on the outlet's own address.
class DemoCredentials {
const DemoCredentials._();
@@ -132,7 +112,7 @@ class DemoCredentials {
static const String cashierPassword = 'cashier123';
}
/// Signs the terminal in against the back office and holds the session.
/// Validates store credentials and holds the signed-in session.
class AuthController extends StateNotifier<AuthState> {
AuthController(this._ref) : super(const Unauthenticated());
@@ -147,152 +127,44 @@ class AuthController extends StateNotifier<AuthState> {
return current is Authenticated && current.login.clearsCatalogueOnSignOut;
}
/// Signs in against `POST /login`.
///
/// The back office decides everything the terminal then does: the role that
/// picks admin shell or billing screen, the location the catalogue is pulled
/// for, and the token every later call carries. Nothing here is chosen at
/// the login screen any more.
Future<bool> signIn({
required String email,
required String password,
}) async {
state = const Authenticating();
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';
// Stand-in for the network round trip.
await Future<void>.delayed(const Duration(milliseconds: 600));
LoginSession session;
try {
session = await _ref.read(authApiProvider).login(
authname: email,
password: password,
deviceId: deviceId,
);
} 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);
return false;
}
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 true;
}
/// Writes everything the session decided into the terminal, then opens it.
///
/// Order matters. The store id and terminal identity are written first,
/// because `syncConfigProvider` is derived from them and from the session —
/// setting the session last means the catalogue and order transports are
/// rebuilt once, already pointing at the right location with the right
/// token.
Future<void> _applySession(LoginSession session) async {
final store = _ref.read(localStoreProvider);
final catalogue = store.catalogue;
// 1. The outlet, as the back office describes it. These are printed on
// every GST invoice, so they come from the server rather than from the
// build's constants.
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);
}
// 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.
await store.identityStore.rename(storeId: session.storeId);
await store.reloadTerminal();
_ref.invalidate(terminalIdentityProvider);
// 3. The staff list, so PIN switching at the counter works against the
// people head office actually employs.
final me = await _syncStaff(session);
// 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,
);
state = Authenticated(
store: account,
user: me,
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,
);
}
// 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
// every bill is stamped with a staff id.
return dao.upsertFromServer(
id: StaffDao.serverId(session.userId),
name: session.displayUserName,
role: session.staffRole,
pin: session.whoAmI?.pin,
);
}
/// The built-in accounts, for a terminal with no line to the back office.
///
/// Development only — see [ApiConfig.allowOfflineDemoLogin]. It reaches no
/// server, so it sets no token: the catalogue cannot be pulled and bills
/// cannot be pushed until a real sign-in happens.
Future<bool> _signInOffline(String email, String password) async {
final login = TerminalLogin.byEmail(email);
if (login == null || password != login.password) return false;
final store = await _ref.read(storeRepositoryProvider).load(email: email);
if (store.staff.isEmpty) return false;
if (login == null) {
state = const AuthFailure('No account is registered against that email.');
return false;
}
final opener = store.staff.firstWhere(
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(
'This terminal has no staff accounts. Reinstall to seed them.',
);
return false;
}
// Whoever on this terminal matches the role that just signed in. Falls
// 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
// just stamped with the account that is there.
final opener = staff.firstWhere(
(s) => s.role == login.role,
orElse: () => store.staff.first,
orElse: () => staff.first,
);
state = Authenticated(store: store, user: opener, login: login);
@@ -318,7 +190,6 @@ class AuthController extends StateNotifier<AuthState> {
store: current.store,
user: user,
login: current.login,
session: current.session,
);
return true;
}
@@ -338,24 +209,19 @@ class AuthController extends StateNotifier<AuthState> {
// would keep stamping bills with an account the shop has revoked.
user: me.isEmpty ? store.staff.first : me.first,
login: current.login,
session: current.session,
);
}
/// Ends the session, and — for a cashier only — the catalogue with it.
///
/// Every way out of a cashier session clears the products: ending a shift,
/// and a temporary logout alike. There is no exception for stepping away for
/// ten minutes, because the terminal is left unattended either way and the
/// next session should bill against what the back office answers with rather
/// than a catalogue carried over.
/// Every cashier sign-out drops the products, whatever the reason for it.
/// The next shift should bill against what the back office answers with,
/// never a catalogue carried over, and a terminal left at a login screen
/// must not be sitting on a shop's prices and stock.
///
/// 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 {
if (clearsCatalogueOnSignOut) {
await _ref.read(localStoreProvider).clearCatalogue();
@@ -384,31 +250,6 @@ final currentUserProvider = Provider<StaffUser?>((ref) {
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.
final terminalLoginProvider = Provider<TerminalLogin?>((ref) {
final s = ref.watch(authControllerProvider);

View File

@@ -1,4 +1,4 @@
import 'dart:ui';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
@@ -6,9 +6,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/providers.dart';
import '../../../core/constants/asset_paths.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.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 '../providers/auth_controller.dart';
@@ -31,17 +33,27 @@ class LoginScreen extends ConsumerStatefulWidget {
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
// Blank on purpose. The role tabs that used to sit above these fields chose
// between two built-in accounts and decided, locally, whether the terminal
// 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 _password = TextEditingController();
/// Which of the two default accounts the tabs are pointing at. Only a
/// convenience for filling the fields — [AuthController.signIn] decides the
/// 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 _rememberTerminal = true;
void _selectLogin(TerminalLogin login) {
setState(() {
_login = login;
_email.text = login.email;
_password.text = login.password;
});
ref.read(authControllerProvider.notifier).clearError();
}
@override
void dispose() {
_email.dispose();
@@ -68,18 +80,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
final busy = auth is Authenticating;
return Scaffold(
backgroundColor: AppColors.background,
body: Stack(
fit: StackFit.expand,
children: [
// Background image
Image.asset(
'assets/images/bg.webp',
fit: BoxFit.cover,
),
// Light blur over the image
const _Backdrop(),
SafeArea(
child: LayoutBuilder(
builder: (context, box) {
@@ -91,8 +96,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
vertical: tight ? AppSpacing.xl : AppSpacing.xxxl,
),
child: ConstrainedBox(
// Fills the viewport so the card is centred vertically, and
// scrolls the moment it cannot be.
// Fills the viewport so the card is centred vertically,
// and scrolls the moment it cannot be.
constraints: BoxConstraints(
minHeight: (box.maxHeight - (tight ? 40 : 64))
.clamp(0.0, double.infinity),
@@ -104,17 +109,21 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Masthead(tight: tight),
// _Masthead(tight: tight),
SizedBox(
height: tight ? AppSpacing.lg : AppSpacing.xl,),
height: tight ? AppSpacing.lg : AppSpacing.xl,
),
_Card(
formKey: _formKey,
email: _email,
password: _password,
obscure: _obscure,
rememberTerminal: _rememberTerminal,
login: _login,
busy: busy,
failure: auth is AuthFailure ? auth.message : null,
failure:
auth is AuthFailure ? auth.message : null,
onSelectLogin: _selectLogin,
onToggleObscure: () =>
setState(() => _obscure = !_obscure),
onToggleRemember: (v) =>
@@ -125,9 +134,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
Center(
child: Text(
'Terminal ${session.terminalId}',
style: const TextStyle(
style: TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
color: Colors.white.withValues(alpha: 0.72),
),
),
),
@@ -146,6 +155,58 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
}
}
/// The shopfront behind the form.
///
/// Blurred hard and darkened, because it is atmosphere rather than something
/// to read: a sharp photograph under a sign-in card competes with the two
/// fields the person came here to fill in. Scaled up slightly before the blur
/// so the softened edges are pushed off-screen instead of showing as a pale
/// border.
///
/// 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.
class _Backdrop extends StatelessWidget {
const _Backdrop();
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
ClipRect(
child: ImageFiltered(
imageFilter: ui.ImageFilter.blur(sigmaX: 0, sigmaY: 0),
child: Transform.scale(
scale: 1.12,
child: Image.asset(
AssetPaths.loginBackground,
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
errorBuilder: (context, _, __) =>
const ColoredBox(color: AppColors.background),
),
),
),
),
// Two layers, not one: the flat wash guarantees contrast for the white
// masthead wherever the photograph happens to be pale, and the gradient
// puts the darkest part behind the text rather than spreading it evenly
// and flattening the image out.
const ColoredBox(color: Color(0x8A1A0B22)),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x66000000), Color(0x22000000), Color(0x77000000)],
),
),
),
],
);
}
}
/// Mark and product name, directly above the card.
class _Masthead extends StatelessWidget {
const _Masthead({required this.tight});
@@ -156,25 +217,31 @@ class _Masthead extends StatelessWidget {
Widget build(BuildContext context) {
return Column(
children: [
// BrandMark(size: tight ? 48 : 60),
BrandMark(size: tight ? 48 : 60),
const SizedBox(height: AppSpacing.md),
const Text(
'Nearle POS',
style: TextStyle(
fontSize: 20,
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),
const Text(
Text(
'Scanner-first billing for Indian retail',
style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
color: Colors.white.withValues(alpha: 0.82),
shadows: const [
Shadow(color: Color(0x55000000), blurRadius: 10),
],
),
),
],
@@ -190,8 +257,10 @@ class _Card extends StatelessWidget {
required this.password,
required this.obscure,
required this.rememberTerminal,
required this.login,
required this.busy,
required this.failure,
required this.onSelectLogin,
required this.onToggleObscure,
required this.onToggleRemember,
required this.onSubmit,
@@ -202,8 +271,10 @@ class _Card extends StatelessWidget {
final TextEditingController password;
final bool obscure;
final bool rememberTerminal;
final TerminalLogin login;
final bool busy;
final String? failure;
final ValueChanged<TerminalLogin> onSelectLogin;
final VoidCallback onToggleObscure;
final ValueChanged<bool?> onToggleRemember;
final VoidCallback onSubmit;
@@ -218,9 +289,9 @@ class _Card extends StatelessWidget {
border: Border.all(color: AppColors.border),
boxShadow: const [
BoxShadow(
color: Color(0x0F101828),
blurRadius: 24,
offset: Offset(0, 8),
color: Color(0x33101828),
blurRadius: 40,
offset: Offset(0, 16),
),
],
),
@@ -251,17 +322,31 @@ class _Card extends StatelessWidget {
),
const SizedBox(height: AppSpacing.xl),
const _Label('Email or username'),
_RoleSwitch(
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(
controller: email,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
enabled: !busy,
// Not validated as an email. The back office takes this as
// `authname` and accepts either form; rejecting a username here
// would block an account the server would have let in.
validator: (v) =>
(v ?? '').trim().isEmpty ? 'This is required' : null,
validator: (v) => (v ?? '').trim().isEmpty
? 'Store email is required'
: Validators.emailOptional(v),
decoration: const InputDecoration(
hintText: 'store@example.in',
prefixIcon: Icon(Icons.storefront_outlined),
@@ -276,8 +361,9 @@ class _Card extends StatelessWidget {
enabled: !busy,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => onSubmit(),
validator: (v) =>
(v ?? '').isEmpty ? 'Password is required' : null,
validator: (v) => (v ?? '').isEmpty
? 'Password is required'
: ((v ?? '').length < 6 ? 'Password looks too short' : null),
decoration: InputDecoration(
hintText: 'Enter your password',
prefixIcon: const Icon(Icons.lock_outline_rounded),
@@ -371,6 +457,12 @@ class _Card extends StatelessWidget {
busy: busy,
onPressed: onSubmit,
),
const SizedBox(height: AppSpacing.lg),
_DemoHint(
login: login,
onFill: busy ? null : () => onSelectLogin(login),
),
],
),
),
@@ -399,3 +491,167 @@ 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)),
),
],
),
);
}
}

View File

@@ -11,17 +11,19 @@ import '../../../core/widgets/numeric_keypad.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/customer.dart';
import '../../pos/providers/cart_controller.dart';
import '../providers/customer_providers.dart';
/// Attaches a customer to the current bill: a mobile number, a name, or
/// neither.
/// Attaches a customer to the current bill.
///
/// Deliberately not a lookup screen. There is no search against the customers
/// already on the terminal, no recent list, and no membership tier — a cashier
/// at a queue keys the number, keys the name if the shopper gives one, and
/// carries on. A number that turns out to be registered already is reused
/// silently rather than being turned into a decision the counter has to make.
/// A mobile number, optionally a name, or skip. Nothing else — no lookup
/// result to read, no tier, no points balance. Those were a screenful of
/// information nobody at a counter acts on, in front of a queue, for a step
/// that is optional in the first place.
///
/// Nothing here blocks the sale: Skip closes it with a walk-in bill.
/// The lookup still happens; it just does not show. On save the number is
/// matched against what the terminal already holds, so a returning shopper is
/// attached to their existing record rather than duplicated — the loyalty
/// figures stay correct, they simply are not read out at the till.
Future<void> showCustomerCaptureSheet(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
@@ -81,64 +83,46 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
Navigator.of(context).pop();
}
/// Saves the number under the name given, then puts it on the bill.
/// Saves with whatever was given.
///
/// The name is optional a bare number is still worth keeping, because it
/// is what the WhatsApp bill is sent to.
///
/// A number already on file is the ordinary case, not an error: the existing
/// record is picked up and attached, and a freshly typed name is written
/// over the stored one so a correction at the counter sticks. The cashier
/// sees the same thing either way, which is the point of not having a lookup
/// step.
/// The name is optional: a bare number is still worth keeping, because it is
/// what the WhatsApp bill is sent to. An existing record wins over creating a
/// second one — silently, because a cashier does not need to be told the
/// shopper has been here before to finish the sale.
Future<void> _save() async {
if (!_complete) {
setState(() => _error = 'Enter all '
'${AppConstants.mobileNumberLength} digits of the mobile number.');
return;
}
final typed = _name.text.trim();
final name = typed.isEmpty
? 'Customer ${_digits.substring(_digits.length - 4)}'
: typed;
if (!_complete) return;
setState(() {
_saving = true;
_error = null;
});
final repo = ref.read(customerRepositoryProvider);
final typed = _name.text.trim();
final repository = ref.read(customerRepositoryProvider);
try {
final created = await repo.create(
Customer(id: '', name: name, mobile: _digits),
);
if (mounted) _attachAndClose(created);
} on StateError {
// Already registered. Reuse the row rather than making the counter
// reconcile it.
try {
final existing = await repo.findByMobile(_digits);
if (existing == null) throw StateError('lookup failed');
final updated = typed.isEmpty || typed == existing.name
? existing
: await repo.update(existing.copyWith(name: typed));
if (mounted) _attachAndClose(updated);
} catch (_) {
if (!mounted) return;
setState(() {
_saving = false;
_error = 'Could not save customer. Try again.';
});
final existing = await repository.findByMobile(_digits);
if (existing != null) {
if (mounted) _attachAndClose(existing);
return;
}
} catch (_) {
final created = await repository.create(
Customer(
id: '',
name: typed.isEmpty
? 'Customer ${_digits.substring(_digits.length - 4)}'
: typed,
mobile: _digits,
),
);
ref.invalidate(recentCustomersProvider);
if (mounted) _attachAndClose(created);
} catch (e) {
if (!mounted) return;
setState(() {
_saving = false;
_error = 'Could not save customer. Try again.';
_error = e is StateError ? e.message : 'Could not save customer.';
});
}
}
@@ -166,13 +150,13 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
mainAxisSize: MainAxisSize.min,
children: [
_grabber(),
// Everything inside is capped and centred. A modal sheet on a
// 27-inch till used to run the full width of the screen, which
// put the keypad and the fields at opposite ends of the desk.
// Capped and centred. A modal sheet on a 27-inch till used to run
// the full width of the screen, which put the keypad and the save
// button at opposite ends of the desk.
Flexible(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 900),
constraints: const BoxConstraints(maxWidth: 640),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -186,32 +170,75 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
AppSpacing.xxl,
AppSpacing.xxl,
),
child: LayoutBuilder(
builder: (context, box) {
// Side by side once there is room for both.
final wide = box.maxWidth >= 660;
final entry = _entryColumn();
final details = _detailsColumn();
if (!wide) {
return Column(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
entry,
const SizedBox(height: AppSpacing.xl),
details,
_display(),
const SizedBox(height: AppSpacing.sm),
Text(
_complete
? 'Ready to save'
: '${AppConstants.mobileNumberLength - _digits.length}'
' more digit(s)',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
),
),
const SizedBox(height: AppSpacing.lg),
Center(
child: NumericKeypad(
maxWidth: 320,
onKey: _append,
onBackspace: _backspace,
onClear: _clear,
),
),
const SizedBox(height: AppSpacing.lg),
TextField(
controller: _name,
textCapitalization:
TextCapitalization.words,
enabled: !_saving,
onSubmitted: (_) => _save(),
inputFormatters: [
LengthLimitingTextInputFormatter(60),
],
);
}
return Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
SizedBox(width: 300, child: entry),
const SizedBox(width: AppSpacing.xxl),
Expanded(child: details),
decoration: const InputDecoration(
labelText: 'Customer name',
hintText: 'Optional',
prefixIcon:
Icon(Icons.person_outline_rounded),
),
),
if (_error != null) ...[
const SizedBox(height: AppSpacing.sm),
Text(
_error!,
style: const TextStyle(
color: AppColors.danger,
fontSize: 12.5,
),
),
],
const SizedBox(height: AppSpacing.lg),
PrimaryButton(
label: 'Save & use',
icon: Icons.check_rounded,
large: true,
busy: _saving,
onPressed: _complete ? _save : null,
),
const SizedBox(height: AppSpacing.sm),
PrimaryButton(
label: 'Skip',
tone: ButtonTone.neutral,
onPressed:
_saving ? null : () => _attachAndClose(null),
),
],
);
},
),
),
),
@@ -275,7 +302,7 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
),
),
const Text(
'Optional — number and name, or skip',
'Optional — the bill can be sent to this number',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5,
@@ -286,16 +313,8 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
),
),
const SizedBox(width: AppSpacing.sm),
TextButton(
onPressed: _saving ? null : () => _attachAndClose(null),
style: TextButton.styleFrom(
foregroundColor: AppColors.textSecondary,
minimumSize: const Size(0, 38),
),
child: const Text('Skip'),
),
IconButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close_rounded),
color: AppColors.textTertiary,
tooltip: 'Close',
@@ -304,36 +323,6 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
),
);
// ----------------------------------------------------------------- Entry
Widget _entryColumn() => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_display(),
const SizedBox(height: AppSpacing.sm),
Text(
_complete
? 'Number complete'
: '${AppConstants.mobileNumberLength - _digits.length} more '
'digit(s)',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
),
),
const SizedBox(height: AppSpacing.lg),
Center(
child: NumericKeypad(
maxWidth: 300,
onKey: _append,
onBackspace: _backspace,
onClear: _clear,
),
),
],
);
/// The number as it is keyed, grouped 5 + 5 the way it is read aloud.
Widget _display() {
final filled = _digits.isNotEmpty;
@@ -395,7 +384,7 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
),
if (filled)
IconButton(
onPressed: _saving ? null : _clear,
onPressed: _clear,
icon: const Icon(Icons.backspace_outlined, size: 18),
color: AppColors.textTertiary,
tooltip: 'Clear',
@@ -404,77 +393,4 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
),
);
}
// --------------------------------------------------------------- Details
Widget _detailsColumn() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_hint(),
const SizedBox(height: AppSpacing.lg),
TextField(
controller: _name,
textCapitalization: TextCapitalization.words,
enabled: !_saving,
onSubmitted: (_) => _save(),
inputFormatters: [LengthLimitingTextInputFormatter(60)],
decoration: const InputDecoration(
labelText: 'Customer name',
hintText: 'Optional',
prefixIcon: Icon(Icons.person_outline_rounded),
),
),
if (_error != null) ...[
const SizedBox(height: AppSpacing.sm),
Text(
_error!,
style: const TextStyle(color: AppColors.danger, fontSize: 12.5),
),
],
const SizedBox(height: AppSpacing.lg),
PrimaryButton(
label: 'Save & use',
icon: Icons.person_add_alt_1_rounded,
large: true,
busy: _saving,
onPressed: _complete && !_saving ? _save : null,
),
const SizedBox(height: AppSpacing.sm),
PrimaryButton(
label: 'Skip — no customer',
tone: ButtonTone.neutral,
onPressed: _saving ? null : () => _attachAndClose(null),
),
],
);
Widget _hint() => Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: AppRadius.brLg,
border: Border.all(color: AppColors.border),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.dialpad_rounded,
size: 19, color: AppColors.textTertiary,),
SizedBox(width: AppSpacing.md),
Expanded(
child: Text(
'Key in the mobile number, add a name if the shopper gives '
'one, then save. The name is optional and the whole step can '
'be skipped — the sale is never held up by it.',
style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
height: 1.5,
),
),
),
],
),
);
}

View File

@@ -23,6 +23,12 @@ class ModulePage extends StatelessWidget {
return SingleChildScrollView(
padding: EdgeInsets.all(padding),
child: Column(
// Top-left is the resting position for every module. Both are spelled
// out rather than left to the defaults, because a Column's default
// cross-axis is centre — which is what had short pages drifting to the
// middle instead of starting at the left edge.
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),

View File

@@ -12,7 +12,6 @@ import '../../../core/utils/formatters.dart';
import '../../../core/widgets/glass_card.dart';
import '../../../core/widgets/numeric_keypad.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../core/widgets/status_pill.dart';
import '../../../domain/entities/promo.dart';
import '../../../domain/entities/transaction.dart';
import '../../customer/widgets/customer_capture_sheet.dart';
@@ -255,10 +254,6 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
),
),
),
if (customer != null) ...[
const SizedBox(width: AppSpacing.sm),
StatusPill.tier(customer.tier, dense: true),
],
],
),
const SizedBox(height: 1),

View File

@@ -183,6 +183,13 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
Expanded(
child: AnimatedSwitcher(
duration: AppMotion.fast,
// Top, not the default centre. The switcher stacks its
// children with loose constraints, so a module page
// shorter than the viewport — Promotions with two
// campaigns, Product Import before anything is pulled —
// sized itself to its content and then floated in the
// middle of the screen with dead space above it.
child: KeyedSubtree(
key: ValueKey(module),
child: _body(module, layout),

View File

@@ -78,36 +78,13 @@ class AppSidebar extends ConsumerWidget {
}
}
/// 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 {
class _Brand extends StatelessWidget {
const _Brand({required this.expanded});
final bool expanded;
@override
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;
Widget build(BuildContext context) {
return Container(
height: AppSizes.headerHeight,
padding: EdgeInsets.symmetric(
@@ -125,11 +102,9 @@ class _Brand extends ConsumerWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
const Text(
'Nearle',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
@@ -138,9 +113,7 @@ class _Brand extends ConsumerWidget {
),
),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
'POS',
style: AppTypography.sectionLabel()
.copyWith(color: AppColors.primary),
),

View File

@@ -352,7 +352,11 @@ class _Row extends StatelessWidget {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
// Start, not space-between. With space-between *and* a Spacer, the
// free width was shared out between every child — which pushed the
// slab hint away from the label it belongs to, so "GST" and "(5%, 12%)"
// read as two unrelated columns. The Spacer alone puts all the slack in
// one place, between the label group and the amount.
children: [
Flexible(
child: Text(
@@ -366,13 +370,14 @@ class _Row extends StatelessWidget {
),
if (hint != null) ...[
const SizedBox(width: AppSpacing.xs),
Text('($hint)',
Text(
'($hint)',
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
),),
),
),
],
const SizedBox(width: AppSpacing.sm),
const Spacer(),
Text(
value,
@@ -383,10 +388,11 @@ class _Row extends StatelessWidget {
),
),
if (trailingIcon != null) ...[
const SizedBox(width: AppSpacing.xs),
Icon(trailingIcon, size: 14, color: AppColors.textTertiary),
],
],),
],
),
);
}
}

View File

@@ -404,15 +404,8 @@ class _CashierBrand extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final store = ref.watch(currentStoreProvider);
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(
mainAxisSize: MainAxisSize.min,
@@ -426,8 +419,7 @@ class _CashierBrand extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
store?.name ?? 'Nearle POS',
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,

View File

@@ -199,20 +199,49 @@ class _EndShiftScreenState extends ConsumerState<EndShiftScreen> {
);
},
),
bottomNavigationBar: _bottomBar(pending, variance),
bottomNavigationBar: _bottomBar(pending, expected, variance),
);
}
Widget _bottomBar(int pending, double variance) {
/// Whether the drawer has been counted and agrees with what was rung.
///
/// Signing out is gated on this. A shift that ends with the cash unaccounted
/// for is a shift nobody can settle afterwards — the person who worked it has
/// gone home, and the difference becomes an argument rather than a number. To
/// the rupee, because that is the smallest note anyone hands over.
bool _balances(double expected) => (_countedTotal - expected).abs() < 0.5;
Widget _bottomBar(int pending, double expected, double variance) {
final counted = _noteCount > 0;
final short = variance < -0.5;
final over = variance > 0.5;
final balanced = _balances(expected);
// The gate. A shift closes on a drawer that reconciles and on nothing
// else — an uncounted or mismatched till is settled at the counter, with
// the person who worked it still there, not discovered by the back office
// the next morning.
final balanced = counted && !short && !over;
final (icon, tone, message) = switch (0) {
_ when !counted => (
Icons.info_outline_rounded,
AppColors.textTertiary,
'Count the drawer to finish. Signing out needs the count to match '
'${Formatters.money(expected)}.',
),
_ when short => (
Icons.error_outline_rounded,
AppColors.danger,
'The drawer is ${Formatters.money(variance.abs())} short. Recount, '
'or find the difference before signing out.',
),
_ when over => (
Icons.error_outline_rounded,
AppColors.warning,
'The drawer is ${Formatters.money(variance)} over. Recount, or find '
'the difference before signing out.',
),
_ => (
Icons.check_circle_outline_rounded,
AppColors.success,
'The drawer matches what was rung. You can sign out.',
),
};
return SafeArea(
child: Container(
@@ -231,40 +260,15 @@ class _EndShiftScreenState extends ConsumerState<EndShiftScreen> {
children: [
Row(
children: [
Icon(
counted
? (short || over
? Icons.error_outline_rounded
: Icons.check_circle_outline_rounded)
: Icons.info_outline_rounded,
size: 16,
color: !counted
? AppColors.textTertiary
: (short
? AppColors.danger
: (over ? AppColors.warning : AppColors.success)),
),
Icon(icon, size: 16, color: tone),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
!counted
? 'Count the drawer. The shift cannot be ended until '
'it matches the expected amount.'
: short
? 'The drawer is '
'${Formatters.money(variance.abs())} short. '
'Recount, or settle the difference — the '
'shift cannot be ended while it is off.'
: over
? 'The drawer is '
'${Formatters.money(variance)} over. '
'Recount — the shift cannot be ended '
'while it is off.'
: 'The drawer matches what was rung. Ready to '
'end the shift.',
message,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
height: 1.4,
),
),
),
@@ -272,25 +276,23 @@ class _EndShiftScreenState extends ConsumerState<EndShiftScreen> {
),
const SizedBox(height: AppSpacing.sm),
PrimaryButton(
label: !balanced
? 'Drawer must match to end shift'
: pending > 0
label: pending > 0
? 'Upload $pending bill(s) & end shift'
: 'End shift',
icon: balanced ? Icons.logout_rounded : Icons.lock_outline_rounded,
icon: Icons.logout_rounded,
large: true,
busy: _pushing,
// Disabled until the count agrees. There is deliberately no way
// past this on the screen: an override that a tired cashier can
// press at the end of a long day is not a control.
onPressed: (_pushing || !balanced)
? null
: () => _finish(sync: pending > 0),
),
if (pending > 0) ...[
if (pending > 0 && balanced) ...[
const SizedBox(height: AppSpacing.xs),
TextButton(
// Skipping the upload is still allowed; skipping the count is
// not, so this is gated on the same condition.
onPressed:
(_pushing || !balanced) ? null : () => _finish(sync: false),
onPressed: _pushing ? null : () => _finish(sync: false),
style: TextButton.styleFrom(
foregroundColor: AppColors.textSecondary,
),
@@ -659,14 +661,10 @@ class _ReviewPanel extends StatelessWidget {
'when you end the shift.',
),
_checkRow(
!short && !over && counted > 0,
counted == 0
? 'Drawer not counted yet — required before the shift can '
'be ended.'
: (short || over)
? 'Drawer does not match the expected amount. The '
'shift stays open until it does.'
: 'Drawer matches the expected amount.',
counted > 0,
counted > 0
? 'Drawer counted.'
: 'Drawer not counted yet.',
),
_checkRow(
false,

View File

@@ -14,17 +14,16 @@ import '../../sync/widgets/sign_out_dialog.dart';
/// Asks what "signing out" means before doing it.
///
/// A cashier stepping off the counter for a few minutes and a cashier
/// finishing for the day want different things from the *shift*, not from the
/// terminal. A temporary logout leaves the shift open — bills stay queued and
/// today's totals keep accumulating — and simply locks the screen. Ending the
/// shift means counting the drawer, reconciling it, and handing the till back.
/// Both cashier paths clear the products — the till never keeps a catalogue
/// across a sign-out, whatever the reason. What they differ on is the drawer:
/// a temporary logout locks the screen and leaves the money alone, while
/// ending the shift counts it and reconciles it against what was rung.
/// Treating both as one button meant either the drawer was never settled, or a
/// cashier stepping away for ten minutes had to count it first.
///
/// What both do identically: the products come off this terminal. A cashier
/// session never leaves a catalogue sitting on an unattended screen, so an
/// admin re-imports it before the counter is worked again.
///
/// Admins see the plain sign-out dialog: they have no drawer to settle.
/// Admins see the plain sign-out dialog: they have no drawer to settle, and
/// their sign-out deliberately leaves the catalogue in place for whoever picks
/// the terminal up.
Future<void> showSessionEndSheet(BuildContext context, WidgetRef ref) async {
if (!ref.read(isCashierModeProvider)) {
return showSignOutDialog(context, ref);
@@ -40,20 +39,17 @@ Future<void> showSessionEndSheet(BuildContext context, WidgetRef ref) async {
class _SessionEndDialog extends ConsumerWidget {
const _SessionEndDialog();
/// Closes the session without closing the shift.
/// Locks the screen and returns to the login screen.
///
/// Explicitly *not* a shift end: unsynced bills stay queued and today's
/// totals keep accumulating against the same day, so the drawer is still
/// settled once, at the end. What it does not leave behind is the
/// catalogue — every cashier sign-out takes the products with it, this one
/// included, so an admin imports them again before billing resumes.
/// Explicitly *not* a shift end — the drawer is left alone, unsynced bills
/// stay queued, and today's totals keep accumulating against the same day.
/// The catalogue still goes: a terminal sitting unattended at a login screen
/// must not be holding a shop's prices and stock, and an admin re-imports in
/// seconds.
Future<void> _temporaryLogout(BuildContext context, WidgetRef ref) async {
ref.read(cartControllerProvider.notifier).reset();
await ref.read(authControllerProvider.notifier).signOut();
// Bump the version so catalogueReadyProvider re-reads hasCatalogue, and
// drop the cached lists so the next session's grid does not flash this
// session's products before it re-checks what is on disk.
ref.read(catalogueVersionProvider.notifier).state++;
ref.invalidate(allProductsProvider);
ref.invalidate(visibleProductsProvider);
@@ -65,22 +61,8 @@ class _SessionEndDialog extends ConsumerWidget {
ref.read(selectedCategoryProvider.notifier).state = null;
if (!context.mounted) return;
// Resolved while this context is still mounted — the messenger lives above
// the router, so the bar survives the route change below.
final messenger = ScaffoldMessenger.of(context);
Navigator.of(context).pop();
context.go(AppRoutes.login);
messenger
..hideCurrentSnackBar()
..showSnackBar(const SnackBar(
content: Text(
'Logged out. The shift is still open, and the product catalogue has '
'been removed from this terminal.',
),
),);
}
@override
@@ -119,8 +101,8 @@ class _SessionEndDialog extends ConsumerWidget {
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
'The current bill has ${cart.lineCount} item(s) and '
'will be cleared either way.',
'The current bill has ${cart.lineCount} item(s), and '
'the imported products are cleared either way.',
style: const TextStyle(
fontSize: 12.5,
color: AppColors.warning,
@@ -132,44 +114,13 @@ class _SessionEndDialog extends ConsumerWidget {
),
),
// The one thing both choices do, said once here rather than
// repeated in each card and discovered at the next login.
Container(
margin: const EdgeInsets.only(bottom: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: const BoxDecoration(
color: AppColors.infoSurface,
borderRadius: AppRadius.brSm,
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.delete_sweep_outlined,
size: 18, color: AppColors.info,),
SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
'Either way, the product catalogue is removed from '
'this terminal. An admin imports it again before the '
'counter is worked.',
style: TextStyle(
fontSize: 12.5,
color: AppColors.info,
height: 1.45,
),
),
),
],
),
),
_Choice(
icon: Icons.lock_clock_outlined,
icon: Icons.lock_outline_rounded,
tone: AppColors.info,
title: 'Temporary logout',
body: 'Locks the terminal without closing the shift. Bills '
'stay queued and todays totals keep running, so the '
'drawer is still counted once at the end.',
body: 'Locks the screen. The drawer is left as it is and the '
'day keeps running — an admin re-imports the products when '
'you come back.',
onTap: () => _temporaryLogout(context, ref),
),
const SizedBox(height: AppSpacing.md),
@@ -178,10 +129,11 @@ class _SessionEndDialog extends ConsumerWidget {
tone: AppColors.primary,
title: 'End shift',
body: pending == 0
? 'Count the drawer, check it against what was rung, then '
'hand the till over.'
: 'Count the drawer, check it against what was rung, then '
'upload the $pending bill(s) still held here.',
? 'Count the drawer. It has to match what was rung before '
'the till can be handed over.'
: 'Count the drawer and upload the $pending bill(s) still '
'held here. The count has to match before you can sign '
'out.',
onTap: () {
Navigator.of(context).pop();
context.push(AppRoutes.endShift);