check
This commit is contained in:
@@ -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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
/// 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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 '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);
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
],
|
||||||
],
|
],
|
||||||
],),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 today’s 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user