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 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/config/api_config.dart';
import '../core/config/sync_config.dart'; import '../core/config/sync_config.dart';
import '../core/services/connectivity_service.dart'; import '../core/services/connectivity_service.dart';
import '../core/services/receipt_service.dart'; import '../core/services/receipt_service.dart';
@@ -8,7 +7,6 @@ import '../core/services/sound_service.dart';
import '../data/datasources/local_store.dart'; import '../data/datasources/local_store.dart';
import '../data/repositories/customer_repository_impl.dart'; import '../data/repositories/customer_repository_impl.dart';
import '../data/repositories/product_repository_impl.dart'; import '../data/repositories/product_repository_impl.dart';
import '../data/remote/auth_api.dart';
import '../data/remote/catalogue_source.dart'; import '../data/remote/catalogue_source.dart';
import '../data/remote/http_catalogue_source.dart'; import '../data/remote/http_catalogue_source.dart';
import '../data/remote/simulated_catalogue_source.dart'; import '../data/remote/simulated_catalogue_source.dart';
@@ -91,30 +89,14 @@ final catalogueSourceProvider = Provider<CatalogueSource>((ref) {
/// exists to prevent. /// exists to prevent.
final syncConfigProvider = StateProvider<SyncConfig>((ref) { final syncConfigProvider = StateProvider<SyncConfig>((ref) {
final terminal = ref.watch(terminalIdentityProvider); final terminal = ref.watch(terminalIdentityProvider);
final session = ref.watch(apiSessionProvider);
return SyncConfig( return SyncConfig(
transport: TransportKind.http, transport: TransportKind.http,
httpBaseUrl: ApiConfig.baseUrl, httpBaseUrl: 'https://fiesta.nearle.app/live/api/v1/pos',
// The location the sign-in was scoped to, falling back to whatever this storeId: terminal.storeId,
// device was last pointed at. Never a literal: two outlets sharing a
// store id would post sales into each other's books.
storeId: session?.storeId ?? terminal.storeId,
terminalId: terminal.code, terminalId: terminal.code,
// The bearer token from `POST /login`. Held here rather than in the
// keystore because it expires — see `LoginSession.expiresAt` — so it
// belongs to the session and goes when the session does.
apiKey: session?.token,
); );
}); });
/// Signs the terminal in. Owns its own HTTP client, closed with the provider.
final authApiProvider = Provider<AuthApi>((ref) {
final api = AuthApi();
ref.onDispose(api.dispose);
return api;
});
/// Real network state, folded with the Settings offline switch. /// Real network state, folded with the Settings offline switch.
final connectivityServiceProvider = Provider<ConnectivityService>((ref) { final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
final service = ConnectivityService( final service = ConnectivityService(
@@ -180,15 +162,11 @@ final storeRepositoryProvider = Provider<StoreRepositoryImpl>(
); );
/// The outlet, refreshed whenever staff or details change. /// The outlet, refreshed whenever staff or details change.
final storeAccountProvider = FutureProvider<StoreAccount>((ref) { final storeAccountProvider = FutureProvider<StoreAccount>(
// The address the back office signed in, so Settings and the invoice header (ref) => ref.watch(storeRepositoryProvider).load(
// show the account that actually owns this till rather than a build email: DemoCredentials.email,
// 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,
);
});
/// Campaigns stored on this terminal. /// Campaigns stored on this terminal.
final promosProvider = FutureProvider<List<Promo>>( final promosProvider = FutureProvider<List<Promo>>(
@@ -248,11 +226,9 @@ final terminalIdentityProvider = Provider<TerminalIdentity>((ref) {
final cashierSessionProvider = StateProvider<CashierSession>((ref) { final cashierSessionProvider = StateProvider<CashierSession>((ref) {
final terminal = ref.watch(terminalIdentityProvider); final terminal = ref.watch(terminalIdentityProvider);
final session = ref.watch(apiSessionProvider);
return CashierSession( return CashierSession(
name: session?.displayUserName ?? 'Operator', name: 'Suriya',
role: (session?.isAdmin ?? false) ? 'ADMIN' : 'CASHIER', role: 'ADMIN',
terminalId: terminal.code, 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. /// the login screen, the sidebar and the cashier header.
static const String logo = '$_img/logo.png'; 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 beepSuccess = '$_snd/beep_success.wav';
static const String beepError = '$_snd/beep_error.wav'; static const String beepError = '$_snd/beep_error.wav';
static const String chargeComplete = '$_snd/charge_complete.wav'; static const String chargeComplete = '$_snd/charge_complete.wav';

View File

@@ -76,17 +76,6 @@ class LocalStore {
_ready = true; _ready = true;
} }
/// Re-reads the terminal's identity after it has been re-pointed at another
/// outlet.
///
/// [terminal] is cached on this object because every bill, topic and presence
/// record reads it — but signing in can change which store the till belongs
/// to, and the cached copy would go on naming the old one until a restart.
Future<TerminalIdentity> reloadTerminal() async {
terminal = await identityStore.load();
return terminal;
}
/// Re-reads cached state from disk. Called on start and after an import. /// Re-reads cached state from disk. Called on start and after an import.
Future<void> hydrate() async { Future<void> hydrate() async {
_products _products

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({ Future<void> updateDetails({
required String id, required String id,
String? name, 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 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart'; import '../../../app/providers.dart';
import '../../../core/config/api_config.dart';
import '../../../data/local/app_database.dart';
import '../../../data/local/staff_dao.dart';
import '../../../data/remote/auth_api.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.
@@ -27,7 +23,6 @@ class Authenticated extends AuthState {
required this.store, required this.store,
required this.user, required this.user,
required this.login, required this.login,
this.session,
}); });
final StoreAccount store; final StoreAccount store;
@@ -37,25 +32,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. Null only on the offline demo path,
/// where there was no back office to answer.
///
/// Everything downstream reads from here rather than from a constant: the
/// bearer token for the catalogue pull and the order push, the location id
/// they are scoped to, and the tenant name shown beside the logo.
final LoginSession? session;
StaffRole get role => login.role; StaffRole get role => login.role;
bool get isAdmin => login == TerminalLogin.admin; bool get isAdmin => login == TerminalLogin.admin;
bool get isCashier => login == TerminalLogin.cashier; bool get isCashier => login == TerminalLogin.cashier;
/// The name beside the logo: the tenant, then the outlet, then whatever the
/// store record on this terminal says.
String get storeName {
final fromApi = session?.displayStoreName ?? '';
return fromApi.isNotEmpty ? fromApi : store.name;
}
} }
class AuthFailure extends AuthState { class AuthFailure extends AuthState {
@@ -64,19 +44,19 @@ class AuthFailure extends AuthState {
final String message; 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 /// Store-level credentials, not a person's: they are replaced wholesale when
/// [AuthController.signIn] maps its answer onto one of these. The two values /// the terminal is registered against a real back office. Staff PINs — the
/// remain because the whole shell keys off them: /// 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. /// catalogue. Signing out leaves the products on the terminal.
/// * [cashier] gets the billing screen and nothing else, and every way out of /// * [cashier] gets the billing screen and nothing else, and signing out
/// the session takes the catalogue with it. /// 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].
enum TerminalLogin { enum TerminalLogin {
admin( admin(
label: '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 { class DemoCredentials {
const DemoCredentials._(); const DemoCredentials._();
@@ -132,7 +112,7 @@ class DemoCredentials {
static const String cashierPassword = 'cashier123'; 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> { class AuthController extends StateNotifier<AuthState> {
AuthController(this._ref) : super(const Unauthenticated()); AuthController(this._ref) : super(const Unauthenticated());
@@ -147,152 +127,44 @@ class AuthController extends StateNotifier<AuthState> {
return current is Authenticated && current.login.clearsCatalogueOnSignOut; 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({ Future<bool> signIn({
required String email, required String email,
required String password, required String password,
}) async { }) async {
state = const Authenticating(); state = const Authenticating();
final store = _ref.read(localStoreProvider); // Stand-in for the network round trip.
// This till's own minted identity, not a fresh uuid — the back office can await Future<void>.delayed(const Duration(milliseconds: 600));
// recognise a terminal across restarts and refuse one it has not
// registered.
final deviceId = store.isReady ? store.terminal.deviceId : 'unopened';
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); final login = TerminalLogin.byEmail(email);
if (login == null || password != login.password) return false;
final store = await _ref.read(storeRepositoryProvider).load(email: email); if (login == null) {
if (store.staff.isEmpty) return false; 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, (s) => s.role == login.role,
orElse: () => store.staff.first, orElse: () => staff.first,
); );
state = Authenticated(store: store, user: opener, login: login); state = Authenticated(store: store, user: opener, login: login);
@@ -318,7 +190,6 @@ 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;
} }
@@ -338,24 +209,19 @@ class AuthController extends StateNotifier<AuthState> {
// would keep stamping bills with an account the shop has revoked. // would keep stamping bills with an account the shop has revoked.
user: me.isEmpty ? store.staff.first : me.first, user: me.isEmpty ? store.staff.first : me.first,
login: current.login, login: current.login,
session: current.session,
); );
} }
/// Ends the session, and — for a cashier only — the catalogue with it. /// Ends the session, and — for a cashier only — the catalogue with it.
/// ///
/// Every way out of a cashier session clears the products: ending a shift, /// Every cashier sign-out drops the products, whatever the reason for it.
/// and a temporary logout alike. There is no exception for stepping away for /// The next shift should bill against what the back office answers with,
/// ten minutes, because the terminal is left unattended either way and the /// never a catalogue carried over, and a terminal left at a login screen
/// next session should bill against what the back office answers with rather /// must not be sitting on a shop's prices and stock.
/// than a catalogue carried over.
/// ///
/// An admin signing out is the opposite case. They have just pulled the /// 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 /// products *so that* a cashier can pick the terminal up, so dropping the
/// table here would make the import pointless. /// table here would make the import pointless.
///
/// The bearer token needs no clearing: it lives on the session, so it goes
/// when the state does, and `syncConfigProvider` is derived from it.
Future<void> signOut() async { Future<void> signOut() async {
if (clearsCatalogueOnSignOut) { if (clearsCatalogueOnSignOut) {
await _ref.read(localStoreProvider).clearCatalogue(); await _ref.read(localStoreProvider).clearCatalogue();
@@ -384,31 +250,6 @@ final currentUserProvider = Provider<StaffUser?>((ref) {
return s is Authenticated ? s.user : null; return s is Authenticated ? s.user : null;
}); });
/// What the back office answered with, or null before sign-in.
///
/// `select` rather than a plain watch: the whole sync configuration is derived
/// from this, and rebuilding the transports on every intermediate auth state
/// would tear down a connection mid-request.
final apiSessionProvider = Provider<LoginSession?>((ref) {
return ref.watch(
authControllerProvider.select(
(s) => s is Authenticated ? s.session : null,
),
);
});
/// The name shown beside the logo — tenant first, then the outlet, then the
/// store record on this terminal.
final storeDisplayNameProvider = Provider<String>((ref) {
final s = ref.watch(authControllerProvider);
return s is Authenticated ? s.storeName : '';
});
/// The outlet, for the line under the store name.
final locationDisplayNameProvider = Provider<String>((ref) {
return ref.watch(apiSessionProvider)?.locationName.trim() ?? '';
});
/// Which credential is holding this session open, or null before sign-in. /// Which credential is holding this session open, or null before sign-in.
final terminalLoginProvider = Provider<TerminalLogin?>((ref) { final terminalLoginProvider = Provider<TerminalLogin?>((ref) {
final s = ref.watch(authControllerProvider); final s = ref.watch(authControllerProvider);

View File

@@ -1,4 +1,4 @@
import 'dart:ui'; 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';
@@ -6,9 +6,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../app/providers.dart'; import '../../../app/providers.dart';
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/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';
@@ -31,17 +33,27 @@ class LoginScreen extends ConsumerStatefulWidget {
class _LoginScreenState extends ConsumerState<LoginScreen> { class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
// Blank on purpose. The role tabs that used to sit above these fields chose /// Which of the two default accounts the tabs are pointing at. Only a
// between two built-in accounts and decided, locally, whether the terminal /// convenience for filling the fields — [AuthController.signIn] decides the
// opened the admin shell or the billing screen. The back office decides that /// role from the email that is actually submitted, so typing a different
// now — `POST /login` answers with the role — so offering the choice here /// address over the top still signs in as that account.
// would only let someone pick a screen the server is about to override. TerminalLogin _login = TerminalLogin.admin;
final _email = TextEditingController();
final _password = TextEditingController(); 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(); _email.dispose();
@@ -54,9 +66,9 @@ 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, email: _email.text,
password: _password.text, password: _password.text,
); );
if (ok && mounted) context.go(AppRoutes.pos); if (ok && mounted) context.go(AppRoutes.pos);
} }
@@ -68,18 +80,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
final busy = auth is Authenticating; final busy = auth is Authenticating;
return Scaffold( return Scaffold(
backgroundColor: AppColors.background,
body: Stack( body: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
// Background image const _Backdrop(),
Image.asset(
'assets/images/bg.webp',
fit: BoxFit.cover,
),
// Light blur over the image
SafeArea( SafeArea(
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, box) { builder: (context, box) {
@@ -91,8 +96,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
vertical: tight ? AppSpacing.xl : AppSpacing.xxxl, vertical: tight ? AppSpacing.xl : AppSpacing.xxxl,
), ),
child: ConstrainedBox( child: ConstrainedBox(
// Fills the viewport so the card is centred vertically, and // Fills the viewport so the card is centred vertically,
// scrolls the moment it cannot be. // and scrolls the moment it cannot be.
constraints: BoxConstraints( constraints: BoxConstraints(
minHeight: (box.maxHeight - (tight ? 40 : 64)) minHeight: (box.maxHeight - (tight ? 40 : 64))
.clamp(0.0, double.infinity), .clamp(0.0, double.infinity),
@@ -104,17 +109,21 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_Masthead(tight: tight), // _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, email: _email,
password: _password, password: _password,
obscure: _obscure, obscure: _obscure,
rememberTerminal: _rememberTerminal, rememberTerminal: _rememberTerminal,
login: _login,
busy: busy, busy: busy,
failure: auth is AuthFailure ? auth.message : null, failure:
auth is AuthFailure ? auth.message : null,
onSelectLogin: _selectLogin,
onToggleObscure: () => onToggleObscure: () =>
setState(() => _obscure = !_obscure), setState(() => _obscure = !_obscure),
onToggleRemember: (v) => onToggleRemember: (v) =>
@@ -125,9 +134,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
Center( Center(
child: Text( child: Text(
'Terminal ${session.terminalId}', 'Terminal ${session.terminalId}',
style: const TextStyle( style: TextStyle(
fontSize: 11.5, 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. /// Mark and product name, directly above the card.
class _Masthead extends StatelessWidget { class _Masthead extends StatelessWidget {
const _Masthead({required this.tight}); const _Masthead({required this.tight});
@@ -156,25 +217,31 @@ class _Masthead extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: [ children: [
// BrandMark(size: tight ? 48 : 60), BrandMark(size: tight ? 48 : 60),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
const Text( const Text(
'Nearle POS', 'Nearle POS',
style: TextStyle( style: TextStyle(
fontSize: 20, fontSize: 22,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: -0.4, letterSpacing: -0.4,
color: Colors.white, color: Colors.white,
height: 1.2, height: 1.2,
shadows: [
Shadow(color: Color(0x66000000), blurRadius: 12),
],
), ),
), ),
if (!tight) ...[ if (!tight) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
const Text( Text(
'Scanner-first billing for Indian retail', 'Scanner-first billing for Indian retail',
style: TextStyle( style: TextStyle(
fontSize: 13, 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.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,
@@ -202,8 +271,10 @@ class _Card extends StatelessWidget {
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;
@@ -218,9 +289,9 @@ class _Card extends StatelessWidget {
border: Border.all(color: AppColors.border), border: Border.all(color: AppColors.border),
boxShadow: const [ boxShadow: const [
BoxShadow( BoxShadow(
color: Color(0x0F101828), color: Color(0x33101828),
blurRadius: 24, blurRadius: 40,
offset: Offset(0, 8), offset: Offset(0, 16),
), ),
], ],
), ),
@@ -251,17 +322,31 @@ class _Card extends StatelessWidget {
), ),
const SizedBox(height: AppSpacing.xl), 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( TextFormField(
controller: email, controller: email,
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
enabled: !busy, enabled: !busy,
// Not validated as an email. The back office takes this as validator: (v) => (v ?? '').trim().isEmpty
// `authname` and accepts either form; rejecting a username here ? 'Store email is required'
// would block an account the server would have let in. : Validators.emailOptional(v),
validator: (v) =>
(v ?? '').trim().isEmpty ? 'This is required' : null,
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: 'store@example.in', hintText: 'store@example.in',
prefixIcon: Icon(Icons.storefront_outlined), prefixIcon: Icon(Icons.storefront_outlined),
@@ -276,8 +361,9 @@ class _Card extends StatelessWidget {
enabled: !busy, enabled: !busy,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => onSubmit(), onFieldSubmitted: (_) => onSubmit(),
validator: (v) => validator: (v) => (v ?? '').isEmpty
(v ?? '').isEmpty ? 'Password is required' : null, ? 'Password is required'
: ((v ?? '').length < 6 ? 'Password looks too short' : null),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Enter your password', hintText: 'Enter your password',
prefixIcon: const Icon(Icons.lock_outline_rounded), prefixIcon: const Icon(Icons.lock_outline_rounded),
@@ -347,7 +433,7 @@ class _Card extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
const Icon(Icons.error_outline_rounded, const Icon(Icons.error_outline_rounded,
color: AppColors.danger, size: 18,), color: AppColors.danger, size: 18,),
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
Expanded( Expanded(
child: Text( child: Text(
@@ -371,6 +457,12 @@ 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),
),
], ],
), ),
), ),
@@ -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 '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/customer.dart'; import '../../../domain/entities/customer.dart';
import '../../pos/providers/cart_controller.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 /// Attaches a customer to the current bill.
/// neither.
/// ///
/// Deliberately not a lookup screen. There is no search against the customers /// A mobile number, optionally a name, or skip. Nothing else — no lookup
/// already on the terminal, no recent list, and no membership tier — a cashier /// result to read, no tier, no points balance. Those were a screenful of
/// at a queue keys the number, keys the name if the shopper gives one, and /// information nobody at a counter acts on, in front of a queue, for a step
/// carries on. A number that turns out to be registered already is reused /// that is optional in the first place.
/// silently rather than being turned into a decision the counter has to make.
/// ///
/// 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) { Future<void> showCustomerCaptureSheet(BuildContext context) {
return showModalBottomSheet<void>( return showModalBottomSheet<void>(
context: context, context: context,
@@ -81,64 +83,46 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
Navigator.of(context).pop(); 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 /// The name is optional: a bare number is still worth keeping, because it is
/// is what the WhatsApp bill is sent to. /// 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
/// A number already on file is the ordinary case, not an error: the existing /// shopper has been here before to finish the sale.
/// 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.
Future<void> _save() async { Future<void> _save() async {
if (!_complete) { if (!_complete) return;
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;
setState(() { setState(() {
_saving = true; _saving = true;
_error = null; _error = null;
}); });
final repo = ref.read(customerRepositoryProvider); final typed = _name.text.trim();
final repository = ref.read(customerRepositoryProvider);
try { try {
final created = await repo.create( final existing = await repository.findByMobile(_digits);
Customer(id: '', name: name, mobile: _digits), if (existing != null) {
); if (mounted) _attachAndClose(existing);
if (mounted) _attachAndClose(created); return;
} 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.';
});
} }
} 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; if (!mounted) return;
setState(() { setState(() {
_saving = false; _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, mainAxisSize: MainAxisSize.min,
children: [ children: [
_grabber(), _grabber(),
// Everything inside is capped and centred. A modal sheet on a // Capped and centred. A modal sheet on a 27-inch till used to run
// 27-inch till used to run the full width of the screen, which // the full width of the screen, which put the keypad and the save
// put the keypad and the fields at opposite ends of the desk. // button at opposite ends of the desk.
Flexible( Flexible(
child: Center( child: Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 900), constraints: const BoxConstraints(maxWidth: 640),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -186,32 +170,75 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
AppSpacing.xxl, AppSpacing.xxl,
AppSpacing.xxl, AppSpacing.xxl,
), ),
child: LayoutBuilder( child: Column(
builder: (context, box) { mainAxisSize: MainAxisSize.min,
// Side by side once there is room for both. crossAxisAlignment: CrossAxisAlignment.stretch,
final wide = box.maxWidth >= 660; children: [
final entry = _entryColumn(); _display(),
final details = _detailsColumn(); const SizedBox(height: AppSpacing.sm),
Text(
if (!wide) { _complete
return Column( ? 'Ready to save'
children: [ : '${AppConstants.mobileNumberLength - _digits.length}'
entry, ' more digit(s)',
const SizedBox(height: AppSpacing.xl), textAlign: TextAlign.center,
details, style: const TextStyle(
], fontSize: 11.5,
); color: AppColors.textTertiary,
} ),
return Row( ),
crossAxisAlignment: const SizedBox(height: AppSpacing.lg),
CrossAxisAlignment.start, Center(
children: [ child: NumericKeypad(
SizedBox(width: 300, child: entry), maxWidth: 320,
const SizedBox(width: AppSpacing.xxl), onKey: _append,
Expanded(child: details), onBackspace: _backspace,
onClear: _clear,
),
),
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.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( const Text(
'Optional — number and name, or skip', 'Optional — the bill can be sent to this number',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 12.5, fontSize: 12.5,
@@ -286,16 +313,8 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
), ),
), ),
const SizedBox(width: AppSpacing.sm), 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( IconButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close_rounded), icon: const Icon(Icons.close_rounded),
color: AppColors.textTertiary, color: AppColors.textTertiary,
tooltip: 'Close', 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. /// The number as it is keyed, grouped 5 + 5 the way it is read aloud.
Widget _display() { Widget _display() {
final filled = _digits.isNotEmpty; final filled = _digits.isNotEmpty;
@@ -395,7 +384,7 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
), ),
if (filled) if (filled)
IconButton( IconButton(
onPressed: _saving ? null : _clear, onPressed: _clear,
icon: const Icon(Icons.backspace_outlined, size: 18), icon: const Icon(Icons.backspace_outlined, size: 18),
color: AppColors.textTertiary, color: AppColors.textTertiary,
tooltip: 'Clear', 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( return SingleChildScrollView(
padding: EdgeInsets.all(padding), padding: EdgeInsets.all(padding),
child: Column( 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, crossAxisAlignment: CrossAxisAlignment.stretch,
children: children, children: children,
), ),

View File

@@ -12,7 +12,6 @@ import '../../../core/utils/formatters.dart';
import '../../../core/widgets/glass_card.dart'; import '../../../core/widgets/glass_card.dart';
import '../../../core/widgets/numeric_keypad.dart'; import '../../../core/widgets/numeric_keypad.dart';
import '../../../core/widgets/primary_button.dart'; import '../../../core/widgets/primary_button.dart';
import '../../../core/widgets/status_pill.dart';
import '../../../domain/entities/promo.dart'; import '../../../domain/entities/promo.dart';
import '../../../domain/entities/transaction.dart'; import '../../../domain/entities/transaction.dart';
import '../../customer/widgets/customer_capture_sheet.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), const SizedBox(height: 1),

View File

@@ -183,6 +183,13 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
Expanded( Expanded(
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: AppMotion.fast, 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( child: KeyedSubtree(
key: ValueKey(module), key: ValueKey(module),
child: _body(module, layout), child: _body(module, layout),

View File

@@ -78,36 +78,13 @@ class AppSidebar extends ConsumerWidget {
} }
} }
/// Store name beside the mark. class _Brand extends StatelessWidget {
///
/// Used to read "Nearle / POS", which told the person standing at the till
/// nothing they did not already know — they can see which app is open. What is
/// worth the space is which shop and which branch this terminal is signed into,
/// because one person works several and a bill rung against the wrong outlet is
/// found the next morning. Both come from the sign-in response.
class _Brand extends ConsumerWidget {
const _Brand({required this.expanded}); const _Brand({required this.expanded});
final bool expanded; final bool expanded;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
final store = ref.watch(storeDisplayNameProvider);
final location = ref.watch(locationDisplayNameProvider);
// Before the back office has answered, fall back to whatever the store
// record on this terminal says rather than to the product's own name.
final title = store.isNotEmpty
? store
: (ref.watch(currentStoreProvider)?.name ?? 'Store');
// A single-outlet shop usually names the tenant and the branch the same
// thing; repeating it twice would look like a bug.
final subtitle =
(location.isEmpty || location.toLowerCase() == title.toLowerCase())
? 'POS'
: location;
return Container( return Container(
height: AppSizes.headerHeight, height: AppSizes.headerHeight,
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
@@ -125,11 +102,9 @@ class _Brand extends ConsumerWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( const Text(
title, 'Nearle',
maxLines: 1, style: TextStyle(
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 17, fontSize: 17,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: -0.3, letterSpacing: -0.3,
@@ -138,9 +113,7 @@ class _Brand extends ConsumerWidget {
), ),
), ),
Text( Text(
subtitle, 'POS',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.sectionLabel() style: AppTypography.sectionLabel()
.copyWith(color: AppColors.primary), .copyWith(color: AppColors.primary),
), ),

View File

@@ -352,41 +352,47 @@ class _Row extends StatelessWidget {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2), padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
child: Row( 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: [ children: [
Flexible( Flexible(
child: Text( child: Text(
label, label,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.textSecondary, color: AppColors.textSecondary,
),
), ),
), ),
), if (hint != null) ...[
if (hint != null) ...[ const SizedBox(width: AppSpacing.xs),
const SizedBox(width: AppSpacing.xs), Text(
Text('($hint)', '($hint)',
style: const TextStyle( style: const TextStyle(
fontSize: 11.5, fontSize: 11.5,
color: AppColors.textTertiary, color: AppColors.textTertiary,
),), ),
], ),
const SizedBox(width: AppSpacing.sm), ],
const Spacer(), const Spacer(),
Text( Text(
value, value,
style: AppTypography.money( style: AppTypography.money(
14.5, 14.5,
weight: FontWeight.w600, weight: FontWeight.w600,
color: valueColor ?? AppColors.textPrimary, color: valueColor ?? AppColors.textPrimary,
),
), ),
), if (trailingIcon != null) ...[
if (trailingIcon != null) ...[ const SizedBox(width: AppSpacing.xs),
Icon(trailingIcon, size: 14, color: AppColors.textTertiary),
Icon(trailingIcon, size: 14, color: AppColors.textTertiary), ],
], ],
],), ),
); );
} }
} }

View File

@@ -404,15 +404,8 @@ class _CashierBrand extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final store = ref.watch(currentStoreProvider);
final user = ref.watch(currentUserProvider); final user = ref.watch(currentUserProvider);
final store = ref.watch(storeDisplayNameProvider);
// Which shop, not which product. See `_Brand` in the sidebar — a cashier
// knows what app is open; what they cannot see is which outlet this till
// is signed into.
final title = store.isNotEmpty
? store
: (ref.watch(currentStoreProvider)?.name ?? 'Store');
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -426,8 +419,7 @@ class _CashierBrand extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
title, store?.name ?? 'Nearle POS',
maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
fontSize: 14, 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 counted = _noteCount > 0;
final short = variance < -0.5; final short = variance < -0.5;
final over = 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 final (icon, tone, message) = switch (0) {
// else — an uncounted or mismatched till is settled at the counter, with _ when !counted => (
// the person who worked it still there, not discovered by the back office Icons.info_outline_rounded,
// the next morning. AppColors.textTertiary,
final balanced = counted && !short && !over; '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( return SafeArea(
child: Container( child: Container(
@@ -231,40 +260,15 @@ class _EndShiftScreenState extends ConsumerState<EndShiftScreen> {
children: [ children: [
Row( Row(
children: [ children: [
Icon( Icon(icon, size: 16, color: tone),
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)),
),
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
Expanded( Expanded(
child: Text( child: Text(
!counted message,
? '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.',
style: const TextStyle( style: const TextStyle(
fontSize: 12.5, fontSize: 12.5,
color: AppColors.textSecondary, color: AppColors.textSecondary,
height: 1.4,
), ),
), ),
), ),
@@ -272,25 +276,23 @@ class _EndShiftScreenState extends ConsumerState<EndShiftScreen> {
), ),
const SizedBox(height: AppSpacing.sm), const SizedBox(height: AppSpacing.sm),
PrimaryButton( PrimaryButton(
label: !balanced label: pending > 0
? 'Drawer must match to end shift' ? 'Upload $pending bill(s) & end shift'
: pending > 0 : 'End shift',
? 'Upload $pending bill(s) & end shift' icon: Icons.logout_rounded,
: 'End shift',
icon: balanced ? Icons.logout_rounded : Icons.lock_outline_rounded,
large: true, large: true,
busy: _pushing, 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) onPressed: (_pushing || !balanced)
? null ? null
: () => _finish(sync: pending > 0), : () => _finish(sync: pending > 0),
), ),
if (pending > 0) ...[ if (pending > 0 && balanced) ...[
const SizedBox(height: AppSpacing.xs), const SizedBox(height: AppSpacing.xs),
TextButton( TextButton(
// Skipping the upload is still allowed; skipping the count is onPressed: _pushing ? null : () => _finish(sync: false),
// not, so this is gated on the same condition.
onPressed:
(_pushing || !balanced) ? null : () => _finish(sync: false),
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: AppColors.textSecondary, foregroundColor: AppColors.textSecondary,
), ),
@@ -659,14 +661,10 @@ class _ReviewPanel extends StatelessWidget {
'when you end the shift.', 'when you end the shift.',
), ),
_checkRow( _checkRow(
!short && !over && counted > 0, counted > 0,
counted == 0 counted > 0
? 'Drawer not counted yet — required before the shift can ' ? 'Drawer counted.'
'be ended.' : 'Drawer not counted yet.',
: (short || over)
? 'Drawer does not match the expected amount. The '
'shift stays open until it does.'
: 'Drawer matches the expected amount.',
), ),
_checkRow( _checkRow(
false, false,

View File

@@ -14,17 +14,16 @@ import '../../sync/widgets/sign_out_dialog.dart';
/// Asks what "signing out" means before doing it. /// Asks what "signing out" means before doing it.
/// ///
/// A cashier stepping off the counter for a few minutes and a cashier /// Both cashier paths clear the products — the till never keeps a catalogue
/// finishing for the day want different things from the *shift*, not from the /// across a sign-out, whatever the reason. What they differ on is the drawer:
/// terminal. A temporary logout leaves the shift open — bills stay queued and /// a temporary logout locks the screen and leaves the money alone, while
/// today's totals keep accumulating — and simply locks the screen. Ending the /// ending the shift counts it and reconciles it against what was rung.
/// shift means counting the drawer, reconciling it, and handing the till back. /// 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 /// Admins see the plain sign-out dialog: they have no drawer to settle, and
/// session never leaves a catalogue sitting on an unattended screen, so an /// their sign-out deliberately leaves the catalogue in place for whoever picks
/// admin re-imports it before the counter is worked again. /// the terminal up.
///
/// Admins see the plain sign-out dialog: they have no drawer to settle.
Future<void> showSessionEndSheet(BuildContext context, WidgetRef ref) async { Future<void> showSessionEndSheet(BuildContext context, WidgetRef ref) async {
if (!ref.read(isCashierModeProvider)) { if (!ref.read(isCashierModeProvider)) {
return showSignOutDialog(context, ref); return showSignOutDialog(context, ref);
@@ -40,20 +39,17 @@ Future<void> showSessionEndSheet(BuildContext context, WidgetRef ref) async {
class _SessionEndDialog extends ConsumerWidget { class _SessionEndDialog extends ConsumerWidget {
const _SessionEndDialog(); 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 /// Explicitly *not* a shift end — the drawer is left alone, unsynced bills
/// totals keep accumulating against the same day, so the drawer is still /// stay queued, and today's totals keep accumulating against the same day.
/// settled once, at the end. What it does not leave behind is the /// The catalogue still goes: a terminal sitting unattended at a login screen
/// catalogue — every cashier sign-out takes the products with it, this one /// must not be holding a shop's prices and stock, and an admin re-imports in
/// included, so an admin imports them again before billing resumes. /// seconds.
Future<void> _temporaryLogout(BuildContext context, WidgetRef ref) async { Future<void> _temporaryLogout(BuildContext context, WidgetRef ref) async {
ref.read(cartControllerProvider.notifier).reset(); ref.read(cartControllerProvider.notifier).reset();
await ref.read(authControllerProvider.notifier).signOut(); 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.read(catalogueVersionProvider.notifier).state++;
ref.invalidate(allProductsProvider); ref.invalidate(allProductsProvider);
ref.invalidate(visibleProductsProvider); ref.invalidate(visibleProductsProvider);
@@ -65,22 +61,8 @@ class _SessionEndDialog extends ConsumerWidget {
ref.read(selectedCategoryProvider.notifier).state = null; ref.read(selectedCategoryProvider.notifier).state = null;
if (!context.mounted) return; 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(); Navigator.of(context).pop();
context.go(AppRoutes.login); 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 @override
@@ -119,8 +101,8 @@ class _SessionEndDialog extends ConsumerWidget {
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
Expanded( Expanded(
child: Text( child: Text(
'The current bill has ${cart.lineCount} item(s) and ' 'The current bill has ${cart.lineCount} item(s), and '
'will be cleared either way.', 'the imported products are cleared either way.',
style: const TextStyle( style: const TextStyle(
fontSize: 12.5, fontSize: 12.5,
color: AppColors.warning, 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( _Choice(
icon: Icons.lock_clock_outlined, icon: Icons.lock_outline_rounded,
tone: AppColors.info, tone: AppColors.info,
title: 'Temporary logout', title: 'Temporary logout',
body: 'Locks the terminal without closing the shift. Bills ' body: 'Locks the screen. The drawer is left as it is and the '
'stay queued and todays totals keep running, so the ' 'day keeps running — an admin re-imports the products when '
'drawer is still counted once at the end.', 'you come back.',
onTap: () => _temporaryLogout(context, ref), onTap: () => _temporaryLogout(context, ref),
), ),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
@@ -178,10 +129,11 @@ class _SessionEndDialog extends ConsumerWidget {
tone: AppColors.primary, tone: AppColors.primary,
title: 'End shift', title: 'End shift',
body: pending == 0 body: pending == 0
? 'Count the drawer, check it against what was rung, then ' ? 'Count the drawer. It has to match what was rung before '
'hand the till over.' 'the till can be handed over.'
: 'Count the drawer, check it against what was rung, then ' : 'Count the drawer and upload the $pending bill(s) still '
'upload the $pending bill(s) still held here.', 'held here. The count has to match before you can sign '
'out.',
onTap: () { onTap: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
context.push(AppRoutes.endShift); context.push(AppRoutes.endShift);