check
This commit is contained in:
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 terminal’s internet '
|
||||
'connection and try again. ($e)',
|
||||
isNetworkFault: true,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> body;
|
||||
try {
|
||||
body = jsonDecode(response.body) as Map<String, Object?>;
|
||||
} on Object {
|
||||
throw ApiException(
|
||||
'The back office answered with something this terminal could not '
|
||||
'read (${response.statusCode}).',
|
||||
statusCode: response.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
final ok = body['status'] == true && _asInt(body['code']) == 200;
|
||||
final details = body['details'];
|
||||
|
||||
if (!ok || details is! Map<String, Object?>) {
|
||||
// The server's own wording is better than anything invented here: it
|
||||
// knows whether the account is wrong, locked, or on a device the shop
|
||||
// has not registered.
|
||||
throw ApiException(
|
||||
(body['message'] as String?)?.trim().isNotEmpty ?? false
|
||||
? body['message']! as String
|
||||
: 'Sign-in was refused (${response.statusCode}).',
|
||||
statusCode: response.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
final session = LoginSession.fromDetails(details);
|
||||
|
||||
if (session.token.isEmpty) {
|
||||
throw const ApiException(
|
||||
'The back office signed this account in but returned no token, so '
|
||||
'nothing else could be fetched. Contact support.',
|
||||
);
|
||||
}
|
||||
|
||||
if (session.locationId == 0) {
|
||||
throw const ApiException(
|
||||
'The back office returned no location for this account. A terminal '
|
||||
'has to belong to an outlet before it can pull products.',
|
||||
);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
void dispose() => _client.close();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Helpers
|
||||
/// The back office is inconsistent about numbers — `store_id` arrives quoted,
|
||||
/// `location_id` bare — so both shapes are accepted everywhere.
|
||||
int? _asInt(Object? value) => switch (value) {
|
||||
int v => v,
|
||||
num v => v.toInt(),
|
||||
String v => int.tryParse(v.trim()),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
String? _nonEmpty(String? value) {
|
||||
final t = value?.trim();
|
||||
return (t == null || t.isEmpty) ? null : t;
|
||||
}
|
||||
|
||||
List<T> _list<T>(Object? raw, T Function(Map<String, Object?>) decode) {
|
||||
if (raw is! List) return const [];
|
||||
return raw.whereType<Map<String, Object?>>().map(decode).toList();
|
||||
}
|
||||
@@ -1,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);
|
||||
|
||||
@@ -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();
|
||||
@@ -54,9 +66,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
|
||||
final ok = await ref.read(authControllerProvider.notifier).signIn(
|
||||
email: _email.text,
|
||||
password: _password.text,
|
||||
);
|
||||
email: _email.text,
|
||||
password: _password.text,
|
||||
);
|
||||
|
||||
if (ok && mounted) context.go(AppRoutes.pos);
|
||||
}
|
||||
@@ -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),
|
||||
@@ -347,7 +433,7 @@ class _Card extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline_rounded,
|
||||
color: AppColors.danger, size: 18,),
|
||||
color: AppColors.danger, size: 18,),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
children: [
|
||||
entry,
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
details,
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 300, child: entry),
|
||||
const SizedBox(width: AppSpacing.xxl),
|
||||
Expanded(child: details),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_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),
|
||||
],
|
||||
);
|
||||
},
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -352,41 +352,47 @@ 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(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hint != null) ...[
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Text('($hint)',
|
||||
if (hint != null) ...[
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Text(
|
||||
'($hint)',
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: AppColors.textTertiary,
|
||||
),),
|
||||
],
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
const Spacer(),
|
||||
Text(
|
||||
value,
|
||||
style: AppTypography.money(
|
||||
14.5,
|
||||
weight: FontWeight.w600,
|
||||
color: valueColor ?? AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
Text(
|
||||
value,
|
||||
style: AppTypography.money(
|
||||
14.5,
|
||||
weight: FontWeight.w600,
|
||||
color: valueColor ?? AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (trailingIcon != null) ...[
|
||||
|
||||
Icon(trailingIcon, size: 14, color: AppColors.textTertiary),
|
||||
if (trailingIcon != null) ...[
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Icon(trailingIcon, size: 14, color: AppColors.textTertiary),
|
||||
],
|
||||
],
|
||||
],),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
? 'Upload $pending bill(s) & end shift'
|
||||
: 'End shift',
|
||||
icon: balanced ? Icons.logout_rounded : Icons.lock_outline_rounded,
|
||||
label: pending > 0
|
||||
? 'Upload $pending bill(s) & end shift'
|
||||
: 'End shift',
|
||||
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,
|
||||
|
||||
@@ -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 today’s 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);
|
||||
|
||||
Reference in New Issue
Block a user