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

View File

@@ -1,10 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart';
import '../../../core/config/api_config.dart';
import '../../../data/local/app_database.dart';
import '../../../data/local/staff_dao.dart';
import '../../../data/remote/auth_api.dart';
import '../../../domain/entities/store_account.dart';
/// Sign-in state for the terminal.
@@ -27,7 +23,6 @@ class Authenticated extends AuthState {
required this.store,
required this.user,
required this.login,
this.session,
});
final StoreAccount store;
@@ -37,25 +32,10 @@ class Authenticated extends AuthState {
/// is allowed to show — not [user], which can be swapped at the till.
final TerminalLogin login;
/// What the back office answered with. Null only on the offline demo path,
/// where there was no back office to answer.
///
/// Everything downstream reads from here rather than from a constant: the
/// bearer token for the catalogue pull and the order push, the location id
/// they are scoped to, and the tenant name shown beside the logo.
final LoginSession? session;
StaffRole get role => login.role;
bool get isAdmin => login == TerminalLogin.admin;
bool get isCashier => login == TerminalLogin.cashier;
/// The name beside the logo: the tenant, then the outlet, then whatever the
/// store record on this terminal says.
String get storeName {
final fromApi = session?.displayStoreName ?? '';
return fromApi.isNotEmpty ? fromApi : store.name;
}
}
class AuthFailure extends AuthState {
@@ -64,19 +44,19 @@ class AuthFailure extends AuthState {
final String message;
}
/// What this terminal is allowed to open.
/// The two ways into this terminal.
///
/// No longer a credential — the back office decides the role now, and
/// [AuthController.signIn] maps its answer onto one of these. The two values
/// remain because the whole shell keys off them:
/// Store-level credentials, not a person's: they are replaced wholesale when
/// the terminal is registered against a real back office. Staff PINs — the
/// credential that actually opens a till drawer — are not here. They live
/// hashed in the database.
///
/// * [admin] runs the full shell and is the only login that can pull the
/// The split is what the two roles are *for*, not decoration:
///
/// * [admin] runs the whole shell and is the only login that can pull the
/// catalogue. Signing out leaves the products on the terminal.
/// * [cashier] gets the billing screen and nothing else, and every way out of
/// the session takes the catalogue with it.
///
/// The email and password fields are the built-in demo accounts, used only by
/// the offline path — see [ApiConfig.allowOfflineDemoLogin].
/// * [cashier] gets the billing screen and nothing else, and signing out
/// takes the catalogue with it.
enum TerminalLogin {
admin(
label: 'Admin',
@@ -121,7 +101,7 @@ enum TerminalLogin {
}
}
/// The built-in accounts, used only by the offline path.
/// Kept for the store record, which is keyed on the outlet's own address.
class DemoCredentials {
const DemoCredentials._();
@@ -132,7 +112,7 @@ class DemoCredentials {
static const String cashierPassword = 'cashier123';
}
/// Signs the terminal in against the back office and holds the session.
/// Validates store credentials and holds the signed-in session.
class AuthController extends StateNotifier<AuthState> {
AuthController(this._ref) : super(const Unauthenticated());
@@ -147,152 +127,44 @@ class AuthController extends StateNotifier<AuthState> {
return current is Authenticated && current.login.clearsCatalogueOnSignOut;
}
/// Signs in against `POST /login`.
///
/// The back office decides everything the terminal then does: the role that
/// picks admin shell or billing screen, the location the catalogue is pulled
/// for, and the token every later call carries. Nothing here is chosen at
/// the login screen any more.
Future<bool> signIn({
required String email,
required String password,
}) async {
state = const Authenticating();
final store = _ref.read(localStoreProvider);
// This till's own minted identity, not a fresh uuid — the back office can
// recognise a terminal across restarts and refuse one it has not
// registered.
final deviceId = store.isReady ? store.terminal.deviceId : 'unopened';
// Stand-in for the network round trip.
await Future<void>.delayed(const Duration(milliseconds: 600));
LoginSession session;
try {
session = await _ref.read(authApiProvider).login(
authname: email,
password: password,
deviceId: deviceId,
);
} on ApiException catch (e) {
// A refusal is final. Only a shop with no line at all may fall through,
// and only onto the built-in accounts — a wrong password must never
// quietly become a local sign-in.
if (ApiConfig.allowOfflineDemoLogin && e.isNetworkFault) {
final offline = await _signInOffline(email, password);
if (offline) return true;
}
state = AuthFailure(e.message);
return false;
}
try {
await _applySession(session);
} on Object catch (e) {
state = AuthFailure(
'Signed in, but this terminal could not store the store details: $e',
);
return false;
}
return true;
}
/// Writes everything the session decided into the terminal, then opens it.
///
/// Order matters. The store id and terminal identity are written first,
/// because `syncConfigProvider` is derived from them and from the session —
/// setting the session last means the catalogue and order transports are
/// rebuilt once, already pointing at the right location with the right
/// token.
Future<void> _applySession(LoginSession session) async {
final store = _ref.read(localStoreProvider);
final catalogue = store.catalogue;
// 1. The outlet, as the back office describes it. These are printed on
// every GST invoice, so they come from the server rather than from the
// build's constants.
await catalogue.setMeta(MetaKeys.storeId, session.storeId);
if (session.displayStoreName.isNotEmpty) {
await catalogue.setMeta(MetaKeys.storeName, session.displayStoreName);
}
if (session.address.isNotEmpty) {
await catalogue.setMeta(MetaKeys.storeAddress, session.address);
}
if (session.phone.isNotEmpty) {
await catalogue.setMeta(MetaKeys.storePhone, session.phone);
}
// 2. This till now belongs to that outlet. Reloaded rather than left to a
// restart: the cached identity is what every bill and topic reads.
await store.identityStore.rename(storeId: session.storeId);
await store.reloadTerminal();
_ref.invalidate(terminalIdentityProvider);
// 3. The staff list, so PIN switching at the counter works against the
// people head office actually employs.
final me = await _syncStaff(session);
// 4. Open the session. syncConfigProvider watches this, so the catalogue
// pull and the order push pick up the token and location id from here.
final account = await _ref.read(storeRepositoryProvider).load(
email: session.email.isEmpty ? DemoCredentials.email : session.email,
);
state = Authenticated(
store: account,
user: me,
login: session.isAdmin ? TerminalLogin.admin : TerminalLogin.cashier,
session: session,
);
_ref.invalidate(storeAccountProvider);
}
/// Mirrors the back office's staff list onto this terminal, and returns the
/// row for whoever just signed in.
///
/// Additive on purpose. Deactivating everyone the server did not mention
/// would lock a shop out of its own till the first time the endpoint answers
/// with an empty array — which is exactly what the sample response does.
Future<StaffUser> _syncStaff(LoginSession session) async {
final dao = _ref.read(localStoreProvider).staff;
for (final member in session.staff) {
if (member.userId == 0) continue;
await dao.upsertFromServer(
id: StaffDao.serverId(member.userId),
name: member.fullName,
role: member.staffRole,
pin: member.pin,
isActive: member.isActive,
);
}
// The person who signed in may not appear in that list — `staff` comes
// back empty for a single-operator shop. They still need a row, because
// every bill is stamped with a staff id.
return dao.upsertFromServer(
id: StaffDao.serverId(session.userId),
name: session.displayUserName,
role: session.staffRole,
pin: session.whoAmI?.pin,
);
}
/// The built-in accounts, for a terminal with no line to the back office.
///
/// Development only — see [ApiConfig.allowOfflineDemoLogin]. It reaches no
/// server, so it sets no token: the catalogue cannot be pulled and bills
/// cannot be pushed until a real sign-in happens.
Future<bool> _signInOffline(String email, String password) async {
final login = TerminalLogin.byEmail(email);
if (login == null || password != login.password) return false;
final store = await _ref.read(storeRepositoryProvider).load(email: email);
if (store.staff.isEmpty) return false;
if (login == null) {
state = const AuthFailure('No account is registered against that email.');
return false;
}
final opener = store.staff.firstWhere(
if (password != login.password) {
state = const AuthFailure('Incorrect password. Please try again.');
return false;
}
final store = await _ref.read(storeAccountProvider.future);
final staff = store.staff;
if (staff.isEmpty) {
state = const AuthFailure(
'This terminal has no staff accounts. Reinstall to seed them.',
);
return false;
}
// Whoever on this terminal matches the role that just signed in. Falls
// back rather than failing: the session's permissions come from [login],
// so a shop with no cashier row still gets a usable till — the bills are
// just stamped with the account that is there.
final opener = staff.firstWhere(
(s) => s.role == login.role,
orElse: () => store.staff.first,
orElse: () => staff.first,
);
state = Authenticated(store: store, user: opener, login: login);
@@ -318,7 +190,6 @@ class AuthController extends StateNotifier<AuthState> {
store: current.store,
user: user,
login: current.login,
session: current.session,
);
return true;
}
@@ -338,24 +209,19 @@ class AuthController extends StateNotifier<AuthState> {
// would keep stamping bills with an account the shop has revoked.
user: me.isEmpty ? store.staff.first : me.first,
login: current.login,
session: current.session,
);
}
/// Ends the session, and — for a cashier only — the catalogue with it.
///
/// Every way out of a cashier session clears the products: ending a shift,
/// and a temporary logout alike. There is no exception for stepping away for
/// ten minutes, because the terminal is left unattended either way and the
/// next session should bill against what the back office answers with rather
/// than a catalogue carried over.
/// Every cashier sign-out drops the products, whatever the reason for it.
/// The next shift should bill against what the back office answers with,
/// never a catalogue carried over, and a terminal left at a login screen
/// must not be sitting on a shop's prices and stock.
///
/// An admin signing out is the opposite case. They have just pulled the
/// products *so that* a cashier can pick the terminal up, so dropping the
/// table here would make the import pointless.
///
/// The bearer token needs no clearing: it lives on the session, so it goes
/// when the state does, and `syncConfigProvider` is derived from it.
Future<void> signOut() async {
if (clearsCatalogueOnSignOut) {
await _ref.read(localStoreProvider).clearCatalogue();
@@ -384,31 +250,6 @@ final currentUserProvider = Provider<StaffUser?>((ref) {
return s is Authenticated ? s.user : null;
});
/// What the back office answered with, or null before sign-in.
///
/// `select` rather than a plain watch: the whole sync configuration is derived
/// from this, and rebuilding the transports on every intermediate auth state
/// would tear down a connection mid-request.
final apiSessionProvider = Provider<LoginSession?>((ref) {
return ref.watch(
authControllerProvider.select(
(s) => s is Authenticated ? s.session : null,
),
);
});
/// The name shown beside the logo — tenant first, then the outlet, then the
/// store record on this terminal.
final storeDisplayNameProvider = Provider<String>((ref) {
final s = ref.watch(authControllerProvider);
return s is Authenticated ? s.storeName : '';
});
/// The outlet, for the line under the store name.
final locationDisplayNameProvider = Provider<String>((ref) {
return ref.watch(apiSessionProvider)?.locationName.trim() ?? '';
});
/// Which credential is holding this session open, or null before sign-in.
final terminalLoginProvider = Provider<TerminalLogin?>((ref) {
final s = ref.watch(authControllerProvider);

View File

@@ -1,4 +1,4 @@
import 'dart:ui';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
@@ -6,9 +6,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/providers.dart';
import '../../../core/constants/asset_paths.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/validators.dart';
import '../../../core/widgets/brand_mark.dart';
import '../../../core/widgets/primary_button.dart';
import '../providers/auth_controller.dart';
@@ -31,17 +33,27 @@ class LoginScreen extends ConsumerStatefulWidget {
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
// Blank on purpose. The role tabs that used to sit above these fields chose
// between two built-in accounts and decided, locally, whether the terminal
// opened the admin shell or the billing screen. The back office decides that
// now — `POST /login` answers with the role — so offering the choice here
// would only let someone pick a screen the server is about to override.
final _email = TextEditingController();
final _password = TextEditingController();
/// Which of the two default accounts the tabs are pointing at. Only a
/// convenience for filling the fields — [AuthController.signIn] decides the
/// role from the email that is actually submitted, so typing a different
/// address over the top still signs in as that account.
TerminalLogin _login = TerminalLogin.admin;
late final _email = TextEditingController(text: _login.email);
late final _password = TextEditingController(text: _login.password);
bool _obscure = true;
bool _rememberTerminal = true;
void _selectLogin(TerminalLogin login) {
setState(() {
_login = login;
_email.text = login.email;
_password.text = login.password;
});
ref.read(authControllerProvider.notifier).clearError();
}
@override
void dispose() {
_email.dispose();
@@ -54,9 +66,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
if (!(_formKey.currentState?.validate() ?? false)) return;
final ok = await ref.read(authControllerProvider.notifier).signIn(
email: _email.text,
password: _password.text,
);
email: _email.text,
password: _password.text,
);
if (ok && mounted) context.go(AppRoutes.pos);
}
@@ -68,18 +80,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
final busy = auth is Authenticating;
return Scaffold(
backgroundColor: AppColors.background,
body: Stack(
fit: StackFit.expand,
children: [
// Background image
Image.asset(
'assets/images/bg.webp',
fit: BoxFit.cover,
),
// Light blur over the image
const _Backdrop(),
SafeArea(
child: LayoutBuilder(
builder: (context, box) {
@@ -91,8 +96,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
vertical: tight ? AppSpacing.xl : AppSpacing.xxxl,
),
child: ConstrainedBox(
// Fills the viewport so the card is centred vertically, and
// scrolls the moment it cannot be.
// Fills the viewport so the card is centred vertically,
// and scrolls the moment it cannot be.
constraints: BoxConstraints(
minHeight: (box.maxHeight - (tight ? 40 : 64))
.clamp(0.0, double.infinity),
@@ -104,17 +109,21 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Masthead(tight: tight),
// _Masthead(tight: tight),
SizedBox(
height: tight ? AppSpacing.lg : AppSpacing.xl,),
height: tight ? AppSpacing.lg : AppSpacing.xl,
),
_Card(
formKey: _formKey,
email: _email,
password: _password,
obscure: _obscure,
rememberTerminal: _rememberTerminal,
login: _login,
busy: busy,
failure: auth is AuthFailure ? auth.message : null,
failure:
auth is AuthFailure ? auth.message : null,
onSelectLogin: _selectLogin,
onToggleObscure: () =>
setState(() => _obscure = !_obscure),
onToggleRemember: (v) =>
@@ -125,9 +134,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
Center(
child: Text(
'Terminal ${session.terminalId}',
style: const TextStyle(
style: TextStyle(
fontSize: 11.5,
color: AppColors.textTertiary,
color: Colors.white.withValues(alpha: 0.72),
),
),
),
@@ -146,6 +155,58 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
}
}
/// The shopfront behind the form.
///
/// Blurred hard and darkened, because it is atmosphere rather than something
/// to read: a sharp photograph under a sign-in card competes with the two
/// fields the person came here to fill in. Scaled up slightly before the blur
/// so the softened edges are pushed off-screen instead of showing as a pale
/// border.
///
/// Falls back to the plain background colour if the asset is missing, so an
/// undeclared file costs the login screen its atmosphere and not its function.
class _Backdrop extends StatelessWidget {
const _Backdrop();
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
ClipRect(
child: ImageFiltered(
imageFilter: ui.ImageFilter.blur(sigmaX: 0, sigmaY: 0),
child: Transform.scale(
scale: 1.12,
child: Image.asset(
AssetPaths.loginBackground,
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
errorBuilder: (context, _, __) =>
const ColoredBox(color: AppColors.background),
),
),
),
),
// Two layers, not one: the flat wash guarantees contrast for the white
// masthead wherever the photograph happens to be pale, and the gradient
// puts the darkest part behind the text rather than spreading it evenly
// and flattening the image out.
const ColoredBox(color: Color(0x8A1A0B22)),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x66000000), Color(0x22000000), Color(0x77000000)],
),
),
),
],
);
}
}
/// Mark and product name, directly above the card.
class _Masthead extends StatelessWidget {
const _Masthead({required this.tight});
@@ -156,25 +217,31 @@ class _Masthead extends StatelessWidget {
Widget build(BuildContext context) {
return Column(
children: [
// BrandMark(size: tight ? 48 : 60),
BrandMark(size: tight ? 48 : 60),
const SizedBox(height: AppSpacing.md),
const Text(
'Nearle POS',
style: TextStyle(
fontSize: 20,
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
color: Colors.white,
height: 1.2,
shadows: [
Shadow(color: Color(0x66000000), blurRadius: 12),
],
),
),
if (!tight) ...[
const SizedBox(height: 2),
const Text(
Text(
'Scanner-first billing for Indian retail',
style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
color: Colors.white.withValues(alpha: 0.82),
shadows: const [
Shadow(color: Color(0x55000000), blurRadius: 10),
],
),
),
],
@@ -190,8 +257,10 @@ class _Card extends StatelessWidget {
required this.password,
required this.obscure,
required this.rememberTerminal,
required this.login,
required this.busy,
required this.failure,
required this.onSelectLogin,
required this.onToggleObscure,
required this.onToggleRemember,
required this.onSubmit,
@@ -202,8 +271,10 @@ class _Card extends StatelessWidget {
final TextEditingController password;
final bool obscure;
final bool rememberTerminal;
final TerminalLogin login;
final bool busy;
final String? failure;
final ValueChanged<TerminalLogin> onSelectLogin;
final VoidCallback onToggleObscure;
final ValueChanged<bool?> onToggleRemember;
final VoidCallback onSubmit;
@@ -218,9 +289,9 @@ class _Card extends StatelessWidget {
border: Border.all(color: AppColors.border),
boxShadow: const [
BoxShadow(
color: Color(0x0F101828),
blurRadius: 24,
offset: Offset(0, 8),
color: Color(0x33101828),
blurRadius: 40,
offset: Offset(0, 16),
),
],
),
@@ -251,17 +322,31 @@ class _Card extends StatelessWidget {
),
const SizedBox(height: AppSpacing.xl),
const _Label('Email or username'),
_RoleSwitch(
selected: login,
enabled: !busy,
onSelect: onSelectLogin,
),
const SizedBox(height: AppSpacing.sm),
Text(
login.blurb,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textTertiary,
height: 1.45,
),
),
const SizedBox(height: AppSpacing.xl),
const _Label('Store email'),
TextFormField(
controller: email,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
enabled: !busy,
// Not validated as an email. The back office takes this as
// `authname` and accepts either form; rejecting a username here
// would block an account the server would have let in.
validator: (v) =>
(v ?? '').trim().isEmpty ? 'This is required' : null,
validator: (v) => (v ?? '').trim().isEmpty
? 'Store email is required'
: Validators.emailOptional(v),
decoration: const InputDecoration(
hintText: 'store@example.in',
prefixIcon: Icon(Icons.storefront_outlined),
@@ -276,8 +361,9 @@ class _Card extends StatelessWidget {
enabled: !busy,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => onSubmit(),
validator: (v) =>
(v ?? '').isEmpty ? 'Password is required' : null,
validator: (v) => (v ?? '').isEmpty
? 'Password is required'
: ((v ?? '').length < 6 ? 'Password looks too short' : null),
decoration: InputDecoration(
hintText: 'Enter your password',
prefixIcon: const Icon(Icons.lock_outline_rounded),
@@ -347,7 +433,7 @@ class _Card extends StatelessWidget {
child: Row(
children: [
const Icon(Icons.error_outline_rounded,
color: AppColors.danger, size: 18,),
color: AppColors.danger, size: 18,),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
@@ -371,6 +457,12 @@ class _Card extends StatelessWidget {
busy: busy,
onPressed: onSubmit,
),
const SizedBox(height: AppSpacing.lg),
_DemoHint(
login: login,
onFill: busy ? null : () => onSelectLogin(login),
),
],
),
),
@@ -399,3 +491,167 @@ class _Label extends StatelessWidget {
}
}
/// Which of the two default accounts is being signed into.
///
/// The roles are not cosmetic — they decide whether the terminal opens the
/// full shell or the billing screen alone, and whether signing out leaves the
/// products behind — so the choice is made before the credentials rather than
/// inferred from them afterwards.
class _RoleSwitch extends StatelessWidget {
const _RoleSwitch({
required this.selected,
required this.enabled,
required this.onSelect,
});
final TerminalLogin selected;
final bool enabled;
final ValueChanged<TerminalLogin> onSelect;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: AppRadius.brSm,
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
for (final login in TerminalLogin.values)
Expanded(
child: _RoleTab(
login: login,
selected: login == selected,
enabled: enabled,
onTap: () => onSelect(login),
),
),
],
),
);
}
}
class _RoleTab extends StatelessWidget {
const _RoleTab({
required this.login,
required this.selected,
required this.enabled,
required this.onTap,
});
final TerminalLogin login;
final bool selected;
final bool enabled;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final icon = login == TerminalLogin.admin
? Icons.admin_panel_settings_outlined
: Icons.point_of_sale_rounded;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: enabled ? onTap : null,
borderRadius: AppRadius.brXs,
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.surface : Colors.transparent,
borderRadius: AppRadius.brXs,
border: Border.all(
color: selected ? AppColors.primaryBorder : Colors.transparent,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 17,
color: selected ? AppColors.primary : AppColors.textSecondary,
),
const SizedBox(width: AppSpacing.sm),
Flexible(
child: Text(
login.label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color:
selected ? AppColors.primary : AppColors.textSecondary,
),
),
),
],
),
),
),
);
}
}
class _DemoHint extends StatelessWidget {
const _DemoHint({required this.login, this.onFill});
final TerminalLogin login;
final VoidCallback? onFill;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brSm,
border: Border.all(color: AppColors.primaryBorder),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.info_outline_rounded,
size: 17, color: AppColors.primary,),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Default ${login.label.toLowerCase()} account',
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
const SizedBox(height: 2),
SelectableText(
'${login.email} \u00b7 ${login.password}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
TextButton(
onPressed: onFill,
style: TextButton.styleFrom(
minimumSize: const Size(0, 32),
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
),
child: const Text('Fill', style: TextStyle(fontSize: 12.5)),
),
],
),
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -404,15 +404,8 @@ class _CashierBrand extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final store = ref.watch(currentStoreProvider);
final user = ref.watch(currentUserProvider);
final store = ref.watch(storeDisplayNameProvider);
// Which shop, not which product. See `_Brand` in the sidebar — a cashier
// knows what app is open; what they cannot see is which outlet this till
// is signed into.
final title = store.isNotEmpty
? store
: (ref.watch(currentStoreProvider)?.name ?? 'Store');
return Row(
mainAxisSize: MainAxisSize.min,
@@ -426,8 +419,7 @@ class _CashierBrand extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
store?.name ?? 'Nearle POS',
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,

View File

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

View File

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